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
+11
View File
@@ -4,6 +4,17 @@ All notable changes to this project are documented here. Format follows [Keep a
## [Unreleased]
### Added
- **Customizable permissions, per-item Ops ACLs, area-lead roles** (migration 023, additive and backward compatible):
- Customize -> Permissions: a role x permission grid. Admin holds everything implicitly and cannot be locked out. Grants per built-in/custom role.
- Per-item access control on Ops secrets (and KB/processes via the same infra): pick which roles, multiple, can access an item. Empty = existing default visibility. Secrets/KB SELECT policies were rewritten as `existing-rule OR acl-match`, so nothing changes until an admin sets an ACL.
- Customize -> Area leads: create area-lead roles, see Vacant until assigned, assign/unassign a member from a picker. The assigned member effectively holds that role, so any Ops item whose access list includes it opens to them.
- SECURITY DEFINER helpers `user_effective_roles` and `user_has_permission`.
### Fixed
- `revealSecret` no longer silently returns the stale plaintext column when a row is marked encrypted but has no ciphertext; it surfaces the corruption instead.
- `upsertAreaLead` rejects a null/empty area code (Postgres treats NULLs as distinct in unique constraints, which had allowed duplicate area-lead rows).
### Changed
- Landing page rebuilt in the `/resources` editorial aesthetic: scoped dark theme (IBM Plex Mono + Libre Baskerville, lime accent, faint grid), a server component (~150 lines, down from a 539-line client monolith), and split into `components/landing/*`. Added a single-row resource showcase of six square tiles with the project SVG logos.
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest'
import {
PERMISSIONS,
PERMISSION_CODES,
PERMISSION_GROUPS,
DEFAULT_ROLE_PERMISSIONS,
isValidPermission,
} from '@/lib/permissions-catalog'
describe('permissions catalog', () => {
it('has unique permission codes', () => {
expect(new Set(PERMISSION_CODES).size).toBe(PERMISSION_CODES.length)
})
it('every permission has a group present in PERMISSION_GROUPS', () => {
for (const p of PERMISSIONS) {
expect(PERMISSION_GROUPS).toContain(p.group)
}
})
it('isValidPermission accepts catalog codes and rejects others', () => {
expect(isValidPermission('ops.secrets.read')).toBe(true)
expect(isValidPermission('ops.kb.write')).toBe(true)
expect(isValidPermission('not.a.permission')).toBe(false)
expect(isValidPermission('')).toBe(false)
})
it('default role grants only reference real permissions', () => {
for (const [role, perms] of Object.entries(DEFAULT_ROLE_PERMISSIONS)) {
expect(role).not.toBe('admin') // admin is implicit-all, never seeded
for (const perm of perms) {
expect(isValidPermission(perm)).toBe(true)
}
}
})
it('does not grant admin defaults (admin is implicit and cannot be locked out)', () => {
expect(DEFAULT_ROLE_PERMISSIONS.admin).toBeUndefined()
})
})
+19 -1
View File
@@ -2,22 +2,35 @@
import { useState } from 'react'
import { RolesPanel } from './panels/roles-panel'
import { PermissionsPanel } from './panels/permissions-panel'
import { TiersPanel } from './panels/tiers-panel'
import { AreasPanel } from './panels/areas-panel'
import { AreaLeadsPanel } from './panels/area-leads-panel'
import { InvitesPanel } from './panels/invites-panel'
import { OnboardingPanel } from './panels/onboarding-panel'
import type { RoleLabelRow, CustomRole, Tier, Invite, Step, Area } from './panels/types'
type Section = 'roles' | 'tiers' | 'areas' | 'invites' | 'onboarding'
type Section = 'roles' | 'permissions' | 'tiers' | 'areas' | 'area-leads' | 'invites' | 'onboarding'
const SECTIONS: { key: Section; label: string }[] = [
{ key: 'roles', label: 'Roles' },
{ key: 'permissions', label: 'Permissions' },
{ key: 'tiers', label: 'Membership tiers' },
{ key: 'areas', label: 'Areas' },
{ key: 'area-leads', label: 'Area leads' },
{ key: 'invites', label: 'Invite codes' },
{ key: 'onboarding', label: 'Onboarding' },
]
interface AreaLead {
id: string
area_code: string | null
area_name: string
lead_id: string | null
lead_handle: string | null
status: string
}
interface Props {
isAdmin: boolean
roleLabels: RoleLabelRow[]
@@ -26,6 +39,9 @@ interface Props {
invites: Invite[]
onboardingSteps: Step[]
areas: Area[]
rolePerms: Array<{ subject: string; permission: string }>
areaLeads: AreaLead[]
members: Array<{ id: string; display_name: string | null; handle: string | null }>
}
export function CustomizeClient(props: Props) {
@@ -57,8 +73,10 @@ export function CustomizeClient(props: Props) {
<div className="flex-1 min-w-0">
{section === 'roles' && <RolesPanel isAdmin={props.isAdmin} roleLabels={props.roleLabels} customRoles={props.customRoles} />}
{section === 'permissions' && <PermissionsPanel isAdmin={props.isAdmin} customRoles={props.customRoles} rolePerms={props.rolePerms} />}
{section === 'tiers' && <TiersPanel isAdmin={props.isAdmin} tiers={props.tiers} />}
{section === 'areas' && <AreasPanel isAdmin={props.isAdmin} areas={props.areas} />}
{section === 'area-leads' && <AreaLeadsPanel isAdmin={props.isAdmin} areaLeads={props.areaLeads} members={props.members} />}
{section === 'invites' && <InvitesPanel isAdmin={props.isAdmin} invites={props.invites} />}
{section === 'onboarding' && <OnboardingPanel isAdmin={props.isAdmin} steps={props.onboardingSteps} />}
</div>
+9
View File
@@ -28,6 +28,9 @@ export default async function CustomizePage() {
{ data: invites },
{ data: onboardingSteps },
{ data: areas },
{ data: rolePerms },
{ data: areaLeads },
{ data: members },
] = await Promise.all([
supabase.from('space_role_labels').select('role, display_name, description, color').eq('space_id', member.space_id),
supabase.from('space_custom_roles').select('id, slug, name, description, color, sort_order').eq('space_id', member.space_id).order('sort_order'),
@@ -35,6 +38,9 @@ export default async function CustomizePage() {
supabase.from('space_invites').select('id, code, label, expires_at, max_uses, uses_count, is_enabled, created_at').eq('space_id', member.space_id).order('created_at', { ascending: false }),
supabase.from('space_onboarding_steps').select('id, step_key, step_type, title, body, config, is_enabled, is_required, is_system, sort_order').eq('space_id', member.space_id).order('sort_order'),
supabase.from('space_areas').select('id, code, name, icon, sort_order, is_archived').eq('space_id', member.space_id).order('sort_order').order('name'),
supabase.from('space_role_permissions').select('subject, permission').eq('space_id', member.space_id),
supabase.from('area_leads').select('id, area_code, area_name, lead_id, lead_handle, status').eq('space_id', member.space_id).order('area_name'),
supabase.from('space_members').select('id, display_name, handle').eq('space_id', member.space_id).in('status', ['current', 'unverified', 'late']).order('display_name'),
])
return (
@@ -46,6 +52,9 @@ export default async function CustomizePage() {
invites={(invites ?? []) as Array<{ id: string; code: string; label: string | null; expires_at: string | null; max_uses: number | null; uses_count: number; is_enabled: boolean; created_at: string }>}
onboardingSteps={(onboardingSteps ?? []) as Array<{ id: string; step_key: string; step_type: 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content'; title: string; body: string | null; config: Record<string, unknown>; is_enabled: boolean; is_required: boolean; is_system: boolean; sort_order: number }>}
areas={(areas ?? []) as Array<{ id: string; code: string; name: string; icon: string | null; sort_order: number; is_archived: boolean }>}
rolePerms={(rolePerms ?? []) as Array<{ subject: string; permission: string }>}
areaLeads={(areaLeads ?? []) as Array<{ id: string; area_code: string | null; area_name: string; lead_id: string | null; lead_handle: string | null; status: string }>}
members={(members ?? []) as Array<{ id: string; display_name: string | null; handle: string | null }>}
/>
)
}
@@ -0,0 +1,129 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { createAreaLeadRole, assignAreaLead, unassignAreaLead, deleteAreaLeadRole } from '@/lib/actions'
import { Card } from './card'
interface AreaLead {
id: string
area_code: string | null
area_name: string
lead_id: string | null
lead_handle: string | null
status: string
}
interface Member { id: string; display_name: string | null; handle: string | null }
interface Props {
isAdmin: boolean
areaLeads: AreaLead[]
members: Member[]
}
export function AreaLeadsPanel({ isAdmin, areaLeads: initial, members }: Props) {
const router = useRouter()
const [rows, setRows] = useState<AreaLead[]>(initial)
const [showNew, setShowNew] = useState(false)
const [d, setD] = useState({ area_code: '', name: '' })
function memberLabel(id: string | null) {
if (!id) return null
const m = members.find(x => x.id === id)
return m ? (m.display_name || m.handle || 'Member') : 'Member'
}
return (
<Card
title="Area-lead roles"
blurb="Each row is an area-lead role. Unassigned roles show as Vacant. Assign a member here (or from the member directory). The assigned member effectively holds this role, so any Ops item whose access list includes it becomes available to them."
action={isAdmin ? (
<button onClick={() => setShowNew(v => !v)} className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition whitespace-nowrap">
{showNew ? 'Cancel' : '+ Area-lead role'}
</button>
) : undefined}
>
{showNew && (
<form
onSubmit={async e => {
e.preventDefault()
const result = await createAreaLeadRole({ area_code: d.area_code.trim().toLowerCase(), name: d.name.trim() })
if ('error' in result && result.error) { toast.error(result.error); return }
toast.success('Area-lead role created')
const created = (result as { data?: AreaLead }).data
if (created) setRows(prev => [...prev, created])
setShowNew(false); setD({ area_code: '', name: '' })
}}
className="mb-4 p-4 border border-border rounded bg-background grid grid-cols-1 md:grid-cols-2 gap-3"
>
<input type="text" required value={d.area_code} onChange={e => setD({ ...d, area_code: e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '-') })} placeholder="area code (e.g. woodshop)" className="bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1.5 focus:outline-none focus:border-primary" />
<input type="text" required value={d.name} onChange={e => setD({ ...d, name: e.target.value })} placeholder="Role name (e.g. Woodshop Lead)" className="bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1.5 focus:outline-none focus:border-primary" />
<button type="submit" className="md:col-span-2 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition">Create</button>
</form>
)}
{rows.length === 0 ? (
<p className="font-sans text-sm text-muted-foreground py-2">No area-lead roles yet.</p>
) : (
<ul className="divide-y divide-border">
{rows.map(r => (
<li key={r.id} className="py-3 flex items-center gap-3 flex-wrap">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-sans text-sm font-medium text-foreground">{r.area_name}</span>
{r.area_code && <span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{r.area_code}</span>}
{r.lead_id ? (
<span className="font-mono text-[10px] text-primary">{memberLabel(r.lead_id)}</span>
) : (
<span className="font-mono text-[10px] text-amber-600">Vacant</span>
)}
</div>
</div>
{isAdmin && (
<div className="flex items-center gap-1.5">
<select
value={r.lead_id ?? ''}
onChange={async e => {
const memberId = e.target.value
if (!memberId) {
const res = await unassignAreaLead(r.id)
if (res.error) { toast.error(res.error); return }
setRows(prev => prev.map(x => x.id === r.id ? { ...x, lead_id: null, lead_handle: null, status: 'vacant' } : x))
toast.success('Set to vacant')
} else {
const res = await assignAreaLead({ area_lead_role_id: r.id, member_id: memberId })
if (res.error) { toast.error(res.error); return }
setRows(prev => prev.map(x => x.id === r.id ? { ...x, lead_id: memberId, status: 'active' } : x))
toast.success('Lead assigned')
router.refresh()
}
}}
className="bg-background border border-border text-foreground font-sans text-xs rounded px-2 py-1 focus:outline-none focus:border-primary"
>
<option value="">Vacant</option>
{members.map(m => (
<option key={m.id} value={m.id}>{m.display_name || m.handle || 'Member'}</option>
))}
</select>
<button
onClick={async () => {
if (!confirm(`Delete area-lead role "${r.area_name}"?`)) return
const res = await deleteAreaLeadRole(r.id)
if (res.error) { toast.error(res.error); return }
setRows(prev => prev.filter(x => x.id !== r.id))
toast.success('Deleted')
}}
className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition"
>
Delete
</button>
</div>
)}
</li>
))}
</ul>
)}
</Card>
)
}
@@ -0,0 +1,130 @@
'use client'
import { useState } from 'react'
import { toast } from 'sonner'
import { setRolePermissions } from '@/lib/actions'
import { PERMISSIONS, PERMISSION_GROUPS } from '@/lib/permissions-catalog'
import { BUILTIN_ROLES } from '@/lib/role-labels'
import { Card } from './card'
import type { CustomRole } from './types'
interface Props {
isAdmin: boolean
customRoles: CustomRole[]
rolePerms: Array<{ subject: string; permission: string }>
}
export function PermissionsPanel({ isAdmin, customRoles, rolePerms }: Props) {
// subjects: built-in roles except admin (implicit-all), plus custom roles.
const subjects = [
...BUILTIN_ROLES.filter(r => r !== 'admin').map(r => ({ key: r, label: r })),
...customRoles.map(c => ({ key: c.slug, label: c.name })),
]
const initial: Record<string, Set<string>> = {}
for (const s of subjects) initial[s.key] = new Set()
for (const rp of rolePerms) {
if (!initial[rp.subject]) initial[rp.subject] = new Set()
initial[rp.subject].add(rp.permission)
}
const [grants, setGrants] = useState(initial)
const [savingSubject, setSavingSubject] = useState<string | null>(null)
async function toggle(subject: string, permission: string, on: boolean) {
const next = new Set(grants[subject] ?? [])
if (on) next.add(permission)
else next.delete(permission)
setGrants(g => ({ ...g, [subject]: next }))
setSavingSubject(subject)
const result = await setRolePermissions({ subject, permissions: Array.from(next) })
setSavingSubject(null)
if (result.error) {
toast.error(result.error)
// revert
setGrants(g => {
const back = new Set(g[subject] ?? [])
if (on) back.delete(permission)
else back.add(permission)
return { ...g, [subject]: back }
})
}
}
return (
<Card
title="Permissions"
blurb="Grant capabilities to each role. Admin always holds every permission and cannot be locked out. Permissions widen what a role can do through the surfaces that consult them; they never override the database tenant isolation."
>
<div className="mb-4 flex items-center gap-2 flex-wrap">
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
<span className="font-sans text-xs text-muted-foreground">holds all permissions (implicit, locked)</span>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr>
<th className="text-left font-mono text-[10px] tracking-widest text-muted-foreground uppercase py-2 pr-4 sticky left-0 bg-card">Permission</th>
{subjects.map(s => (
<th key={s.key} className="px-2 py-2 font-mono text-[10px] text-muted-foreground text-center min-w-[88px]">
{s.label}{savingSubject === s.key && <span className="text-primary"> …</span>}
</th>
))}
</tr>
</thead>
<tbody>
{PERMISSION_GROUPS.map(group => (
<PermGroup
key={group}
group={group}
subjects={subjects}
grants={grants}
isAdmin={isAdmin}
onToggle={toggle}
/>
))}
</tbody>
</table>
</div>
</Card>
)
}
function PermGroup({
group, subjects, grants, isAdmin, onToggle,
}: {
group: string
subjects: { key: string; label: string }[]
grants: Record<string, Set<string>>
isAdmin: boolean
onToggle: (subject: string, permission: string, on: boolean) => void
}) {
const perms = PERMISSIONS.filter(p => p.group === group)
return (
<>
<tr>
<td colSpan={subjects.length + 1} className="pt-4 pb-1">
<span className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">{group}</span>
</td>
</tr>
{perms.map(p => (
<tr key={p.code} className="border-t border-border">
<td className="py-2 pr-4 sticky left-0 bg-card">
<span className="font-sans text-sm text-foreground">{p.label}</span>
<span className="font-mono text-[10px] text-muted-foreground/60 ml-2">{p.code}</span>
</td>
{subjects.map(s => (
<td key={s.key} className="px-2 py-2 text-center">
<input
type="checkbox"
disabled={!isAdmin}
checked={grants[s.key]?.has(p.code) ?? false}
onChange={e => onToggle(s.key, p.code, e.target.checked)}
/>
</td>
))}
</tr>
))}
</>
)
}
+64 -27
View File
@@ -7,6 +7,7 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { createKbEntry, updateKbEntry, deleteKbEntry } from '@/lib/actions'
import { revealSecret, deleteSecret } from '@/lib/actions/secrets'
import { OpsAclEditor } from '@/components/ops/ops-acl-editor'
import { createClient } from '@/lib/supabase/client'
import { toast } from 'sonner'
import type { Tables, TablesInsert } from '@/types/database'
@@ -22,6 +23,9 @@ interface Props {
areaLeads: AreaLead[]
secrets: Secret[]
canSeeSecrets: boolean
canManageAcl?: boolean
aclRoleOptions?: { value: string; label: string }[]
aclByEntity?: Record<string, string[]>
}
type Tab = 'kb' | 'processes' | 'secrets' | 'area-leads'
@@ -342,10 +346,17 @@ function AreaLeadModal({
}
// ─── Secret Row (reveal on click) ─────────────────────────────────────────────
function SecretRow({ secret, onDelete }: { secret: Secret; onDelete: (id: string) => void }) {
function SecretRow({ secret, onDelete, canManageAcl, aclRoleOptions, aclInitial }: {
secret: Secret
onDelete: (id: string) => void
canManageAcl?: boolean
aclRoleOptions?: { value: string; label: string }[]
aclInitial?: string[]
}) {
const [revealed, setRevealed] = useState(false)
const [value, setValue] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [showAcl, setShowAcl] = useState(false)
async function reveal() {
if (revealed) { setRevealed(false); setValue(null); return }
@@ -366,31 +377,50 @@ function SecretRow({ secret, onDelete }: { secret: Secret; onDelete: (id: string
}
return (
<div className="flex items-center gap-3 px-4 py-3">
<div className="w-8 h-8 rounded bg-amber-50 border border-amber-200 flex items-center justify-center flex-shrink-0">
<Lock className="w-4 h-4 text-amber-500" />
</div>
<div className="flex-1 min-w-0">
<p className="font-sans text-sm font-medium text-foreground">{secret.title}</p>
{secret.area && <p className="font-mono text-[10px] text-muted-foreground">{secret.area}</p>}
{revealed && value && (
<p className="font-mono text-xs text-foreground bg-muted px-2 py-1 rounded mt-1 break-all">{value}</p>
)}
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<button
onClick={reveal}
disabled={loading}
className="flex items-center gap-1 font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition"
title={revealed ? 'Hide' : 'Reveal'}
>
{loading ? '...' : revealed ? <EyeOff className="w-3 h-3" /> : <Eye className="w-3 h-3" />}
{revealed ? 'Hide' : 'Reveal'}
</button>
<button onClick={handleDelete} className="text-muted-foreground hover:text-red-500 transition p-1" title="Delete">
<Trash2 className="w-3.5 h-3.5" />
</button>
<div className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded bg-amber-50 border border-amber-200 flex items-center justify-center flex-shrink-0">
<Lock className="w-4 h-4 text-amber-500" />
</div>
<div className="flex-1 min-w-0">
<p className="font-sans text-sm font-medium text-foreground">{secret.title}</p>
{secret.area && <p className="font-mono text-[10px] text-muted-foreground">{secret.area}</p>}
{revealed && value && (
<p className="font-mono text-xs text-foreground bg-muted px-2 py-1 rounded mt-1 break-all">{value}</p>
)}
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{canManageAcl && (
<button
onClick={() => setShowAcl(v => !v)}
className="font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition"
title="Who can access this secret"
>
Access
</button>
)}
<button
onClick={reveal}
disabled={loading}
className="flex items-center gap-1 font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition"
title={revealed ? 'Hide' : 'Reveal'}
>
{loading ? '...' : revealed ? <EyeOff className="w-3 h-3" /> : <Eye className="w-3 h-3" />}
{revealed ? 'Hide' : 'Reveal'}
</button>
<button onClick={handleDelete} className="text-muted-foreground hover:text-red-500 transition p-1" title="Delete">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{showAcl && canManageAcl && (
<OpsAclEditor
entityType="secret"
entityId={secret.id}
options={aclRoleOptions ?? []}
initial={aclInitial ?? []}
/>
)}
</div>
)
}
@@ -504,7 +534,7 @@ function KbEntryRow({
}
// ─── Main Component ────────────────────────────────────────────────────────────
export function OpsClient({ member, spaceId, kbEntries: initial, areaLeads: initialLeads, secrets: initialSecrets, canSeeSecrets }: Props) {
export function OpsClient({ member, spaceId, kbEntries: initial, areaLeads: initialLeads, secrets: initialSecrets, canSeeSecrets, canManageAcl = false, aclRoleOptions = [], aclByEntity = {} }: Props) {
const router = useRouter()
const [activeTab, setActiveTab] = useState<Tab>('kb')
const [search, setSearch] = useState('')
@@ -722,7 +752,14 @@ export function OpsClient({ member, spaceId, kbEntries: initial, areaLeads: init
) : filteredSecrets.length > 0 ? (
<div className="bg-card rounded border border-border divide-y divide-border">
{filteredSecrets.map(s => (
<SecretRow key={s.id} secret={s} onDelete={id => setSecrets(prev => prev.filter(x => x.id !== id))} />
<SecretRow
key={s.id}
secret={s}
onDelete={id => setSecrets(prev => prev.filter(x => x.id !== id))}
canManageAcl={canManageAcl}
aclRoleOptions={aclRoleOptions}
aclInitial={aclByEntity[`secret:${s.id}`] ?? []}
/>
))}
</div>
) : (
+23 -1
View File
@@ -12,8 +12,9 @@ export default async function OpsPage() {
if (!member) return null
const canSeeSecrets = member.role === 'admin' || member.role === 'board'
const canManageAcl = member.role === 'admin' || member.role === 'board'
const [{ data: kbEntries }, { data: areaLeads }, secretsResult] = await Promise.all([
const [{ data: kbEntries }, { data: areaLeads }, secretsResult, { data: customRoles }, { data: acl }] = await Promise.all([
supabase.from('knowledge_base').select('*').eq('space_id', member.space_id).order('is_pinned', { ascending: false }).order('created_at', { ascending: false }),
supabase.from('area_leads').select('*').eq('space_id', member.space_id).order('area_name'),
canSeeSecrets
@@ -22,8 +23,26 @@ export default async function OpsPage() {
// through the revealSecret() server action.
? supabase.from('secrets').select('id, title, area, created_at, label, description, icon, space_id, created_by, updated_at, category, notes, encryption_version').eq('space_id', member.space_id)
: Promise.resolve({ data: [] as Tables<'secrets'>[] }),
supabase.from('space_custom_roles').select('slug, name').eq('space_id', member.space_id),
supabase.from('ops_acl').select('entity_type, entity_id, role').eq('space_id', member.space_id),
])
// Selectable ACL roles: built-in roles, custom-role slugs, and area-lead
// sentinels labelled by area name.
const aclRoleOptions = [
{ value: 'treasurer', label: 'treasurer' },
{ value: 'member', label: 'member' },
{ value: 'associate', label: 'associate' },
...((customRoles ?? []).map(c => ({ value: c.slug, label: c.name }))),
...((areaLeads ?? []).map(al => ({ value: `area_lead:${al.id}`, label: `${al.area_name} lead` }))),
]
const aclByEntity: Record<string, string[]> = {}
for (const row of acl ?? []) {
const k = `${row.entity_type}:${row.entity_id}`
;(aclByEntity[k] ??= []).push(row.role)
}
return (
<OpsClient
member={member as Tables<'space_members'>}
@@ -32,6 +51,9 @@ export default async function OpsPage() {
areaLeads={areaLeads ?? []}
secrets={secretsResult.data ?? []}
canSeeSecrets={canSeeSecrets}
canManageAcl={canManageAcl}
aclRoleOptions={aclRoleOptions}
aclByEntity={aclByEntity}
/>
)
}
+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>
)
}
+35
View File
@@ -4,6 +4,41 @@ Append-only. Newest entries on top. Keep each entry to one screen.
---
## 2026-05-15 (pass 15) — Permissions, Ops ACLs, area-lead roles
Branch: `main`. Migration: 023 (applies on next deploy).
### Pre-work audit (background agent)
No regressions from recent passes; routing now clean (the duplicate `app/page.tsx` was the only structural anti-pattern and it is gone). Verbatim current RLS captured. `types/database.ts` is stale for 8 prior-pass tables but masked by `ignoreBuildErrors` (type debt, not a runtime bug). Two latent Ops bugs found and fixed this pass.
### What shipped
Security-sensitive subsystem, built ADDITIVE so a space with no permission/ACL rows behaves exactly as before.
- **Migration `scripts/023_permissions.sql`** (folded into `schema.sql` section 15):
- `space_role_permissions` (space_id, subject, permission) and `ops_acl` (space_id, entity_type in secret|kb|process|area_lead, entity_id, role). Full RLS; writes admin/board.
- `user_effective_roles(uid,sid)` SECURITY DEFINER: built-in role + custom-role slugs + `area_lead:<area_leads.id>` for areas the member leads.
- `user_has_permission(uid,sid,perm)` SECURITY DEFINER: admin implicit-all, else a grant via an effective role.
- `secrets`/`knowledge_base` SELECT rewritten as `EXACT existing rule OR ops_acl match`. Zero behavior change with no ACL rows.
- Seed trigger + backfill of sensible default `space_role_permissions` (board/treasurer/member/associate; admin never stored).
- **Area-lead roles reuse the existing `area_leads` table** (decision: do not duplicate the concept). A row is a role; `lead_id IS NULL` => Vacant; assigning sets `lead_id`; the member then holds `area_lead:<id>` via `user_effective_roles`.
- `lib/permissions-catalog.ts` (fixed code list + per-role defaults + guardrails), Zod schemas, `lib/actions/permissions.ts` (setRolePermissions, setOpsAcl, createAreaLeadRole, assign/unassign/deleteAreaLeadRole).
- UI: Customize gains **Permissions** (role x permission matrix; admin shown as implicit-all/locked) and **Area leads** (create role, Vacant state, member-picker assign/unassign) panels — new modular files under `customize/panels/`. Reusable `components/ops/ops-acl-editor.tsx` wired into the Secrets list ("Access" toggle, multi-role) via `ops/page.tsx` plumbing (custom roles + area-lead sentinels as options).
- Latent bug fixes: `revealSecret` now errors on a corrupt encrypted-but-no-ciphertext row instead of silently returning the stale plaintext column; `upsertAreaLead` rejects null/empty `area_code` (Postgres treats NULLs as distinct, which allowed duplicate rows).
- `types/database.ts`: added `space_role_permissions` and `ops_acl`. Catalog unit tests (5) added; 271 tests pass.
### Deferred (tracked, honest)
- KB/process per-item ACL: infra (RLS, setOpsAcl with kb/process, OpsAclEditor) is complete; only the ops-client KB-modal wiring is not done yet (the Secrets path, the explicit ask, is wired). Mechanical follow-up.
- "Make area lead" directly from the members directory row: assignment is fully available in Customize -> Area leads; the members-list shortcut is deferred.
- Broad permission-based gating of every server action / RLS rewrite app-wide is intentionally NOT done; this pass keeps existing role checks and only OR-widens via ACLs. `user_has_permission` exists for incremental adoption.
- `types/database.ts` still stale for the other 8 prior tables (loose casts; build green).
### Verification
- `pnpm build` clean; `pnpm test` 271/271. Migration 023 is idempotent and applies via deploy.sh's tracked runner.
## 2026-05-15 (pass 14) — Landing redesign + KB search fix
Branch: `main`.
+7
View File
@@ -16,6 +16,13 @@ export async function upsertAreaLead(formData: {
const v = parseInput(upsertAreaLeadSchema, formData)
if (!v.ok) return { error: v.error }
// area_code is the (space_id, area_code) upsert conflict target. Postgres
// treats NULLs as distinct in unique constraints, so a null/empty code
// would let duplicate area-lead rows accumulate. Require it.
if (!v.data.area_code || !v.data.area_code.trim()) {
return { error: 'An area code is required.' }
}
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin access required')
if (!auth.ok) return { error: auth.error }
+3
View File
@@ -41,3 +41,6 @@ export * from './invites'
// Configurable member onboarding
export * from './onboarding'
// Customizable permissions, per-item Ops ACLs, area-lead roles
export * from './permissions'
+197
View File
@@ -0,0 +1,197 @@
'use server'
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'
import { requireMemberWithRole, parseInput } from '@/lib/auth-helpers'
import {
setRolePermissionsSchema,
setOpsAclSchema,
createAreaLeadRoleSchema,
assignAreaLeadSchema,
uuidSchema,
} from '@/lib/validations'
import { isValidPermission } from '@/lib/permissions-catalog'
const PERM_ADMIN_ROLES = ['admin', 'board'] as const
// Replace the full permission set for one role/custom-role subject.
export async function setRolePermissions(input: { subject: string; permissions: string[] }) {
const v = parseInput(setRolePermissionsSchema, input)
if (!v.ok) return { error: v.error }
const perms = v.data.permissions.filter(isValidPermission)
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, PERM_ADMIN_ROLES, 'Admin or board access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const del = await supabase
.from('space_role_permissions')
.delete()
.eq('space_id', member.space_id)
.eq('subject', v.data.subject)
if (del.error) return { error: del.error.message }
if (perms.length > 0) {
const ins = await supabase
.from('space_role_permissions')
.insert(perms.map(permission => ({ space_id: member.space_id, subject: v.data.subject, permission })))
if (ins.error) return { error: ins.error.message }
}
revalidatePath('/customize')
return { success: true as const }
}
// Replace the full ACL role list for one Ops item. Empty list => the item
// falls back to its existing visibility rule (no ACL rows).
export async function setOpsAcl(input: {
entity_type: 'secret' | 'kb' | 'process' | 'area_lead'
entity_id: string
roles: string[]
}) {
const v = parseInput(setOpsAclSchema, input)
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, PERM_ADMIN_ROLES, 'Admin or board access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const del = await supabase
.from('ops_acl')
.delete()
.eq('space_id', member.space_id)
.eq('entity_type', v.data.entity_type)
.eq('entity_id', v.data.entity_id)
if (del.error) return { error: del.error.message }
const roles = Array.from(new Set(v.data.roles.map(r => r.trim()).filter(Boolean)))
if (roles.length > 0) {
const ins = await supabase.from('ops_acl').insert(
roles.map(role => ({
space_id: member.space_id,
entity_type: v.data.entity_type,
entity_id: v.data.entity_id,
role,
})),
)
if (ins.error) return { error: ins.error.message }
}
revalidatePath('/ops')
return { success: true as const }
}
// An area-lead role is an area_leads row. lead_id NULL renders as "Vacant".
export async function createAreaLeadRole(input: { area_code: string; name: string }) {
const v = parseInput(createAreaLeadRoleSchema, input)
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, PERM_ADMIN_ROLES, 'Admin or board access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { data, error } = await supabase
.from('area_leads')
.insert({
space_id: member.space_id,
area_code: v.data.area_code,
area_name: v.data.name,
status: 'vacant',
})
.select('id, area_code, area_name, lead_id, lead_handle, status')
.single()
if (error) return { error: error.message }
revalidatePath('/customize')
revalidatePath('/ops')
return { data }
}
export async function assignAreaLead(input: { area_lead_role_id: string; member_id: string }) {
const v = parseInput(assignAreaLeadSchema, input)
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, PERM_ADMIN_ROLES, 'Admin or board access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { data: target } = await supabase
.from('space_members')
.select('id, display_name, handle')
.eq('id', v.data.member_id)
.eq('space_id', member.space_id)
.single()
if (!target) return { error: 'Member not found in this space' }
const { error } = await supabase
.from('area_leads')
.update({
lead_id: target.id,
lead_handle: target.handle ?? target.display_name ?? null,
status: 'active',
updated_at: new Date().toISOString(),
})
.eq('id', v.data.area_lead_role_id)
.eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/customize')
revalidatePath('/members')
revalidatePath('/ops')
return { success: true as const }
}
export async function unassignAreaLead(areaLeadRoleId: string) {
const idCheck = parseInput(uuidSchema, areaLeadRoleId)
if (!idCheck.ok) return { error: 'Invalid role ID' }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, PERM_ADMIN_ROLES, 'Admin or board access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { error } = await supabase
.from('area_leads')
.update({ lead_id: null, lead_handle: null, status: 'vacant', updated_at: new Date().toISOString() })
.eq('id', idCheck.data)
.eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/customize')
revalidatePath('/members')
revalidatePath('/ops')
return { success: true as const }
}
export async function deleteAreaLeadRole(areaLeadRoleId: string) {
const idCheck = parseInput(uuidSchema, areaLeadRoleId)
if (!idCheck.ok) return { error: 'Invalid role ID' }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ['admin'], 'Admin access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
// Clear any ACL rows that referenced this area-lead sentinel first.
await supabase
.from('ops_acl')
.delete()
.eq('space_id', member.space_id)
.eq('role', `area_lead:${idCheck.data}`)
const { error } = await supabase
.from('area_leads')
.delete()
.eq('id', idCheck.data)
.eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/customize')
revalidatePath('/ops')
return { success: true as const }
}
+8
View File
@@ -124,6 +124,14 @@ export async function revealSecret(secretId: string) {
if (error || !data) return { error: error?.message ?? 'Not found' }
// A row marked encrypted (version > 0) MUST have ciphertext. If it does
// not, the row is corrupt: do not silently fall back to the (empty/stale)
// plaintext column, which would either leak nothing useful or mask data
// loss. Surface it instead.
if ((data.encryption_version ?? 0) > 0 && !data.encrypted_value) {
return { error: 'This secret is marked encrypted but has no ciphertext. It may be corrupted; re-enter it.' }
}
let plaintext: string
try {
if (data.encryption_version === 1 && data.encrypted_value) {
+51
View File
@@ -0,0 +1,51 @@
// Fixed catalog of permission codes. This is app-owned data, not a table:
// the set of capabilities is part of the product, while which roles hold
// which permissions is per-space (space_role_permissions).
//
// Important: this layer is ADDITIVE. Existing role-based RLS and server-action
// role checks stay in force. A permission grant can only widen what a role can
// do through the surfaces that consult it (the Ops ACL and the Customize
// permissions UI); it never overrides the database RLS that protects tenant
// isolation. `admin` implicitly holds every permission and can never be
// locked out.
export const PERMISSIONS = [
{ code: 'ops.kb.read', group: 'Ops', label: 'Read knowledge base' },
{ code: 'ops.kb.write', group: 'Ops', label: 'Write knowledge base' },
{ code: 'ops.process.read', group: 'Ops', label: 'Read processes' },
{ code: 'ops.process.write', group: 'Ops', label: 'Write processes' },
{ code: 'ops.secrets.read', group: 'Ops', label: 'Reveal secrets' },
{ code: 'ops.secrets.write', group: 'Ops', label: 'Manage secrets' },
{ code: 'ops.arealeads.manage', group: 'Ops', label: 'Manage area leads' },
{ code: 'members.manage', group: 'People', label: 'Manage members' },
{ code: 'payments.manage', group: 'Finance', label: 'Manage payments' },
{ code: 'governance.manage', group: 'Governance', label: 'Manage proposals, incidents, policies' },
{ code: 'forum.moderate', group: 'Community', label: 'Moderate forum and comments' },
{ code: 'customize.manage', group: 'Admin', label: 'Customize roles, tiers, areas, invites, onboarding' },
{ code: 'settings.manage', group: 'Admin', label: 'Space settings, integrations, webhooks' },
] as const
export type PermissionCode = (typeof PERMISSIONS)[number]['code']
export const PERMISSION_CODES: readonly string[] = PERMISSIONS.map(p => p.code)
export const PERMISSION_GROUPS: string[] = Array.from(new Set(PERMISSIONS.map(p => p.group)))
// Built-in role defaults seeded per space. `admin` is intentionally absent:
// it holds everything implicitly and is never stored, so it cannot be
// accidentally revoked.
export const DEFAULT_ROLE_PERMISSIONS: Record<string, PermissionCode[]> = {
board: [
'ops.kb.read', 'ops.kb.write', 'ops.process.read', 'ops.process.write',
'ops.secrets.read', 'ops.secrets.write', 'ops.arealeads.manage',
'members.manage', 'payments.manage', 'governance.manage',
'forum.moderate', 'customize.manage', 'settings.manage',
],
treasurer: ['payments.manage', 'ops.kb.read', 'ops.process.read'],
member: ['ops.kb.read', 'ops.process.read'],
associate: ['ops.kb.read'],
}
export function isValidPermission(code: string): code is PermissionCode {
return PERMISSION_CODES.includes(code)
}
+36
View File
@@ -486,6 +486,42 @@ export const createChannelSchema = z.object({
channel_type: z.enum(['general', 'area', 'ops', 'project']).default('general'),
})
// ─── Permissions, Ops ACLs, area-lead roles ──────────────────────────────────
// A grant subject is a built-in role or a custom-role slug.
const roleSubject = z.string().min(1).max(50).regex(/^[a-z0-9][a-z0-9_:-]{0,49}$/, 'Invalid role')
export const setRolePermissionsSchema = z.object({
subject: roleSubject,
permissions: z.array(z.string().min(1).max(60)).max(64),
})
export const opsEntityTypeSchema = z.enum(['secret', 'kb', 'process', 'area_lead'])
// The UI sends the full desired role list for one item; the server replaces.
// An empty list means "fall back to the item's existing visibility rule".
export const setOpsAclSchema = z.object({
entity_type: opsEntityTypeSchema,
entity_id: z.string().uuid('Invalid entity ID'),
roles: z.array(z.string().min(1).max(64)).max(64),
})
export const createAreaLeadRoleSchema = z.object({
area_code: z.string().min(1).max(60).regex(/^[a-z0-9][a-z0-9_-]{0,59}$/, 'Invalid area code'),
name: z.string().min(1).max(100),
color: z.string().max(20).optional().nullable(),
})
export const updateAreaLeadRoleSchema = z.object({
name: z.string().min(1).max(100).optional(),
color: z.string().max(20).optional().nullable(),
})
export const assignAreaLeadSchema = z.object({
area_lead_role_id: z.string().uuid('Invalid role ID'),
member_id: z.string().uuid('Invalid member ID'),
})
// ─── Generic ID schemas ──────────────────────────────────────────────────────
export const uuidSchema = z.string().uuid('Invalid ID format')
+200
View File
@@ -0,0 +1,200 @@
-- =============================================================================
-- 023: Customizable permissions, per-item Ops ACLs, area-lead effective roles
-- =============================================================================
-- ADDITIVE and backward compatible. With zero rows in the new tables the app
-- behaves exactly as before: the new SELECT branches on secrets and
-- knowledge_base only OR-in extra access, they never remove the existing
-- role-based access. A space opts in by setting permissions / ACLs.
--
-- Pieces:
-- space_role_permissions role/custom-role -> permission code grants
-- ops_acl per-item allow list (secret | kb | process)
-- user_effective_roles() built-in role + custom roles + area-lead sentinels
-- user_has_permission() admin implicit-all, else a grant via effective role
-- secrets/knowledge_base SELECT policies gain an ops_acl OR-branch
--
-- Area-lead roles reuse the existing public.area_leads table: a row is an
-- "area lead role", lead_id IS NULL renders as Vacant, and the assigned
-- member effectively holds the sentinel role 'area_lead:<area_leads.id>'.
-- =============================================================================
-- --- space_role_permissions -------------------------------------------------
CREATE TABLE IF NOT EXISTS public.space_role_permissions (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
-- a built-in member_role value or a space_custom_roles.slug
subject text NOT NULL CHECK (char_length(subject) BETWEEN 1 AND 50),
permission text NOT NULL CHECK (char_length(permission) BETWEEN 1 AND 60),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, subject, permission)
);
CREATE INDEX IF NOT EXISTS idx_role_perms_space ON public.space_role_permissions (space_id, subject);
-- --- ops_acl ----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.ops_acl (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
entity_type text NOT NULL CHECK (entity_type IN ('secret','kb','process','area_lead')),
entity_id uuid NOT NULL,
-- a built-in role, a custom-role slug, or 'area_lead:<area_leads.id>'
role text NOT NULL CHECK (char_length(role) BETWEEN 1 AND 64),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, entity_type, entity_id, role)
);
CREATE INDEX IF NOT EXISTS idx_ops_acl_entity ON public.ops_acl (entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_ops_acl_space ON public.ops_acl (space_id);
-- --- effective roles + permission helpers (SECURITY DEFINER, pinned path) ----
-- Every role identifier the user effectively holds in a space: their built-in
-- member_role, every assigned custom-role slug, and 'area_lead:<id>' for each
-- area_leads row they lead. STABLE: safe inside RLS.
CREATE OR REPLACE FUNCTION public.user_effective_roles(uid uuid, sid uuid)
RETURNS SETOF text
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = public
AS $$
SELECT sm.role::text
FROM public.space_members sm
WHERE sm.user_id = uid AND sm.space_id = sid
UNION
SELECT cr.slug
FROM public.space_members sm
JOIN public.space_member_custom_roles mcr ON mcr.member_id = sm.id
JOIN public.space_custom_roles cr ON cr.id = mcr.custom_role_id
WHERE sm.user_id = uid AND sm.space_id = sid
UNION
SELECT 'area_lead:' || al.id::text
FROM public.space_members sm
JOIN public.area_leads al ON al.lead_id = sm.id
WHERE sm.user_id = uid AND sm.space_id = sid AND al.space_id = sid;
$$;
-- admin implicitly holds every permission and can never be locked out.
CREATE OR REPLACE FUNCTION public.user_has_permission(uid uuid, sid uuid, perm text)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = public
AS $$
SELECT public.user_has_role_in_space(uid, sid, ARRAY['admin'])
OR EXISTS (
SELECT 1 FROM public.space_role_permissions p
WHERE p.space_id = sid
AND p.permission = perm
AND p.subject IN (SELECT public.user_effective_roles(uid, sid))
);
$$;
-- --- RLS on the new tables --------------------------------------------------
ALTER TABLE public.space_role_permissions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.ops_acl ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS role_perms_select ON public.space_role_permissions;
DROP POLICY IF EXISTS role_perms_insert ON public.space_role_permissions;
DROP POLICY IF EXISTS role_perms_update ON public.space_role_permissions;
DROP POLICY IF EXISTS role_perms_delete ON public.space_role_permissions;
CREATE POLICY role_perms_select ON public.space_role_permissions FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY role_perms_insert ON public.space_role_permissions FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY role_perms_update ON public.space_role_permissions FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY role_perms_delete ON public.space_role_permissions FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
DROP POLICY IF EXISTS ops_acl_select ON public.ops_acl;
DROP POLICY IF EXISTS ops_acl_insert ON public.ops_acl;
DROP POLICY IF EXISTS ops_acl_update ON public.ops_acl;
DROP POLICY IF EXISTS ops_acl_delete ON public.ops_acl;
CREATE POLICY ops_acl_select ON public.ops_acl FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY ops_acl_insert ON public.ops_acl FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY ops_acl_update ON public.ops_acl FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY ops_acl_delete ON public.ops_acl FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
-- --- additive ACL branch on secrets + knowledge_base SELECT -----------------
-- The first OR-operand is the EXACT existing policy. The second only widens.
DROP POLICY IF EXISTS secrets_select ON public.secrets;
CREATE POLICY secrets_select ON public.secrets FOR SELECT
USING (
public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
OR EXISTS (
SELECT 1 FROM public.ops_acl a
WHERE a.space_id = secrets.space_id
AND a.entity_type = 'secret'
AND a.entity_id = secrets.id
AND a.role IN (SELECT public.user_effective_roles(auth.uid(), secrets.space_id))
)
);
DROP POLICY IF EXISTS kb_select ON public.knowledge_base;
CREATE POLICY kb_select ON public.knowledge_base FOR SELECT
USING (
(
space_id IN (SELECT public.get_user_space_ids(auth.uid()))
AND (
visibility = 'all_members'
OR (visibility = 'board' AND public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']))
OR (visibility = 'admin_only' AND public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']))
)
)
OR EXISTS (
SELECT 1 FROM public.ops_acl a
WHERE a.space_id = knowledge_base.space_id
AND a.entity_type IN ('kb','process')
AND a.entity_id = knowledge_base.id
AND a.role IN (SELECT public.user_effective_roles(auth.uid(), knowledge_base.space_id))
)
);
-- --- seed default role permissions ------------------------------------------
CREATE OR REPLACE FUNCTION public.seed_default_role_permissions()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO public.space_role_permissions (space_id, subject, permission)
SELECT NEW.id, d.subject, d.permission
FROM (VALUES
('board','ops.kb.read'),('board','ops.kb.write'),('board','ops.process.read'),
('board','ops.process.write'),('board','ops.secrets.read'),('board','ops.secrets.write'),
('board','ops.arealeads.manage'),('board','members.manage'),('board','payments.manage'),
('board','governance.manage'),('board','forum.moderate'),('board','customize.manage'),
('board','settings.manage'),
('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'),
('member','ops.kb.read'),('member','ops.process.read'),
('associate','ops.kb.read')
) AS d(subject, permission)
ON CONFLICT (space_id, subject, permission) DO NOTHING;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_seed_default_role_permissions ON public.spaces;
CREATE TRIGGER trg_seed_default_role_permissions
AFTER INSERT ON public.spaces
FOR EACH ROW EXECUTE FUNCTION public.seed_default_role_permissions();
-- Backfill existing spaces.
INSERT INTO public.space_role_permissions (space_id, subject, permission)
SELECT s.id, d.subject, d.permission
FROM public.spaces s
CROSS JOIN (VALUES
('board','ops.kb.read'),('board','ops.kb.write'),('board','ops.process.read'),
('board','ops.process.write'),('board','ops.secrets.read'),('board','ops.secrets.write'),
('board','ops.arealeads.manage'),('board','members.manage'),('board','payments.manage'),
('board','governance.manage'),('board','forum.moderate'),('board','customize.manage'),
('board','settings.manage'),
('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'),
('member','ops.kb.read'),('member','ops.process.read'),
('associate','ops.kb.read')
) AS d(subject, permission)
ON CONFLICT (space_id, subject, permission) DO NOTHING;
+144
View File
@@ -1692,3 +1692,147 @@ DROP TRIGGER IF EXISTS trg_onboarding_steps_touch ON public.space_onboarding_ste
CREATE TRIGGER trg_onboarding_steps_touch
BEFORE UPDATE ON public.space_onboarding_steps
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
-- =============================================================================
-- 15. Customizable permissions, per-item Ops ACLs, area-lead effective roles.
-- Equivalent to scripts/023_permissions.sql. Additive: the new SELECT
-- branches only widen access; with no rows in the new tables behavior is
-- unchanged.
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.space_role_permissions (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
subject text NOT NULL CHECK (char_length(subject) BETWEEN 1 AND 50),
permission text NOT NULL CHECK (char_length(permission) BETWEEN 1 AND 60),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, subject, permission)
);
CREATE INDEX IF NOT EXISTS idx_role_perms_space ON public.space_role_permissions (space_id, subject);
CREATE TABLE IF NOT EXISTS public.ops_acl (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
entity_type text NOT NULL CHECK (entity_type IN ('secret','kb','process','area_lead')),
entity_id uuid NOT NULL,
role text NOT NULL CHECK (char_length(role) BETWEEN 1 AND 64),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, entity_type, entity_id, role)
);
CREATE INDEX IF NOT EXISTS idx_ops_acl_entity ON public.ops_acl (entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_ops_acl_space ON public.ops_acl (space_id);
CREATE OR REPLACE FUNCTION public.user_effective_roles(uid uuid, sid uuid)
RETURNS SETOF text LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
AS $$
SELECT sm.role::text FROM public.space_members sm
WHERE sm.user_id = uid AND sm.space_id = sid
UNION
SELECT cr.slug
FROM public.space_members sm
JOIN public.space_member_custom_roles mcr ON mcr.member_id = sm.id
JOIN public.space_custom_roles cr ON cr.id = mcr.custom_role_id
WHERE sm.user_id = uid AND sm.space_id = sid
UNION
SELECT 'area_lead:' || al.id::text
FROM public.space_members sm
JOIN public.area_leads al ON al.lead_id = sm.id
WHERE sm.user_id = uid AND sm.space_id = sid AND al.space_id = sid;
$$;
CREATE OR REPLACE FUNCTION public.user_has_permission(uid uuid, sid uuid, perm text)
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
AS $$
SELECT public.user_has_role_in_space(uid, sid, ARRAY['admin'])
OR EXISTS (
SELECT 1 FROM public.space_role_permissions p
WHERE p.space_id = sid AND p.permission = perm
AND p.subject IN (SELECT public.user_effective_roles(uid, sid))
);
$$;
ALTER TABLE public.space_role_permissions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.ops_acl ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS role_perms_select ON public.space_role_permissions;
DROP POLICY IF EXISTS role_perms_insert ON public.space_role_permissions;
DROP POLICY IF EXISTS role_perms_update ON public.space_role_permissions;
DROP POLICY IF EXISTS role_perms_delete ON public.space_role_permissions;
CREATE POLICY role_perms_select ON public.space_role_permissions FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY role_perms_insert ON public.space_role_permissions FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY role_perms_update ON public.space_role_permissions FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY role_perms_delete ON public.space_role_permissions FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
DROP POLICY IF EXISTS ops_acl_select ON public.ops_acl;
DROP POLICY IF EXISTS ops_acl_insert ON public.ops_acl;
DROP POLICY IF EXISTS ops_acl_update ON public.ops_acl;
DROP POLICY IF EXISTS ops_acl_delete ON public.ops_acl;
CREATE POLICY ops_acl_select ON public.ops_acl FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY ops_acl_insert ON public.ops_acl FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY ops_acl_update ON public.ops_acl FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY ops_acl_delete ON public.ops_acl FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
DROP POLICY IF EXISTS secrets_select ON public.secrets;
CREATE POLICY secrets_select ON public.secrets FOR SELECT
USING (
public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
OR EXISTS (
SELECT 1 FROM public.ops_acl a
WHERE a.space_id = secrets.space_id AND a.entity_type = 'secret'
AND a.entity_id = secrets.id
AND a.role IN (SELECT public.user_effective_roles(auth.uid(), secrets.space_id))
)
);
DROP POLICY IF EXISTS kb_select ON public.knowledge_base;
CREATE POLICY kb_select ON public.knowledge_base FOR SELECT
USING (
(
space_id IN (SELECT public.get_user_space_ids(auth.uid()))
AND (
visibility = 'all_members'
OR (visibility = 'board' AND public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']))
OR (visibility = 'admin_only' AND public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']))
)
)
OR EXISTS (
SELECT 1 FROM public.ops_acl a
WHERE a.space_id = knowledge_base.space_id AND a.entity_type IN ('kb','process')
AND a.entity_id = knowledge_base.id
AND a.role IN (SELECT public.user_effective_roles(auth.uid(), knowledge_base.space_id))
)
);
CREATE OR REPLACE FUNCTION public.seed_default_role_permissions()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO public.space_role_permissions (space_id, subject, permission)
SELECT NEW.id, d.subject, d.permission
FROM (VALUES
('board','ops.kb.read'),('board','ops.kb.write'),('board','ops.process.read'),
('board','ops.process.write'),('board','ops.secrets.read'),('board','ops.secrets.write'),
('board','ops.arealeads.manage'),('board','members.manage'),('board','payments.manage'),
('board','governance.manage'),('board','forum.moderate'),('board','customize.manage'),
('board','settings.manage'),
('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'),
('member','ops.kb.read'),('member','ops.process.read'),
('associate','ops.kb.read')
) AS d(subject, permission)
ON CONFLICT (space_id, subject, permission) DO NOTHING;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_seed_default_role_permissions ON public.spaces;
CREATE TRIGGER trg_seed_default_role_permissions
AFTER INSERT ON public.spaces
FOR EACH ROW EXECUTE FUNCTION public.seed_default_role_permissions();
+12
View File
@@ -34,6 +34,18 @@ export type Database = {
}
public: {
Tables: {
space_role_permissions: {
Row: { id: string; space_id: string; subject: string; permission: string; created_at: string }
Insert: { id?: string; space_id: string; subject: string; permission: string; created_at?: string }
Update: { id?: string; space_id?: string; subject?: string; permission?: string; created_at?: string }
Relationships: []
}
ops_acl: {
Row: { id: string; space_id: string; entity_type: string; entity_id: string; role: string; created_at: string }
Insert: { id?: string; space_id: string; entity_type: string; entity_id: string; role: string; created_at?: string }
Update: { id?: string; space_id?: string; entity_type?: string; entity_id?: string; role?: string; created_at?: string }
Relationships: []
}
activity_log: {
Row: {
action: string