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.
201 lines
9.6 KiB
PL/PgSQL
201 lines
9.6 KiB
PL/PgSQL
-- =============================================================================
|
|
-- 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;
|