feat(rebac): first pass
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- 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'
|
||||
);
|
||||
Reference in New Issue
Block a user