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:
@@ -4,6 +4,17 @@ All notable changes to this project are documented here. Format follows [Keep a
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### 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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,22 +2,35 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { RolesPanel } from './panels/roles-panel'
|
import { RolesPanel } from './panels/roles-panel'
|
||||||
|
import { PermissionsPanel } from './panels/permissions-panel'
|
||||||
import { TiersPanel } from './panels/tiers-panel'
|
import { TiersPanel } from './panels/tiers-panel'
|
||||||
import { AreasPanel } from './panels/areas-panel'
|
import { AreasPanel } from './panels/areas-panel'
|
||||||
|
import { AreaLeadsPanel } from './panels/area-leads-panel'
|
||||||
import { InvitesPanel } from './panels/invites-panel'
|
import { InvitesPanel } from './panels/invites-panel'
|
||||||
import { OnboardingPanel } from './panels/onboarding-panel'
|
import { OnboardingPanel } from './panels/onboarding-panel'
|
||||||
import type { RoleLabelRow, CustomRole, Tier, Invite, Step, Area } from './panels/types'
|
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 }[] = [
|
const SECTIONS: { key: Section; label: string }[] = [
|
||||||
{ key: 'roles', label: 'Roles' },
|
{ key: 'roles', label: 'Roles' },
|
||||||
|
{ key: 'permissions', label: 'Permissions' },
|
||||||
{ key: 'tiers', label: 'Membership tiers' },
|
{ key: 'tiers', label: 'Membership tiers' },
|
||||||
{ key: 'areas', label: 'Areas' },
|
{ key: 'areas', label: 'Areas' },
|
||||||
|
{ key: 'area-leads', label: 'Area leads' },
|
||||||
{ key: 'invites', label: 'Invite codes' },
|
{ key: 'invites', label: 'Invite codes' },
|
||||||
{ key: 'onboarding', label: 'Onboarding' },
|
{ 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 {
|
interface Props {
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
roleLabels: RoleLabelRow[]
|
roleLabels: RoleLabelRow[]
|
||||||
@@ -26,6 +39,9 @@ interface Props {
|
|||||||
invites: Invite[]
|
invites: Invite[]
|
||||||
onboardingSteps: Step[]
|
onboardingSteps: Step[]
|
||||||
areas: Area[]
|
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) {
|
export function CustomizeClient(props: Props) {
|
||||||
@@ -57,8 +73,10 @@ export function CustomizeClient(props: Props) {
|
|||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{section === 'roles' && <RolesPanel isAdmin={props.isAdmin} roleLabels={props.roleLabels} customRoles={props.customRoles} />}
|
{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 === 'tiers' && <TiersPanel isAdmin={props.isAdmin} tiers={props.tiers} />}
|
||||||
{section === 'areas' && <AreasPanel isAdmin={props.isAdmin} areas={props.areas} />}
|
{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 === 'invites' && <InvitesPanel isAdmin={props.isAdmin} invites={props.invites} />}
|
||||||
{section === 'onboarding' && <OnboardingPanel isAdmin={props.isAdmin} steps={props.onboardingSteps} />}
|
{section === 'onboarding' && <OnboardingPanel isAdmin={props.isAdmin} steps={props.onboardingSteps} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export default async function CustomizePage() {
|
|||||||
{ data: invites },
|
{ data: invites },
|
||||||
{ data: onboardingSteps },
|
{ data: onboardingSteps },
|
||||||
{ data: areas },
|
{ data: areas },
|
||||||
|
{ data: rolePerms },
|
||||||
|
{ data: areaLeads },
|
||||||
|
{ data: members },
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
supabase.from('space_role_labels').select('role, display_name, description, color').eq('space_id', member.space_id),
|
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'),
|
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_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_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_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 (
|
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 }>}
|
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 }>}
|
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 }>}
|
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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import ReactMarkdown from 'react-markdown'
|
|||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import { createKbEntry, updateKbEntry, deleteKbEntry } from '@/lib/actions'
|
import { createKbEntry, updateKbEntry, deleteKbEntry } from '@/lib/actions'
|
||||||
import { revealSecret, deleteSecret } from '@/lib/actions/secrets'
|
import { revealSecret, deleteSecret } from '@/lib/actions/secrets'
|
||||||
|
import { OpsAclEditor } from '@/components/ops/ops-acl-editor'
|
||||||
import { createClient } from '@/lib/supabase/client'
|
import { createClient } from '@/lib/supabase/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import type { Tables, TablesInsert } from '@/types/database'
|
import type { Tables, TablesInsert } from '@/types/database'
|
||||||
@@ -22,6 +23,9 @@ interface Props {
|
|||||||
areaLeads: AreaLead[]
|
areaLeads: AreaLead[]
|
||||||
secrets: Secret[]
|
secrets: Secret[]
|
||||||
canSeeSecrets: boolean
|
canSeeSecrets: boolean
|
||||||
|
canManageAcl?: boolean
|
||||||
|
aclRoleOptions?: { value: string; label: string }[]
|
||||||
|
aclByEntity?: Record<string, string[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = 'kb' | 'processes' | 'secrets' | 'area-leads'
|
type Tab = 'kb' | 'processes' | 'secrets' | 'area-leads'
|
||||||
@@ -342,10 +346,17 @@ function AreaLeadModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Secret Row (reveal on click) ─────────────────────────────────────────────
|
// ─── 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 [revealed, setRevealed] = useState(false)
|
||||||
const [value, setValue] = useState<string | null>(null)
|
const [value, setValue] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [showAcl, setShowAcl] = useState(false)
|
||||||
|
|
||||||
async function reveal() {
|
async function reveal() {
|
||||||
if (revealed) { setRevealed(false); setValue(null); return }
|
if (revealed) { setRevealed(false); setValue(null); return }
|
||||||
@@ -366,7 +377,8 @@ function SecretRow({ secret, onDelete }: { secret: Secret; onDelete: (id: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 px-4 py-3">
|
<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">
|
<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" />
|
<Lock className="w-4 h-4 text-amber-500" />
|
||||||
</div>
|
</div>
|
||||||
@@ -378,6 +390,15 @@ function SecretRow({ secret, onDelete }: { secret: Secret; onDelete: (id: string
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
<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
|
<button
|
||||||
onClick={reveal}
|
onClick={reveal}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -392,6 +413,15 @@ function SecretRow({ secret, onDelete }: { secret: Secret; onDelete: (id: string
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{showAcl && canManageAcl && (
|
||||||
|
<OpsAclEditor
|
||||||
|
entityType="secret"
|
||||||
|
entityId={secret.id}
|
||||||
|
options={aclRoleOptions ?? []}
|
||||||
|
initial={aclInitial ?? []}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,7 +534,7 @@ function KbEntryRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Main Component ────────────────────────────────────────────────────────────
|
// ─── 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 router = useRouter()
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('kb')
|
const [activeTab, setActiveTab] = useState<Tab>('kb')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
@@ -722,7 +752,14 @@ export function OpsClient({ member, spaceId, kbEntries: initial, areaLeads: init
|
|||||||
) : filteredSecrets.length > 0 ? (
|
) : filteredSecrets.length > 0 ? (
|
||||||
<div className="bg-card rounded border border-border divide-y divide-border">
|
<div className="bg-card rounded border border-border divide-y divide-border">
|
||||||
{filteredSecrets.map(s => (
|
{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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+23
-1
@@ -12,8 +12,9 @@ export default async function OpsPage() {
|
|||||||
if (!member) return null
|
if (!member) return null
|
||||||
|
|
||||||
const canSeeSecrets = member.role === 'admin' || member.role === 'board'
|
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('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'),
|
supabase.from('area_leads').select('*').eq('space_id', member.space_id).order('area_name'),
|
||||||
canSeeSecrets
|
canSeeSecrets
|
||||||
@@ -22,8 +23,26 @@ export default async function OpsPage() {
|
|||||||
// through the revealSecret() server action.
|
// 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)
|
? 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'>[] }),
|
: 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 (
|
return (
|
||||||
<OpsClient
|
<OpsClient
|
||||||
member={member as Tables<'space_members'>}
|
member={member as Tables<'space_members'>}
|
||||||
@@ -32,6 +51,9 @@ export default async function OpsPage() {
|
|||||||
areaLeads={areaLeads ?? []}
|
areaLeads={areaLeads ?? []}
|
||||||
secrets={secretsResult.data ?? []}
|
secrets={secretsResult.data ?? []}
|
||||||
canSeeSecrets={canSeeSecrets}
|
canSeeSecrets={canSeeSecrets}
|
||||||
|
canManageAcl={canManageAcl}
|
||||||
|
aclRoleOptions={aclRoleOptions}
|
||||||
|
aclByEntity={aclByEntity}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
## 2026-05-15 (pass 14) — Landing redesign + KB search fix
|
||||||
|
|
||||||
Branch: `main`.
|
Branch: `main`.
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ export async function upsertAreaLead(formData: {
|
|||||||
const v = parseInput(upsertAreaLeadSchema, formData)
|
const v = parseInput(upsertAreaLeadSchema, formData)
|
||||||
if (!v.ok) return { error: v.error }
|
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 supabase = await createClient()
|
||||||
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin access required')
|
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin access required')
|
||||||
if (!auth.ok) return { error: auth.error }
|
if (!auth.ok) return { error: auth.error }
|
||||||
|
|||||||
@@ -41,3 +41,6 @@ export * from './invites'
|
|||||||
|
|
||||||
// Configurable member onboarding
|
// Configurable member onboarding
|
||||||
export * from './onboarding'
|
export * from './onboarding'
|
||||||
|
|
||||||
|
// Customizable permissions, per-item Ops ACLs, area-lead roles
|
||||||
|
export * from './permissions'
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -124,6 +124,14 @@ export async function revealSecret(secretId: string) {
|
|||||||
|
|
||||||
if (error || !data) return { error: error?.message ?? 'Not found' }
|
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
|
let plaintext: string
|
||||||
try {
|
try {
|
||||||
if (data.encryption_version === 1 && data.encrypted_value) {
|
if (data.encryption_version === 1 && data.encrypted_value) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -486,6 +486,42 @@ export const createChannelSchema = z.object({
|
|||||||
channel_type: z.enum(['general', 'area', 'ops', 'project']).default('general'),
|
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 ──────────────────────────────────────────────────────
|
// ─── Generic ID schemas ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const uuidSchema = z.string().uuid('Invalid ID format')
|
export const uuidSchema = z.string().uuid('Invalid ID format')
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1692,3 +1692,147 @@ DROP TRIGGER IF EXISTS trg_onboarding_steps_touch ON public.space_onboarding_ste
|
|||||||
CREATE TRIGGER trg_onboarding_steps_touch
|
CREATE TRIGGER trg_onboarding_steps_touch
|
||||||
BEFORE UPDATE ON public.space_onboarding_steps
|
BEFORE UPDATE ON public.space_onboarding_steps
|
||||||
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
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();
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
public: {
|
public: {
|
||||||
Tables: {
|
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: {
|
activity_log: {
|
||||||
Row: {
|
Row: {
|
||||||
action: string
|
action: string
|
||||||
|
|||||||
Reference in New Issue
Block a user