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]
|
||||
|
||||
### 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.
|
||||
|
||||
|
||||
@@ -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 { RolesPanel } from './panels/roles-panel'
|
||||
import { PermissionsPanel } from './panels/permissions-panel'
|
||||
import { TiersPanel } from './panels/tiers-panel'
|
||||
import { AreasPanel } from './panels/areas-panel'
|
||||
import { AreaLeadsPanel } from './panels/area-leads-panel'
|
||||
import { InvitesPanel } from './panels/invites-panel'
|
||||
import { OnboardingPanel } from './panels/onboarding-panel'
|
||||
import type { RoleLabelRow, CustomRole, Tier, Invite, Step, Area } from './panels/types'
|
||||
|
||||
type Section = 'roles' | 'tiers' | 'areas' | 'invites' | 'onboarding'
|
||||
type Section = 'roles' | 'permissions' | 'tiers' | 'areas' | 'area-leads' | 'invites' | 'onboarding'
|
||||
|
||||
const SECTIONS: { key: Section; label: string }[] = [
|
||||
{ key: 'roles', label: 'Roles' },
|
||||
{ key: 'permissions', label: 'Permissions' },
|
||||
{ key: 'tiers', label: 'Membership tiers' },
|
||||
{ key: 'areas', label: 'Areas' },
|
||||
{ key: 'area-leads', label: 'Area leads' },
|
||||
{ key: 'invites', label: 'Invite codes' },
|
||||
{ key: 'onboarding', label: 'Onboarding' },
|
||||
]
|
||||
|
||||
interface AreaLead {
|
||||
id: string
|
||||
area_code: string | null
|
||||
area_name: string
|
||||
lead_id: string | null
|
||||
lead_handle: string | null
|
||||
status: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isAdmin: boolean
|
||||
roleLabels: RoleLabelRow[]
|
||||
@@ -26,6 +39,9 @@ interface Props {
|
||||
invites: Invite[]
|
||||
onboardingSteps: Step[]
|
||||
areas: Area[]
|
||||
rolePerms: Array<{ subject: string; permission: string }>
|
||||
areaLeads: AreaLead[]
|
||||
members: Array<{ id: string; display_name: string | null; handle: string | null }>
|
||||
}
|
||||
|
||||
export function CustomizeClient(props: Props) {
|
||||
@@ -57,8 +73,10 @@ export function CustomizeClient(props: Props) {
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{section === 'roles' && <RolesPanel isAdmin={props.isAdmin} roleLabels={props.roleLabels} customRoles={props.customRoles} />}
|
||||
{section === 'permissions' && <PermissionsPanel isAdmin={props.isAdmin} customRoles={props.customRoles} rolePerms={props.rolePerms} />}
|
||||
{section === 'tiers' && <TiersPanel isAdmin={props.isAdmin} tiers={props.tiers} />}
|
||||
{section === 'areas' && <AreasPanel isAdmin={props.isAdmin} areas={props.areas} />}
|
||||
{section === 'area-leads' && <AreaLeadsPanel isAdmin={props.isAdmin} areaLeads={props.areaLeads} members={props.members} />}
|
||||
{section === 'invites' && <InvitesPanel isAdmin={props.isAdmin} invites={props.invites} />}
|
||||
{section === 'onboarding' && <OnboardingPanel isAdmin={props.isAdmin} steps={props.onboardingSteps} />}
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,9 @@ export default async function CustomizePage() {
|
||||
{ data: invites },
|
||||
{ data: onboardingSteps },
|
||||
{ data: areas },
|
||||
{ data: rolePerms },
|
||||
{ data: areaLeads },
|
||||
{ data: members },
|
||||
] = await Promise.all([
|
||||
supabase.from('space_role_labels').select('role, display_name, description, color').eq('space_id', member.space_id),
|
||||
supabase.from('space_custom_roles').select('id, slug, name, description, color, sort_order').eq('space_id', member.space_id).order('sort_order'),
|
||||
@@ -35,6 +38,9 @@ export default async function CustomizePage() {
|
||||
supabase.from('space_invites').select('id, code, label, expires_at, max_uses, uses_count, is_enabled, created_at').eq('space_id', member.space_id).order('created_at', { ascending: false }),
|
||||
supabase.from('space_onboarding_steps').select('id, step_key, step_type, title, body, config, is_enabled, is_required, is_system, sort_order').eq('space_id', member.space_id).order('sort_order'),
|
||||
supabase.from('space_areas').select('id, code, name, icon, sort_order, is_archived').eq('space_id', member.space_id).order('sort_order').order('name'),
|
||||
supabase.from('space_role_permissions').select('subject, permission').eq('space_id', member.space_id),
|
||||
supabase.from('area_leads').select('id, area_code, area_name, lead_id, lead_handle, status').eq('space_id', member.space_id).order('area_name'),
|
||||
supabase.from('space_members').select('id, display_name, handle').eq('space_id', member.space_id).in('status', ['current', 'unverified', 'late']).order('display_name'),
|
||||
])
|
||||
|
||||
return (
|
||||
@@ -46,6 +52,9 @@ export default async function CustomizePage() {
|
||||
invites={(invites ?? []) as Array<{ id: string; code: string; label: string | null; expires_at: string | null; max_uses: number | null; uses_count: number; is_enabled: boolean; created_at: string }>}
|
||||
onboardingSteps={(onboardingSteps ?? []) as Array<{ id: string; step_key: string; step_type: 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content'; title: string; body: string | null; config: Record<string, unknown>; is_enabled: boolean; is_required: boolean; is_system: boolean; sort_order: number }>}
|
||||
areas={(areas ?? []) as Array<{ id: string; code: string; name: string; icon: string | null; sort_order: number; is_archived: boolean }>}
|
||||
rolePerms={(rolePerms ?? []) as Array<{ subject: string; permission: string }>}
|
||||
areaLeads={(areaLeads ?? []) as Array<{ id: string; area_code: string | null; area_name: string; lead_id: string | null; lead_handle: string | null; status: string }>}
|
||||
members={(members ?? []) as Array<{ id: string; display_name: string | null; handle: string | null }>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
import { createAreaLeadRole, assignAreaLead, unassignAreaLead, deleteAreaLeadRole } from '@/lib/actions'
|
||||
import { Card } from './card'
|
||||
|
||||
interface AreaLead {
|
||||
id: string
|
||||
area_code: string | null
|
||||
area_name: string
|
||||
lead_id: string | null
|
||||
lead_handle: string | null
|
||||
status: string
|
||||
}
|
||||
interface Member { id: string; display_name: string | null; handle: string | null }
|
||||
|
||||
interface Props {
|
||||
isAdmin: boolean
|
||||
areaLeads: AreaLead[]
|
||||
members: Member[]
|
||||
}
|
||||
|
||||
export function AreaLeadsPanel({ isAdmin, areaLeads: initial, members }: Props) {
|
||||
const router = useRouter()
|
||||
const [rows, setRows] = useState<AreaLead[]>(initial)
|
||||
const [showNew, setShowNew] = useState(false)
|
||||
const [d, setD] = useState({ area_code: '', name: '' })
|
||||
|
||||
function memberLabel(id: string | null) {
|
||||
if (!id) return null
|
||||
const m = members.find(x => x.id === id)
|
||||
return m ? (m.display_name || m.handle || 'Member') : 'Member'
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Area-lead roles"
|
||||
blurb="Each row is an area-lead role. Unassigned roles show as Vacant. Assign a member here (or from the member directory). The assigned member effectively holds this role, so any Ops item whose access list includes it becomes available to them."
|
||||
action={isAdmin ? (
|
||||
<button onClick={() => setShowNew(v => !v)} className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition whitespace-nowrap">
|
||||
{showNew ? 'Cancel' : '+ Area-lead role'}
|
||||
</button>
|
||||
) : undefined}
|
||||
>
|
||||
{showNew && (
|
||||
<form
|
||||
onSubmit={async e => {
|
||||
e.preventDefault()
|
||||
const result = await createAreaLeadRole({ area_code: d.area_code.trim().toLowerCase(), name: d.name.trim() })
|
||||
if ('error' in result && result.error) { toast.error(result.error); return }
|
||||
toast.success('Area-lead role created')
|
||||
const created = (result as { data?: AreaLead }).data
|
||||
if (created) setRows(prev => [...prev, created])
|
||||
setShowNew(false); setD({ area_code: '', name: '' })
|
||||
}}
|
||||
className="mb-4 p-4 border border-border rounded bg-background grid grid-cols-1 md:grid-cols-2 gap-3"
|
||||
>
|
||||
<input type="text" required value={d.area_code} onChange={e => setD({ ...d, area_code: e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '-') })} placeholder="area code (e.g. woodshop)" className="bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1.5 focus:outline-none focus:border-primary" />
|
||||
<input type="text" required value={d.name} onChange={e => setD({ ...d, name: e.target.value })} placeholder="Role name (e.g. Woodshop Lead)" className="bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1.5 focus:outline-none focus:border-primary" />
|
||||
<button type="submit" className="md:col-span-2 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition">Create</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="font-sans text-sm text-muted-foreground py-2">No area-lead roles yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{rows.map(r => (
|
||||
<li key={r.id} className="py-3 flex items-center gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-sans text-sm font-medium text-foreground">{r.area_name}</span>
|
||||
{r.area_code && <span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{r.area_code}</span>}
|
||||
{r.lead_id ? (
|
||||
<span className="font-mono text-[10px] text-primary">{memberLabel(r.lead_id)}</span>
|
||||
) : (
|
||||
<span className="font-mono text-[10px] text-amber-600">Vacant</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<select
|
||||
value={r.lead_id ?? ''}
|
||||
onChange={async e => {
|
||||
const memberId = e.target.value
|
||||
if (!memberId) {
|
||||
const res = await unassignAreaLead(r.id)
|
||||
if (res.error) { toast.error(res.error); return }
|
||||
setRows(prev => prev.map(x => x.id === r.id ? { ...x, lead_id: null, lead_handle: null, status: 'vacant' } : x))
|
||||
toast.success('Set to vacant')
|
||||
} else {
|
||||
const res = await assignAreaLead({ area_lead_role_id: r.id, member_id: memberId })
|
||||
if (res.error) { toast.error(res.error); return }
|
||||
setRows(prev => prev.map(x => x.id === r.id ? { ...x, lead_id: memberId, status: 'active' } : x))
|
||||
toast.success('Lead assigned')
|
||||
router.refresh()
|
||||
}
|
||||
}}
|
||||
className="bg-background border border-border text-foreground font-sans text-xs rounded px-2 py-1 focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="">Vacant</option>
|
||||
{members.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.display_name || m.handle || 'Member'}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm(`Delete area-lead role "${r.area_name}"?`)) return
|
||||
const res = await deleteAreaLeadRole(r.id)
|
||||
if (res.error) { toast.error(res.error); return }
|
||||
setRows(prev => prev.filter(x => x.id !== r.id))
|
||||
toast.success('Deleted')
|
||||
}}
|
||||
className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { setRolePermissions } from '@/lib/actions'
|
||||
import { PERMISSIONS, PERMISSION_GROUPS } from '@/lib/permissions-catalog'
|
||||
import { BUILTIN_ROLES } from '@/lib/role-labels'
|
||||
import { Card } from './card'
|
||||
import type { CustomRole } from './types'
|
||||
|
||||
interface Props {
|
||||
isAdmin: boolean
|
||||
customRoles: CustomRole[]
|
||||
rolePerms: Array<{ subject: string; permission: string }>
|
||||
}
|
||||
|
||||
export function PermissionsPanel({ isAdmin, customRoles, rolePerms }: Props) {
|
||||
// subjects: built-in roles except admin (implicit-all), plus custom roles.
|
||||
const subjects = [
|
||||
...BUILTIN_ROLES.filter(r => r !== 'admin').map(r => ({ key: r, label: r })),
|
||||
...customRoles.map(c => ({ key: c.slug, label: c.name })),
|
||||
]
|
||||
|
||||
const initial: Record<string, Set<string>> = {}
|
||||
for (const s of subjects) initial[s.key] = new Set()
|
||||
for (const rp of rolePerms) {
|
||||
if (!initial[rp.subject]) initial[rp.subject] = new Set()
|
||||
initial[rp.subject].add(rp.permission)
|
||||
}
|
||||
const [grants, setGrants] = useState(initial)
|
||||
const [savingSubject, setSavingSubject] = useState<string | null>(null)
|
||||
|
||||
async function toggle(subject: string, permission: string, on: boolean) {
|
||||
const next = new Set(grants[subject] ?? [])
|
||||
if (on) next.add(permission)
|
||||
else next.delete(permission)
|
||||
setGrants(g => ({ ...g, [subject]: next }))
|
||||
setSavingSubject(subject)
|
||||
const result = await setRolePermissions({ subject, permissions: Array.from(next) })
|
||||
setSavingSubject(null)
|
||||
if (result.error) {
|
||||
toast.error(result.error)
|
||||
// revert
|
||||
setGrants(g => {
|
||||
const back = new Set(g[subject] ?? [])
|
||||
if (on) back.delete(permission)
|
||||
else back.add(permission)
|
||||
return { ...g, [subject]: back }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Permissions"
|
||||
blurb="Grant capabilities to each role. Admin always holds every permission and cannot be locked out. Permissions widen what a role can do through the surfaces that consult them; they never override the database tenant isolation."
|
||||
>
|
||||
<div className="mb-4 flex items-center gap-2 flex-wrap">
|
||||
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
|
||||
<span className="font-sans text-xs text-muted-foreground">holds all permissions (implicit, locked)</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left font-mono text-[10px] tracking-widest text-muted-foreground uppercase py-2 pr-4 sticky left-0 bg-card">Permission</th>
|
||||
{subjects.map(s => (
|
||||
<th key={s.key} className="px-2 py-2 font-mono text-[10px] text-muted-foreground text-center min-w-[88px]">
|
||||
{s.label}{savingSubject === s.key && <span className="text-primary"> …</span>}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{PERMISSION_GROUPS.map(group => (
|
||||
<PermGroup
|
||||
key={group}
|
||||
group={group}
|
||||
subjects={subjects}
|
||||
grants={grants}
|
||||
isAdmin={isAdmin}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function PermGroup({
|
||||
group, subjects, grants, isAdmin, onToggle,
|
||||
}: {
|
||||
group: string
|
||||
subjects: { key: string; label: string }[]
|
||||
grants: Record<string, Set<string>>
|
||||
isAdmin: boolean
|
||||
onToggle: (subject: string, permission: string, on: boolean) => void
|
||||
}) {
|
||||
const perms = PERMISSIONS.filter(p => p.group === group)
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td colSpan={subjects.length + 1} className="pt-4 pb-1">
|
||||
<span className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">{group}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{perms.map(p => (
|
||||
<tr key={p.code} className="border-t border-border">
|
||||
<td className="py-2 pr-4 sticky left-0 bg-card">
|
||||
<span className="font-sans text-sm text-foreground">{p.label}</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground/60 ml-2">{p.code}</span>
|
||||
</td>
|
||||
{subjects.map(s => (
|
||||
<td key={s.key} className="px-2 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!isAdmin}
|
||||
checked={grants[s.key]?.has(p.code) ?? false}
|
||||
onChange={e => onToggle(s.key, p.code, e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { createKbEntry, updateKbEntry, deleteKbEntry } from '@/lib/actions'
|
||||
import { revealSecret, deleteSecret } from '@/lib/actions/secrets'
|
||||
import { OpsAclEditor } from '@/components/ops/ops-acl-editor'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { toast } from 'sonner'
|
||||
import type { Tables, TablesInsert } from '@/types/database'
|
||||
@@ -22,6 +23,9 @@ interface Props {
|
||||
areaLeads: AreaLead[]
|
||||
secrets: Secret[]
|
||||
canSeeSecrets: boolean
|
||||
canManageAcl?: boolean
|
||||
aclRoleOptions?: { value: string; label: string }[]
|
||||
aclByEntity?: Record<string, string[]>
|
||||
}
|
||||
|
||||
type Tab = 'kb' | 'processes' | 'secrets' | 'area-leads'
|
||||
@@ -342,10 +346,17 @@ function AreaLeadModal({
|
||||
}
|
||||
|
||||
// ─── Secret Row (reveal on click) ─────────────────────────────────────────────
|
||||
function SecretRow({ secret, onDelete }: { secret: Secret; onDelete: (id: string) => void }) {
|
||||
function SecretRow({ secret, onDelete, canManageAcl, aclRoleOptions, aclInitial }: {
|
||||
secret: Secret
|
||||
onDelete: (id: string) => void
|
||||
canManageAcl?: boolean
|
||||
aclRoleOptions?: { value: string; label: string }[]
|
||||
aclInitial?: string[]
|
||||
}) {
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
const [value, setValue] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showAcl, setShowAcl] = useState(false)
|
||||
|
||||
async function reveal() {
|
||||
if (revealed) { setRevealed(false); setValue(null); return }
|
||||
@@ -366,31 +377,50 @@ function SecretRow({ secret, onDelete }: { secret: Secret; onDelete: (id: string
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="w-8 h-8 rounded bg-amber-50 border border-amber-200 flex items-center justify-center flex-shrink-0">
|
||||
<Lock className="w-4 h-4 text-amber-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-sans text-sm font-medium text-foreground">{secret.title}</p>
|
||||
{secret.area && <p className="font-mono text-[10px] text-muted-foreground">{secret.area}</p>}
|
||||
{revealed && value && (
|
||||
<p className="font-mono text-xs text-foreground bg-muted px-2 py-1 rounded mt-1 break-all">{value}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={reveal}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1 font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition"
|
||||
title={revealed ? 'Hide' : 'Reveal'}
|
||||
>
|
||||
{loading ? '...' : revealed ? <EyeOff className="w-3 h-3" /> : <Eye className="w-3 h-3" />}
|
||||
{revealed ? 'Hide' : 'Reveal'}
|
||||
</button>
|
||||
<button onClick={handleDelete} className="text-muted-foreground hover:text-red-500 transition p-1" title="Delete">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded bg-amber-50 border border-amber-200 flex items-center justify-center flex-shrink-0">
|
||||
<Lock className="w-4 h-4 text-amber-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-sans text-sm font-medium text-foreground">{secret.title}</p>
|
||||
{secret.area && <p className="font-mono text-[10px] text-muted-foreground">{secret.area}</p>}
|
||||
{revealed && value && (
|
||||
<p className="font-mono text-xs text-foreground bg-muted px-2 py-1 rounded mt-1 break-all">{value}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{canManageAcl && (
|
||||
<button
|
||||
onClick={() => setShowAcl(v => !v)}
|
||||
className="font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition"
|
||||
title="Who can access this secret"
|
||||
>
|
||||
Access
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={reveal}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1 font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition"
|
||||
title={revealed ? 'Hide' : 'Reveal'}
|
||||
>
|
||||
{loading ? '...' : revealed ? <EyeOff className="w-3 h-3" /> : <Eye className="w-3 h-3" />}
|
||||
{revealed ? 'Hide' : 'Reveal'}
|
||||
</button>
|
||||
<button onClick={handleDelete} className="text-muted-foreground hover:text-red-500 transition p-1" title="Delete">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showAcl && canManageAcl && (
|
||||
<OpsAclEditor
|
||||
entityType="secret"
|
||||
entityId={secret.id}
|
||||
options={aclRoleOptions ?? []}
|
||||
initial={aclInitial ?? []}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -504,7 +534,7 @@ function KbEntryRow({
|
||||
}
|
||||
|
||||
// ─── Main Component ────────────────────────────────────────────────────────────
|
||||
export function OpsClient({ member, spaceId, kbEntries: initial, areaLeads: initialLeads, secrets: initialSecrets, canSeeSecrets }: Props) {
|
||||
export function OpsClient({ member, spaceId, kbEntries: initial, areaLeads: initialLeads, secrets: initialSecrets, canSeeSecrets, canManageAcl = false, aclRoleOptions = [], aclByEntity = {} }: Props) {
|
||||
const router = useRouter()
|
||||
const [activeTab, setActiveTab] = useState<Tab>('kb')
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -722,7 +752,14 @@ export function OpsClient({ member, spaceId, kbEntries: initial, areaLeads: init
|
||||
) : filteredSecrets.length > 0 ? (
|
||||
<div className="bg-card rounded border border-border divide-y divide-border">
|
||||
{filteredSecrets.map(s => (
|
||||
<SecretRow key={s.id} secret={s} onDelete={id => setSecrets(prev => prev.filter(x => x.id !== id))} />
|
||||
<SecretRow
|
||||
key={s.id}
|
||||
secret={s}
|
||||
onDelete={id => setSecrets(prev => prev.filter(x => x.id !== id))}
|
||||
canManageAcl={canManageAcl}
|
||||
aclRoleOptions={aclRoleOptions}
|
||||
aclInitial={aclByEntity[`secret:${s.id}`] ?? []}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
+23
-1
@@ -12,8 +12,9 @@ export default async function OpsPage() {
|
||||
if (!member) return null
|
||||
|
||||
const canSeeSecrets = member.role === 'admin' || member.role === 'board'
|
||||
const canManageAcl = member.role === 'admin' || member.role === 'board'
|
||||
|
||||
const [{ data: kbEntries }, { data: areaLeads }, secretsResult] = await Promise.all([
|
||||
const [{ data: kbEntries }, { data: areaLeads }, secretsResult, { data: customRoles }, { data: acl }] = await Promise.all([
|
||||
supabase.from('knowledge_base').select('*').eq('space_id', member.space_id).order('is_pinned', { ascending: false }).order('created_at', { ascending: false }),
|
||||
supabase.from('area_leads').select('*').eq('space_id', member.space_id).order('area_name'),
|
||||
canSeeSecrets
|
||||
@@ -22,8 +23,26 @@ export default async function OpsPage() {
|
||||
// through the revealSecret() server action.
|
||||
? supabase.from('secrets').select('id, title, area, created_at, label, description, icon, space_id, created_by, updated_at, category, notes, encryption_version').eq('space_id', member.space_id)
|
||||
: Promise.resolve({ data: [] as Tables<'secrets'>[] }),
|
||||
supabase.from('space_custom_roles').select('slug, name').eq('space_id', member.space_id),
|
||||
supabase.from('ops_acl').select('entity_type, entity_id, role').eq('space_id', member.space_id),
|
||||
])
|
||||
|
||||
// Selectable ACL roles: built-in roles, custom-role slugs, and area-lead
|
||||
// sentinels labelled by area name.
|
||||
const aclRoleOptions = [
|
||||
{ value: 'treasurer', label: 'treasurer' },
|
||||
{ value: 'member', label: 'member' },
|
||||
{ value: 'associate', label: 'associate' },
|
||||
...((customRoles ?? []).map(c => ({ value: c.slug, label: c.name }))),
|
||||
...((areaLeads ?? []).map(al => ({ value: `area_lead:${al.id}`, label: `${al.area_name} lead` }))),
|
||||
]
|
||||
|
||||
const aclByEntity: Record<string, string[]> = {}
|
||||
for (const row of acl ?? []) {
|
||||
const k = `${row.entity_type}:${row.entity_id}`
|
||||
;(aclByEntity[k] ??= []).push(row.role)
|
||||
}
|
||||
|
||||
return (
|
||||
<OpsClient
|
||||
member={member as Tables<'space_members'>}
|
||||
@@ -32,6 +51,9 @@ export default async function OpsPage() {
|
||||
areaLeads={areaLeads ?? []}
|
||||
secrets={secretsResult.data ?? []}
|
||||
canSeeSecrets={canSeeSecrets}
|
||||
canManageAcl={canManageAcl}
|
||||
aclRoleOptions={aclRoleOptions}
|
||||
aclByEntity={aclByEntity}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Branch: `main`.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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' }
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -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'),
|
||||
})
|
||||
|
||||
// ─── 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')
|
||||
|
||||
@@ -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
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user