m/paliad#61 Slice B backend. Implements the explicit-share path
(checklist_shares + visibility predicate extension) and the
global_admin-only promotion / demotion of authored templates to and
from the firm catalog.
Schema (mig 115, idempotent):
- paliad.checklist_shares (uuid id, checklist_id FK, polymorphic
recipient via xor-check: recipient_kind in {user, office,
partner_unit, project} with exactly one matching recipient_* column
populated; granted_by FK; granted_at)
- Hot-path lookup index + per-kind partial UNIQUE indexes prevent
duplicate grants
- RLS: SELECT owner OR self-recipient (user-kind) OR global_admin;
INSERT owner-only with granted_by=self; DELETE owner OR global_admin;
no UPDATE (revoke = DELETE)
- can_see_checklist CREATE OR REPLACE — adds 4 share branches; project-
share branch uses inline ltree walk over projects.path because
can_see_project reads auth.uid() (NULL on service-role connection,
same pattern as visibility.go)
- xor-check verified live: rejects kind='user' with recipient_office
set; accepts the matching kind/recipient pair
Services:
- ChecklistShareService — Grant (owner-only, validates recipient kind +
required FK target, friendly 409 on partial-unique-index conflict),
Revoke (owner or global_admin), ListGrants (owner or global_admin;
enriches recipient_label via LEFT JOINs)
- ChecklistPromotionService — Promote (global_admin → visibility=global
+ promoted_at/by + audit), Demote (global_admin → target visibility,
default 'firm', clears promoted_at/by; rejects demote of non-global
rows)
- ChecklistCatalogService.checklistVisibilityPredicate extended to
include all 5 share branches; service-role-friendly (no auth.uid())
- ChecklistTemplateService.normaliseSliceAVisibility now accepts
'shared' as an author-set value; 'global' stays admin-only
Endpoints:
- GET /api/checklists/templates/{slug}/shares — list grants (owner/admin)
- POST /api/checklists/templates/{slug}/shares — grant
- DELETE /api/checklists/shares/{id} — revoke
- POST /api/admin/checklists/{slug}/promote — promote to global
- POST /api/admin/checklists/{slug}/demote — demote (body.target default 'firm')
Audit (paliad.system_audit_log):
- checklist.shared — recipient_kind + recipient_id in metadata
- checklist.unshared — same shape, captured pre-DELETE
- checklist.promoted_global — prior_visibility + owner_id
- checklist.demoted — target_visibility
Tests: validateShareInput covers all 4 kinds (happy + missing-id);
predicate-shape test asserts all 6 visibility branches present;
pqUniqueViolation regex sniff; nullableString helper; SliceB visibility
opens 'shared' but keeps 'global' admin-only.
Hotfix-merge note: head shipped 794617c after Slice A — the
template-edit page route moved from /checklists/{slug}/edit to
/checklists/templates/{slug}/edit to disambiguate from
/checklists/instances/{id}. Slice B routes follow the safe
/<resource>/<noun>/{id} pattern (no new {slug}-then-verb endpoints).
212 lines
9.1 KiB
PL/PgSQL
212 lines
9.1 KiB
PL/PgSQL
-- mig 115 — t-paliad-225 / m/paliad#61 Slice B — explicit sharing +
|
|
-- admin-promotion plumbing for user-authored checklists.
|
|
--
|
|
-- Design: docs/design-user-checklists-2026-05-20.md §3.2 / §4.2 / §4.3
|
|
-- / §4.5.
|
|
--
|
|
-- Introduces paliad.checklist_shares with the polymorphic recipient
|
|
-- pattern (xor-check enforces exactly one recipient_* column populated
|
|
-- per recipient_kind). Extends paliad.can_see_checklist with the
|
|
-- explicit-share branches so the 'shared' visibility level actually
|
|
-- gates anything.
|
|
--
|
|
-- Sections:
|
|
-- 1. CREATE TABLE paliad.checklist_shares (+ indexes + RLS).
|
|
-- 2. CREATE OR REPLACE paliad.can_see_checklist — adds 4 share
|
|
-- branches (user / office / partner_unit / project).
|
|
--
|
|
-- Idempotent throughout.
|
|
|
|
-- ============================================================================
|
|
-- 1. paliad.checklist_shares — explicit grants for a single checklist.
|
|
--
|
|
-- recipient_kind disambiguates which recipient_* column is populated.
|
|
-- The XOR check makes the constraint structurally enforce "exactly one
|
|
-- recipient_<kind> non-null per row". Per-kind UNIQUE partial indexes
|
|
-- prevent duplicate grants per (checklist, recipient).
|
|
--
|
|
-- Slice A's checklists.visibility CHECK already includes 'shared' so no
|
|
-- ALTER is needed here.
|
|
-- ============================================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS paliad.checklist_shares (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
checklist_id uuid NOT NULL REFERENCES paliad.checklists(id) ON DELETE CASCADE,
|
|
recipient_kind text NOT NULL
|
|
CHECK (recipient_kind IN ('user', 'office', 'partner_unit', 'project')),
|
|
recipient_user_id uuid REFERENCES paliad.users(id) ON DELETE CASCADE,
|
|
recipient_office text,
|
|
recipient_partner_unit_id uuid REFERENCES paliad.partner_units(id) ON DELETE CASCADE,
|
|
recipient_project_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
|
granted_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE SET NULL,
|
|
granted_at timestamptz NOT NULL DEFAULT now(),
|
|
|
|
CONSTRAINT checklist_shares_recipient_xor CHECK (
|
|
(recipient_kind = 'user'
|
|
AND recipient_user_id IS NOT NULL
|
|
AND recipient_office IS NULL
|
|
AND recipient_partner_unit_id IS NULL
|
|
AND recipient_project_id IS NULL)
|
|
OR (recipient_kind = 'office'
|
|
AND recipient_office IS NOT NULL
|
|
AND recipient_user_id IS NULL
|
|
AND recipient_partner_unit_id IS NULL
|
|
AND recipient_project_id IS NULL)
|
|
OR (recipient_kind = 'partner_unit'
|
|
AND recipient_partner_unit_id IS NOT NULL
|
|
AND recipient_user_id IS NULL
|
|
AND recipient_office IS NULL
|
|
AND recipient_project_id IS NULL)
|
|
OR (recipient_kind = 'project'
|
|
AND recipient_project_id IS NOT NULL
|
|
AND recipient_user_id IS NULL
|
|
AND recipient_office IS NULL
|
|
AND recipient_partner_unit_id IS NULL)
|
|
)
|
|
);
|
|
|
|
-- Hot-path lookup for the visibility predicate.
|
|
CREATE INDEX IF NOT EXISTS checklist_shares_lookup_idx
|
|
ON paliad.checklist_shares (checklist_id);
|
|
|
|
-- Uniqueness per recipient kind. Partial indexes so a NULL recipient_<other>
|
|
-- doesn't collide with another row's NULL recipient_<other>.
|
|
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_user_uniq
|
|
ON paliad.checklist_shares (checklist_id, recipient_user_id)
|
|
WHERE recipient_kind = 'user';
|
|
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_office_uniq
|
|
ON paliad.checklist_shares (checklist_id, recipient_office)
|
|
WHERE recipient_kind = 'office';
|
|
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_partner_unit_uniq
|
|
ON paliad.checklist_shares (checklist_id, recipient_partner_unit_id)
|
|
WHERE recipient_kind = 'partner_unit';
|
|
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_project_uniq
|
|
ON paliad.checklist_shares (checklist_id, recipient_project_id)
|
|
WHERE recipient_kind = 'project';
|
|
|
|
COMMENT ON TABLE paliad.checklist_shares IS
|
|
'Explicit grants for paliad.checklists. Polymorphic recipient '
|
|
'(user/office/partner_unit/project) enforced by recipient_xor CHECK. '
|
|
'Owner of the checklist grants and revokes; global_admin can revoke '
|
|
'as well. Slice B (t-paliad-225) — see can_see_checklist body for '
|
|
'the visibility branches that consume these rows.';
|
|
|
|
ALTER TABLE paliad.checklist_shares ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- SELECT: caller can see the row if they own the parent checklist OR
|
|
-- they are the recipient (for user-kind grants — recipients shouldn't
|
|
-- be surprised by who else can also see the checklist) OR global_admin.
|
|
DROP POLICY IF EXISTS checklist_shares_select ON paliad.checklist_shares;
|
|
CREATE POLICY checklist_shares_select
|
|
ON paliad.checklist_shares FOR SELECT TO authenticated
|
|
USING (
|
|
EXISTS (
|
|
SELECT 1 FROM paliad.checklists c
|
|
WHERE c.id = checklist_id AND c.owner_id = auth.uid()
|
|
)
|
|
OR (recipient_kind = 'user' AND recipient_user_id = auth.uid())
|
|
OR EXISTS (
|
|
SELECT 1 FROM paliad.users u
|
|
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
|
)
|
|
);
|
|
|
|
-- INSERT: only the checklist owner can grant; granted_by must be self.
|
|
DROP POLICY IF EXISTS checklist_shares_insert ON paliad.checklist_shares;
|
|
CREATE POLICY checklist_shares_insert
|
|
ON paliad.checklist_shares FOR INSERT TO authenticated
|
|
WITH CHECK (
|
|
EXISTS (
|
|
SELECT 1 FROM paliad.checklists c
|
|
WHERE c.id = checklist_id AND c.owner_id = auth.uid()
|
|
)
|
|
AND granted_by = auth.uid()
|
|
);
|
|
|
|
-- DELETE: owner OR global_admin. No UPDATE policy — grants are
|
|
-- immutable, revoke = DELETE + re-insert with the corrected recipient.
|
|
DROP POLICY IF EXISTS checklist_shares_delete ON paliad.checklist_shares;
|
|
CREATE POLICY checklist_shares_delete
|
|
ON paliad.checklist_shares FOR DELETE TO authenticated
|
|
USING (
|
|
EXISTS (
|
|
SELECT 1 FROM paliad.checklists c
|
|
WHERE c.id = checklist_id AND c.owner_id = auth.uid()
|
|
)
|
|
OR EXISTS (
|
|
SELECT 1 FROM paliad.users u
|
|
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
|
)
|
|
);
|
|
|
|
-- ============================================================================
|
|
-- 2. paliad.can_see_checklist — extend with the 4 share branches.
|
|
--
|
|
-- Owner + firm/global branches stay as in mig 114. Share branches:
|
|
-- - user — the row's recipient_user_id matches the caller
|
|
-- - office — recipient_office matches caller's office OR is in
|
|
-- their additional_offices array
|
|
-- - partner_unit — caller is a member of the recipient partner_unit
|
|
-- - project — caller can see the recipient project (reuses
|
|
-- paliad.can_see_project, ltree-walked)
|
|
--
|
|
-- can_see_project reads auth.uid() through SECURITY DEFINER inheritance
|
|
-- (same pattern effective_project_admin uses in mig 111).
|
|
-- ============================================================================
|
|
|
|
CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid)
|
|
RETURNS boolean
|
|
LANGUAGE sql STABLE SECURITY DEFINER
|
|
SET search_path TO 'paliad', 'public'
|
|
AS $$
|
|
-- Owner
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM paliad.checklists c
|
|
WHERE c.id = _checklist_id AND c.owner_id = _user_id
|
|
)
|
|
-- firm / global
|
|
OR EXISTS (
|
|
SELECT 1 FROM paliad.checklists c
|
|
WHERE c.id = _checklist_id AND c.visibility IN ('firm', 'global')
|
|
)
|
|
-- Explicit share: user
|
|
OR EXISTS (
|
|
SELECT 1 FROM paliad.checklist_shares s
|
|
WHERE s.checklist_id = _checklist_id
|
|
AND s.recipient_kind = 'user'
|
|
AND s.recipient_user_id = _user_id
|
|
)
|
|
-- Explicit share: office (caller's primary OR additional offices)
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM paliad.checklist_shares s
|
|
JOIN paliad.users u ON u.id = _user_id
|
|
WHERE s.checklist_id = _checklist_id
|
|
AND s.recipient_kind = 'office'
|
|
AND (s.recipient_office = u.office
|
|
OR s.recipient_office = ANY(u.additional_offices))
|
|
)
|
|
-- Explicit share: partner_unit (caller is a member)
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM paliad.checklist_shares s
|
|
JOIN paliad.partner_unit_members pum
|
|
ON pum.partner_unit_id = s.recipient_partner_unit_id
|
|
AND pum.user_id = _user_id
|
|
WHERE s.checklist_id = _checklist_id
|
|
AND s.recipient_kind = 'partner_unit'
|
|
)
|
|
-- Explicit share: project (caller can see the project via existing predicate)
|
|
OR EXISTS (
|
|
SELECT 1 FROM paliad.checklist_shares s
|
|
WHERE s.checklist_id = _checklist_id
|
|
AND s.recipient_kind = 'project'
|
|
AND paliad.can_see_project(s.recipient_project_id)
|
|
);
|
|
$$;
|
|
|
|
COMMENT ON FUNCTION paliad.can_see_checklist(uuid, uuid) IS
|
|
'True iff the user owns the checklist OR firm/global visibility OR '
|
|
'an explicit share row matches the caller (by user / office / '
|
|
'partner_unit / project ancestry).';
|