Files

162 lines
6.7 KiB
PL/PgSQL
Raw Permalink Normal View History

2026-05-20 22:56:00 +02:00
-- ════════════════════════════════════════════════════════════════════════════
-- ReBAC: access_grants table + lifecycle cleanup triggers + data migration
-- ════════════════════════════════════════════════════════════════════════════
-- PR 1 of the ReBAC rollout. Schema and data only — no code changes yet.
--
-- This migration:
-- 1. Creates storage.access_grants (the single grant table for ReBAC)
-- 2. Installs AFTER DELETE triggers so lifecycle cleanup is enforced at the
-- DB level even if a future code path bypasses the service layer
-- 3. Migrates existing storage.shares permission flags into access_grants
-- rows with subject_type='token'
--
-- The storage.shares.permissions_* columns are NOT dropped here. They stay
-- until PR 5 (share_service is updated to read from access_grants instead).
-- See /Users/ed/.claude/plans/compiled-shimmying-bonbon.md → "Rollout sequencing".
-- ── 1. The grant table ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS storage.access_grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Subject (who has the permission)
-- 'user' → auth.users.id
-- 'group' → future: group membership
-- 'token' → refers to storage.shares.id (anonymous link)
-- 'external' → future: refers to auth.external_subjects.id
-- (Open Cloud Mesh / federated OIDC)
subject_type TEXT NOT NULL
CHECK (subject_type IN ('user', 'group', 'token', 'external')),
subject_id UUID NOT NULL,
-- Resource (what the permission is on)
resource_type TEXT NOT NULL
CHECK (resource_type IN ('folder', 'file')),
resource_id UUID NOT NULL,
-- Permission (what action is allowed)
permission TEXT NOT NULL
CHECK (permission IN ('read', 'create', 'share', 'comment', 'delete', 'update')),
-- Audit
granted_by UUID NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (subject_type, subject_id, resource_type, resource_id, permission)
);
CREATE INDEX IF NOT EXISTS idx_grants_subject
ON storage.access_grants (subject_type, subject_id);
CREATE INDEX IF NOT EXISTS idx_grants_resource
ON storage.access_grants (resource_type, resource_id);
COMMENT ON TABLE storage.access_grants IS
'ReBAC grant table — subject × resource × permission. Owner is implicit '
'via storage.folders.user_id / storage.files.user_id (no rows here for owners).';
-- ── 2. Lifecycle cleanup triggers (defense-in-depth) ────────────────────────
-- These fire AFTER DELETE on the resource/subject tables so stale grants can
-- never outlive their target. The application layer also calls explicit
-- engine.revoke_all_for_* on the canonical paths.
CREATE OR REPLACE FUNCTION storage.cleanup_grants_on_resource_delete()
RETURNS TRIGGER AS $$
BEGIN
DELETE FROM storage.access_grants
WHERE resource_type = TG_ARGV[0]
AND resource_id = OLD.id;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_cleanup_grants_folder ON storage.folders;
CREATE TRIGGER trg_cleanup_grants_folder
AFTER DELETE ON storage.folders
FOR EACH ROW
EXECUTE FUNCTION storage.cleanup_grants_on_resource_delete('folder');
DROP TRIGGER IF EXISTS trg_cleanup_grants_file ON storage.files;
CREATE TRIGGER trg_cleanup_grants_file
AFTER DELETE ON storage.files
FOR EACH ROW
EXECUTE FUNCTION storage.cleanup_grants_on_resource_delete('file');
CREATE OR REPLACE FUNCTION storage.cleanup_grants_on_subject_delete()
RETURNS TRIGGER AS $$
BEGIN
DELETE FROM storage.access_grants
WHERE subject_type = TG_ARGV[0]
AND subject_id = OLD.id;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_cleanup_grants_user ON auth.users;
CREATE TRIGGER trg_cleanup_grants_user
AFTER DELETE ON auth.users
FOR EACH ROW
EXECUTE FUNCTION storage.cleanup_grants_on_subject_delete('user');
DROP TRIGGER IF EXISTS trg_cleanup_grants_token ON storage.shares;
CREATE TRIGGER trg_cleanup_grants_token
AFTER DELETE ON storage.shares
FOR EACH ROW
EXECUTE FUNCTION storage.cleanup_grants_on_subject_delete('token');
-- ── 3. Data migration from storage.shares ───────────────────────────────────
-- Each existing share row becomes one or more access_grants rows with
-- subject_type='token', subject_id=shares.id.
--
-- The old model's permission flags map to the new model as:
-- permissions_read → ['read']
-- permissions_write → ['read', 'create', 'update', 'delete']
-- (write implies full mutation rights)
-- permissions_reshare → ['share']
--
-- WHERE NOT EXISTS guards make this idempotent — re-running the migration
-- won't create duplicates.
INSERT INTO storage.access_grants
(subject_type, subject_id, resource_type, resource_id, permission, granted_by)
SELECT 'token', s.id, s.item_type, s.item_id::uuid, 'read', s.created_by
FROM storage.shares s
WHERE s.permissions_read
AND NOT EXISTS (
SELECT 1 FROM storage.access_grants g
WHERE g.subject_type = 'token'
AND g.subject_id = s.id
AND g.resource_id = s.item_id::uuid
AND g.permission = 'read'
);
INSERT INTO storage.access_grants
(subject_type, subject_id, resource_type, resource_id, permission, granted_by)
SELECT 'token', s.id, s.item_type, s.item_id::uuid, p.perm, s.created_by
FROM storage.shares s
CROSS JOIN (VALUES ('read'), ('create'), ('update'), ('delete')) AS p(perm)
WHERE s.permissions_write
AND NOT EXISTS (
SELECT 1 FROM storage.access_grants g
WHERE g.subject_type = 'token'
AND g.subject_id = s.id
AND g.resource_id = s.item_id::uuid
AND g.permission = p.perm
);
INSERT INTO storage.access_grants
(subject_type, subject_id, resource_type, resource_id, permission, granted_by)
SELECT 'token', s.id, s.item_type, s.item_id::uuid, 'share', s.created_by
FROM storage.shares s
WHERE s.permissions_reshare
AND NOT EXISTS (
SELECT 1 FROM storage.access_grants g
WHERE g.subject_type = 'token'
AND g.subject_id = s.id
AND g.resource_id = s.item_id::uuid
AND g.permission = 'share'
);