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

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

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

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

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

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

types/database.ts: added the two new tables. 271/271 tests pass,
build clean. KB-modal ACL wiring + members-row shortcut deferred and
tracked in HANDOFF.
This commit is contained in:
Moheeb Zara
2026-05-15 17:15:14 -07:00
parent 109947552f
commit 9447b5ee80
19 changed files with 1192 additions and 29 deletions
+144
View File
@@ -1692,3 +1692,147 @@ DROP TRIGGER IF EXISTS trg_onboarding_steps_touch ON public.space_onboarding_ste
CREATE TRIGGER trg_onboarding_steps_touch
BEFORE UPDATE ON public.space_onboarding_steps
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
-- =============================================================================
-- 15. Customizable permissions, per-item Ops ACLs, area-lead effective roles.
-- Equivalent to scripts/023_permissions.sql. Additive: the new SELECT
-- branches only widen access; with no rows in the new tables behavior is
-- unchanged.
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.space_role_permissions (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
subject text NOT NULL CHECK (char_length(subject) BETWEEN 1 AND 50),
permission text NOT NULL CHECK (char_length(permission) BETWEEN 1 AND 60),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, subject, permission)
);
CREATE INDEX IF NOT EXISTS idx_role_perms_space ON public.space_role_permissions (space_id, subject);
CREATE TABLE IF NOT EXISTS public.ops_acl (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
entity_type text NOT NULL CHECK (entity_type IN ('secret','kb','process','area_lead')),
entity_id uuid NOT NULL,
role text NOT NULL CHECK (char_length(role) BETWEEN 1 AND 64),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, entity_type, entity_id, role)
);
CREATE INDEX IF NOT EXISTS idx_ops_acl_entity ON public.ops_acl (entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_ops_acl_space ON public.ops_acl (space_id);
CREATE OR REPLACE FUNCTION public.user_effective_roles(uid uuid, sid uuid)
RETURNS SETOF text LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
AS $$
SELECT sm.role::text FROM public.space_members sm
WHERE sm.user_id = uid AND sm.space_id = sid
UNION
SELECT cr.slug
FROM public.space_members sm
JOIN public.space_member_custom_roles mcr ON mcr.member_id = sm.id
JOIN public.space_custom_roles cr ON cr.id = mcr.custom_role_id
WHERE sm.user_id = uid AND sm.space_id = sid
UNION
SELECT 'area_lead:' || al.id::text
FROM public.space_members sm
JOIN public.area_leads al ON al.lead_id = sm.id
WHERE sm.user_id = uid AND sm.space_id = sid AND al.space_id = sid;
$$;
CREATE OR REPLACE FUNCTION public.user_has_permission(uid uuid, sid uuid, perm text)
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
AS $$
SELECT public.user_has_role_in_space(uid, sid, ARRAY['admin'])
OR EXISTS (
SELECT 1 FROM public.space_role_permissions p
WHERE p.space_id = sid AND p.permission = perm
AND p.subject IN (SELECT public.user_effective_roles(uid, sid))
);
$$;
ALTER TABLE public.space_role_permissions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.ops_acl ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS role_perms_select ON public.space_role_permissions;
DROP POLICY IF EXISTS role_perms_insert ON public.space_role_permissions;
DROP POLICY IF EXISTS role_perms_update ON public.space_role_permissions;
DROP POLICY IF EXISTS role_perms_delete ON public.space_role_permissions;
CREATE POLICY role_perms_select ON public.space_role_permissions FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY role_perms_insert ON public.space_role_permissions FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY role_perms_update ON public.space_role_permissions FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY role_perms_delete ON public.space_role_permissions FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
DROP POLICY IF EXISTS ops_acl_select ON public.ops_acl;
DROP POLICY IF EXISTS ops_acl_insert ON public.ops_acl;
DROP POLICY IF EXISTS ops_acl_update ON public.ops_acl;
DROP POLICY IF EXISTS ops_acl_delete ON public.ops_acl;
CREATE POLICY ops_acl_select ON public.ops_acl FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY ops_acl_insert ON public.ops_acl FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY ops_acl_update ON public.ops_acl FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY ops_acl_delete ON public.ops_acl FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
DROP POLICY IF EXISTS secrets_select ON public.secrets;
CREATE POLICY secrets_select ON public.secrets FOR SELECT
USING (
public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
OR EXISTS (
SELECT 1 FROM public.ops_acl a
WHERE a.space_id = secrets.space_id AND a.entity_type = 'secret'
AND a.entity_id = secrets.id
AND a.role IN (SELECT public.user_effective_roles(auth.uid(), secrets.space_id))
)
);
DROP POLICY IF EXISTS kb_select ON public.knowledge_base;
CREATE POLICY kb_select ON public.knowledge_base FOR SELECT
USING (
(
space_id IN (SELECT public.get_user_space_ids(auth.uid()))
AND (
visibility = 'all_members'
OR (visibility = 'board' AND public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']))
OR (visibility = 'admin_only' AND public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']))
)
)
OR EXISTS (
SELECT 1 FROM public.ops_acl a
WHERE a.space_id = knowledge_base.space_id AND a.entity_type IN ('kb','process')
AND a.entity_id = knowledge_base.id
AND a.role IN (SELECT public.user_effective_roles(auth.uid(), knowledge_base.space_id))
)
);
CREATE OR REPLACE FUNCTION public.seed_default_role_permissions()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO public.space_role_permissions (space_id, subject, permission)
SELECT NEW.id, d.subject, d.permission
FROM (VALUES
('board','ops.kb.read'),('board','ops.kb.write'),('board','ops.process.read'),
('board','ops.process.write'),('board','ops.secrets.read'),('board','ops.secrets.write'),
('board','ops.arealeads.manage'),('board','members.manage'),('board','payments.manage'),
('board','governance.manage'),('board','forum.moderate'),('board','customize.manage'),
('board','settings.manage'),
('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'),
('member','ops.kb.read'),('member','ops.process.read'),
('associate','ops.kb.read')
) AS d(subject, permission)
ON CONFLICT (space_id, subject, permission) DO NOTHING;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_seed_default_role_permissions ON public.spaces;
CREATE TRIGGER trg_seed_default_role_permissions
AFTER INSERT ON public.spaces
FOR EACH ROW EXECUTE FUNCTION public.seed_default_role_permissions();