From 9447b5ee8097d754fc31f5f23b01e8c2be6ed34f Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Fri, 15 May 2026 17:15:14 -0700 Subject: [PATCH] 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: 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:. 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. --- CHANGELOG.md | 11 + __tests__/permissions-catalog.test.ts | 40 ++++ app/(app)/customize/customize-client.tsx | 20 +- app/(app)/customize/page.tsx | 9 + .../customize/panels/area-leads-panel.tsx | 129 +++++++++++ .../customize/panels/permissions-panel.tsx | 130 ++++++++++++ app/(app)/ops/ops-client.tsx | 91 +++++--- app/(app)/ops/page.tsx | 24 ++- components/ops/ops-acl-editor.tsx | 74 +++++++ docs/HANDOFF.md | 35 +++ lib/actions/area-leads.ts | 7 + lib/actions/index.ts | 3 + lib/actions/permissions.ts | 197 +++++++++++++++++ lib/actions/secrets.ts | 8 + lib/permissions-catalog.ts | 51 +++++ lib/validations.ts | 36 ++++ scripts/023_permissions.sql | 200 ++++++++++++++++++ scripts/schema.sql | 144 +++++++++++++ types/database.ts | 12 ++ 19 files changed, 1192 insertions(+), 29 deletions(-) create mode 100644 __tests__/permissions-catalog.test.ts create mode 100644 app/(app)/customize/panels/area-leads-panel.tsx create mode 100644 app/(app)/customize/panels/permissions-panel.tsx create mode 100644 components/ops/ops-acl-editor.tsx create mode 100644 lib/actions/permissions.ts create mode 100644 lib/permissions-catalog.ts create mode 100644 scripts/023_permissions.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 230eea9..1640a65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/__tests__/permissions-catalog.test.ts b/__tests__/permissions-catalog.test.ts new file mode 100644 index 0000000..6efea4e --- /dev/null +++ b/__tests__/permissions-catalog.test.ts @@ -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() + }) +}) diff --git a/app/(app)/customize/customize-client.tsx b/app/(app)/customize/customize-client.tsx index 3dfb696..cbdcb5f 100644 --- a/app/(app)/customize/customize-client.tsx +++ b/app/(app)/customize/customize-client.tsx @@ -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) {
{section === 'roles' && } + {section === 'permissions' && } {section === 'tiers' && } {section === 'areas' && } + {section === 'area-leads' && } {section === 'invites' && } {section === 'onboarding' && }
diff --git a/app/(app)/customize/page.tsx b/app/(app)/customize/page.tsx index 4ee7e5a..6286611 100644 --- a/app/(app)/customize/page.tsx +++ b/app/(app)/customize/page.tsx @@ -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; 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 }>} /> ) } diff --git a/app/(app)/customize/panels/area-leads-panel.tsx b/app/(app)/customize/panels/area-leads-panel.tsx new file mode 100644 index 0000000..36df6b8 --- /dev/null +++ b/app/(app)/customize/panels/area-leads-panel.tsx @@ -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(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 ( + 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'} + + ) : undefined} + > + {showNew && ( +
{ + 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" + > + 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" /> + 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" /> + +
+ )} + + {rows.length === 0 ? ( +

No area-lead roles yet.

+ ) : ( +
    + {rows.map(r => ( +
  • +
    +
    + {r.area_name} + {r.area_code && {r.area_code}} + {r.lead_id ? ( + {memberLabel(r.lead_id)} + ) : ( + Vacant + )} +
    +
    + {isAdmin && ( +
    + + +
    + )} +
  • + ))} +
+ )} +
+ ) +} diff --git a/app/(app)/customize/panels/permissions-panel.tsx b/app/(app)/customize/panels/permissions-panel.tsx new file mode 100644 index 0000000..362431b --- /dev/null +++ b/app/(app)/customize/panels/permissions-panel.tsx @@ -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> = {} + 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(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 ( + +
+ admin + holds all permissions (implicit, locked) +
+ +
+ + + + + {subjects.map(s => ( + + ))} + + + + {PERMISSION_GROUPS.map(group => ( + + ))} + +
Permission + {s.label}{savingSubject === s.key && …} +
+
+
+ ) +} + +function PermGroup({ + group, subjects, grants, isAdmin, onToggle, +}: { + group: string + subjects: { key: string; label: string }[] + grants: Record> + isAdmin: boolean + onToggle: (subject: string, permission: string, on: boolean) => void +}) { + const perms = PERMISSIONS.filter(p => p.group === group) + return ( + <> + + + {group} + + + {perms.map(p => ( + + + {p.label} + {p.code} + + {subjects.map(s => ( + + onToggle(s.key, p.code, e.target.checked)} + /> + + ))} + + ))} + + ) +} diff --git a/app/(app)/ops/ops-client.tsx b/app/(app)/ops/ops-client.tsx index ca8ddd5..5767c9b 100644 --- a/app/(app)/ops/ops-client.tsx +++ b/app/(app)/ops/ops-client.tsx @@ -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 } 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(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 ( -
-
- -
-
-

{secret.title}

- {secret.area &&

{secret.area}

} - {revealed && value && ( -

{value}

- )} -
-
- - +
+
+
+ +
+
+

{secret.title}

+ {secret.area &&

{secret.area}

} + {revealed && value && ( +

{value}

+ )} +
+
+ {canManageAcl && ( + + )} + + +
+ {showAcl && canManageAcl && ( + + )}
) } @@ -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('kb') const [search, setSearch] = useState('') @@ -722,7 +752,14 @@ export function OpsClient({ member, spaceId, kbEntries: initial, areaLeads: init ) : filteredSecrets.length > 0 ? (
{filteredSecrets.map(s => ( - setSecrets(prev => prev.filter(x => x.id !== id))} /> + setSecrets(prev => prev.filter(x => x.id !== id))} + canManageAcl={canManageAcl} + aclRoleOptions={aclRoleOptions} + aclInitial={aclByEntity[`secret:${s.id}`] ?? []} + /> ))}
) : ( diff --git a/app/(app)/ops/page.tsx b/app/(app)/ops/page.tsx index b566660..8ed391c 100644 --- a/app/(app)/ops/page.tsx +++ b/app/(app)/ops/page.tsx @@ -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 = {} + for (const row of acl ?? []) { + const k = `${row.entity_type}:${row.entity_id}` + ;(aclByEntity[k] ??= []).push(row.role) + } + return ( } @@ -32,6 +51,9 @@ export default async function OpsPage() { areaLeads={areaLeads ?? []} secrets={secretsResult.data ?? []} canSeeSecrets={canSeeSecrets} + canManageAcl={canManageAcl} + aclRoleOptions={aclRoleOptions} + aclByEntity={aclByEntity} /> ) } diff --git a/components/ops/ops-acl-editor.tsx b/components/ops/ops-acl-editor.tsx new file mode 100644 index 0000000..3473ac0 --- /dev/null +++ b/components/ops/ops-acl-editor.tsx @@ -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>(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 ( +
+

+ Who can access {saving && …} +

+ {options.length === 0 ? ( +

No roles available.

+ ) : ( +
+ {options.map(o => { + const on = selected.has(o.value) + return ( + + ) + })} +
+ )} +

+ Admins and board always have access. Empty selection keeps the default visibility. +

+
+ ) +} diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index d91fdcc..9ce78e2 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -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:` 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:` 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`. diff --git a/lib/actions/area-leads.ts b/lib/actions/area-leads.ts index e7b2659..e0f03f6 100644 --- a/lib/actions/area-leads.ts +++ b/lib/actions/area-leads.ts @@ -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 } diff --git a/lib/actions/index.ts b/lib/actions/index.ts index c9a2e97..6c066a0 100644 --- a/lib/actions/index.ts +++ b/lib/actions/index.ts @@ -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' diff --git a/lib/actions/permissions.ts b/lib/actions/permissions.ts new file mode 100644 index 0000000..00c771e --- /dev/null +++ b/lib/actions/permissions.ts @@ -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 } +} diff --git a/lib/actions/secrets.ts b/lib/actions/secrets.ts index eb4eb79..f5f5090 100644 --- a/lib/actions/secrets.ts +++ b/lib/actions/secrets.ts @@ -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) { diff --git a/lib/permissions-catalog.ts b/lib/permissions-catalog.ts new file mode 100644 index 0000000..15aff9c --- /dev/null +++ b/lib/permissions-catalog.ts @@ -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 = { + 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) +} diff --git a/lib/validations.ts b/lib/validations.ts index 55797ad..c543d53 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -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') diff --git a/scripts/023_permissions.sql b/scripts/023_permissions.sql new file mode 100644 index 0000000..2b90bb0 --- /dev/null +++ b/scripts/023_permissions.sql @@ -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:'. +-- ============================================================================= + +-- --- 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:' + 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:' 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; diff --git a/scripts/schema.sql b/scripts/schema.sql index d6ed405..95d5bc7 100644 --- a/scripts/schema.sql +++ b/scripts/schema.sql @@ -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(); diff --git a/types/database.ts b/types/database.ts index 18bc032..445466d 100644 --- a/types/database.ts +++ b/types/database.ts @@ -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