feat: customizable permissions, per-item Ops ACLs, area-lead roles

Migration 023 (folded into schema.sql section 15). ADDITIVE and
backward compatible: with no permission/ACL rows the app behaves
exactly as before.

Schema:
- space_role_permissions (space_id, subject, permission); ops_acl
  (space_id, entity_type secret|kb|process|area_lead, entity_id,
  role). Full RLS, writes admin/board.
- user_effective_roles(uid,sid): built-in role + custom-role slugs +
  area_lead:<id> for areas the member leads. SECURITY DEFINER.
- user_has_permission(uid,sid,perm): admin implicit-all else a grant
  via an effective role. SECURITY DEFINER.
- secrets/knowledge_base SELECT rewritten as
  `exact existing rule OR ops_acl match` (only widens; zero change
  with no ACL rows). Seed + backfill default role permissions.

Area-lead roles reuse the existing area_leads table rather than
duplicating the concept: a row is a role, lead_id NULL = Vacant,
assigning sets lead_id, the member then holds area_lead:<id>.

App:
- lib/permissions-catalog.ts (codes + per-role defaults + guardrails),
  Zod schemas, lib/actions/permissions.ts (setRolePermissions,
  setOpsAcl, createAreaLeadRole, assign/unassign/delete).
- Customize gains Permissions (role x permission matrix, admin shown
  implicit-all/locked) and Area leads (create/Vacant/assign) panels,
  modular under customize/panels/. Reusable
  components/ops/ops-acl-editor.tsx wired into the Secrets list.

Audit-found latent fixes:
- revealSecret errors on encrypted-but-no-ciphertext corruption
  instead of silently returning the stale plaintext column.
- upsertAreaLead rejects null/empty area_code (NULLs are distinct in
  unique constraints; allowed duplicate rows).

types/database.ts: added the two new tables. 271/271 tests pass,
build clean. KB-modal ACL wiring + members-row shortcut deferred and
tracked in HANDOFF.
This commit is contained in:
Moheeb Zara
2026-05-15 17:15:14 -07:00
parent 109947552f
commit 9447b5ee80
19 changed files with 1192 additions and 29 deletions
+74
View File
@@ -0,0 +1,74 @@
'use client'
import { useState } from 'react'
import { toast } from 'sonner'
import { setOpsAcl } from '@/lib/actions'
export interface AclRoleOption { value: string; label: string }
interface Props {
entityType: 'secret' | 'kb' | 'process'
entityId: string
options: AclRoleOption[]
initial: string[]
}
// Toggling roles here writes ops_acl. An empty selection means the item
// falls back to its default visibility (admin/board for secrets; the
// visibility column for KB). Selecting roles WIDENS read access to those
// roles in addition to the default.
export function OpsAclEditor({ entityType, entityId, options, initial }: Props) {
const [selected, setSelected] = useState<Set<string>>(new Set(initial))
const [saving, setSaving] = useState(false)
async function toggle(value: string, on: boolean) {
const next = new Set(selected)
if (on) next.add(value)
else next.delete(value)
setSelected(next)
setSaving(true)
const result = await setOpsAcl({ entity_type: entityType, entity_id: entityId, roles: Array.from(next) })
setSaving(false)
if (result.error) {
toast.error(result.error)
setSelected(prev => {
const back = new Set(prev)
if (on) back.delete(value)
else back.add(value)
return back
})
}
}
return (
<div className="border border-border rounded p-3 bg-background/60 mt-2">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-2">
Who can access {saving && <span className="text-primary">…</span>}
</p>
{options.length === 0 ? (
<p className="font-sans text-xs text-muted-foreground">No roles available.</p>
) : (
<div className="flex flex-wrap gap-1.5">
{options.map(o => {
const on = selected.has(o.value)
return (
<button
key={o.value}
type="button"
onClick={() => toggle(o.value, !on)}
className={`font-mono text-[10px] px-2 py-1 rounded border transition ${
on ? 'bg-primary/10 text-primary border-primary/30' : 'border-border text-muted-foreground hover:border-primary hover:text-primary'
}`}
>
{o.label}
</button>
)
})}
</div>
)}
<p className="font-sans text-[11px] text-muted-foreground mt-2">
Admins and board always have access. Empty selection keeps the default visibility.
</p>
</div>
)
}