Merge pull request #465 from EdouardVanbelle/feat/drive
This commit is contained in:
@@ -107,9 +107,19 @@ storage.access_grants
|
||||
expires_at TIMESTAMPTZ NULL
|
||||
```
|
||||
|
||||
One row per `(subject, permission, resource)` triple. An "admin role on folder
|
||||
One row per `(subject, permission, resource)` triple. An "owner role on folder
|
||||
X for user Y" is 6 rows; a "viewer role" is 1 row.
|
||||
|
||||
> **Note (D-Prep, 2026-06-17):** the role assignment has since pivoted into
|
||||
> a separate `storage.role_grants` table that stores **one row per role
|
||||
> assignment** rather than one per permission. `access_grants` stays
|
||||
> populated via dual-write during the transition; the engine reads the
|
||||
> role-keyed table for authz decisions. The cleanup PR drops
|
||||
> `access_grants` after the dual-write window. The historical role name
|
||||
> `Admin` was renamed to `Owner` at the same time, to disambiguate from
|
||||
> `UserRole::Admin` (user-account privilege) and match Drive plan
|
||||
> terminology.
|
||||
|
||||
Cleanup is trigger-driven (`trg_cleanup_grants_folder`, …): when a resource or
|
||||
subject is deleted, all referencing grants disappear in the same transaction.
|
||||
|
||||
|
||||
+1401
File diff suppressed because it is too large
Load Diff
@@ -602,20 +602,29 @@ impl Role {
|
||||
pub fn expand(self) -> &'static [Permission] {
|
||||
match self {
|
||||
Role::Viewer => &[Permission::Read],
|
||||
Role::Commenter => &[Permission::Read, Permission::Comment],
|
||||
Role::Editor => &[Permission::Read, Permission::Comment,
|
||||
Permission::Create, Permission::Update],
|
||||
Role::Manager => &[Permission::Read, Permission::Comment,
|
||||
Permission::Create, Permission::Update,
|
||||
Permission::Share],
|
||||
Role::Admin => &[Permission::Read, Permission::Comment,
|
||||
Permission::Create, Permission::Update,
|
||||
Permission::Share, Permission::Delete],
|
||||
Role::Commenter => &[Permission::Read, Permission::Comment],
|
||||
Role::Contributor => &[Permission::Read, Permission::Create],
|
||||
Role::Editor => &[Permission::Read, Permission::Comment,
|
||||
Permission::Create, Permission::Update],
|
||||
Role::Owner => &[Permission::Read, Permission::Comment,
|
||||
Permission::Create, Permission::Update,
|
||||
Permission::Share, Permission::Delete,
|
||||
Permission::Manage],
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note (D-Prep, 2026-06-17):** the `Manager` role was retired before shipping
|
||||
> (its bundle was a strict subset of `Owner`); the historical `Admin` role was
|
||||
> renamed to `Owner` to disambiguate from `UserRole::Admin` (the user-account
|
||||
> privilege) and match Drive plan terminology. `Contributor` is the new
|
||||
> drop-zone role. The actual on-the-wire enum lives in
|
||||
> `src/application/dtos/grant_dto.rs`; that file is the canonical source of
|
||||
> truth for bundle expansion. The pivot to role-keyed storage (`role_grants`
|
||||
> table) also happened in D-Prep — see
|
||||
> `docs/architecture/rebac-authorization.md` for the dual-write timeline.
|
||||
|
||||
### `POST /api/grants` accepts either shape
|
||||
|
||||
```json
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- D-Prep: storage.role_grants — role-bundle replacement for access_grants
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Refactor #1 of the Drive sequence (see `docs/plan/drive.md` § Prerequisite).
|
||||
--
|
||||
-- Today every role assignment is stored as N rows in `storage.access_grants`
|
||||
-- (one row per Permission in the role's bundle — editor = 4 rows, owner = 6).
|
||||
-- This migration introduces `storage.role_grants` where each role assignment
|
||||
-- is ONE row carrying the role name; permission expansion happens at engine
|
||||
-- read time via the in-code `role_bundle()` function.
|
||||
--
|
||||
-- The five roles shipped on day one:
|
||||
-- viewer = {read}
|
||||
-- commenter = {comment, read} ← new
|
||||
-- contributor = {create, read} ← new
|
||||
-- editor = {comment, create, read, update}
|
||||
-- owner = {comment, create, delete, read, share, update}
|
||||
-- (post-Drive: + manage, when Group-as-Resource lands)
|
||||
--
|
||||
-- This migration is **additive**: `storage.access_grants` stays populated as
|
||||
-- a dual-write safety net until a follow-up cleanup PR drops it after the
|
||||
-- new model has baked in production. The down migration just drops
|
||||
-- role_grants — access_grants is untouched, so rollback is trivial.
|
||||
--
|
||||
-- Pre-flight: the migration REFUSES to run if `access_grants` contains any
|
||||
-- non-bundle clusters (permission sets that don't match one of the five
|
||||
-- roles above). Run `tools/audit-grants-bundle-shape.sql` first to confirm
|
||||
-- the data is clean — Ed's audit on 2026-06-17 returned 100% bundle-shaped.
|
||||
|
||||
|
||||
-- ── 1. Pre-flight assertion ─────────────────────────────────────────────────
|
||||
-- Refuse to migrate if there are any non-bundle clusters. The five known
|
||||
-- bundles are listed here verbatim; keep them in sync with the in-code
|
||||
-- `role_bundle()` function.
|
||||
|
||||
DO $BODY$
|
||||
DECLARE
|
||||
bad_count BIGINT;
|
||||
BEGIN
|
||||
WITH cluster AS (
|
||||
SELECT subject_type, subject_id, resource_type, resource_id,
|
||||
array_agg(permission ORDER BY permission) AS perms
|
||||
FROM storage.access_grants
|
||||
GROUP BY 1, 2, 3, 4
|
||||
)
|
||||
SELECT count(*) INTO bad_count
|
||||
FROM cluster
|
||||
WHERE perms NOT IN (
|
||||
ARRAY['read']::text[],
|
||||
ARRAY['comment','read']::text[],
|
||||
ARRAY['create','read']::text[],
|
||||
ARRAY['comment','create','read','update']::text[],
|
||||
ARRAY['comment','create','delete','read','share','update']::text[]
|
||||
);
|
||||
|
||||
IF bad_count > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'D-Prep migration refused: % (subject,resource) clusters in '
|
||||
'storage.access_grants have non-bundle permission sets. Run '
|
||||
'tools/audit-grants-bundle-shape.sql section 3 to inspect them, '
|
||||
'then either resolve manually or extend the bundle list above '
|
||||
'with a new named role before retrying.', bad_count;
|
||||
END IF;
|
||||
END $BODY$;
|
||||
|
||||
|
||||
-- ── 2. The role_grants table ────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS storage.role_grants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- Subject (who has the role)
|
||||
-- 'user' → auth.users.id
|
||||
-- 'group' → storage.subject_groups.id
|
||||
-- 'token' → storage.shares.id (anonymous link — always 'viewer')
|
||||
subject_type TEXT NOT NULL
|
||||
CHECK (subject_type IN ('user', 'group', 'token')),
|
||||
subject_id UUID NOT NULL,
|
||||
|
||||
-- Resource (what the role is on)
|
||||
-- 'drive' and 'group' join later as Drive + Group-as-Resource land.
|
||||
resource_type TEXT NOT NULL
|
||||
CHECK (resource_type IN ('folder', 'file')),
|
||||
resource_id UUID NOT NULL,
|
||||
|
||||
-- Role — expands to a permission bundle via the in-code `role_bundle()`
|
||||
-- function. The CHECK lists the day-one role roster; adding a new
|
||||
-- role is a single ALTER TABLE DROP CONSTRAINT / ADD CONSTRAINT pair
|
||||
-- (or replace with a foreign key into a lookup table if instance-
|
||||
-- defined roles ever land).
|
||||
--
|
||||
-- Universal roster: ANY role can be granted on ANY resource_type.
|
||||
-- Permission bundles include capabilities the resource type may not
|
||||
-- check for (e.g. `Manage` on a folder, `Create` on a file); those
|
||||
-- produce harmless no-ops at engine read time — no per-resource-type
|
||||
-- validation needed at the DB layer.
|
||||
--
|
||||
-- The UI exposes only Viewer/Editor/Owner in the share dialog today
|
||||
-- (matches the existing 3-button UX). Commenter and Contributor stay
|
||||
-- in the enum for server-side use + future UI exposure when a real
|
||||
-- use case asks for them.
|
||||
role TEXT NOT NULL
|
||||
CHECK (role IN ('viewer', 'commenter', 'contributor', 'editor', 'owner')),
|
||||
|
||||
-- Audit + lifecycle
|
||||
granted_by UUID NOT NULL,
|
||||
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
|
||||
-- Exactly one role per (subject, resource). Atomic role changes become
|
||||
-- a single UPDATE; no DELETE+INSERT race.
|
||||
UNIQUE (subject_type, subject_id, resource_type, resource_id)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE storage.role_grants IS
|
||||
'Role-based ReBAC grants. One row = one role assignment. Permission '
|
||||
'bundle expansion is in-code; see role_bundle() in '
|
||||
'src/application/dtos/grant_dto.rs. Replaces storage.access_grants; '
|
||||
'both tables coexist during the D-Prep dual-write window.';
|
||||
COMMENT ON COLUMN storage.role_grants.role IS
|
||||
'One of viewer / commenter / contributor / editor / owner. Expanded to '
|
||||
'a Permission bundle by the in-code role_bundle() function at engine '
|
||||
'read time.';
|
||||
|
||||
|
||||
-- ── 3. Indexes — match the hot-path queries ─────────────────────────────────
|
||||
|
||||
-- "What does this caller have access to?" — every WebDAV / NC request,
|
||||
-- every UI default-drive resolution (post-Drive) hits this.
|
||||
CREATE INDEX IF NOT EXISTS idx_role_grants_subject
|
||||
ON storage.role_grants (subject_type, subject_id);
|
||||
|
||||
-- "Who has access to this resource?" — share dialogs, audit views.
|
||||
CREATE INDEX IF NOT EXISTS idx_role_grants_resource
|
||||
ON storage.role_grants (resource_type, resource_id);
|
||||
|
||||
-- Partial index on expiry — only rows that actually expire (mirrors the
|
||||
-- access_grants index pattern, same rationale).
|
||||
CREATE INDEX IF NOT EXISTS idx_role_grants_expires_at
|
||||
ON storage.role_grants (expires_at) WHERE expires_at IS NOT NULL;
|
||||
|
||||
-- For GET /api/grants/outgoing/resources (who granted what).
|
||||
CREATE INDEX IF NOT EXISTS idx_role_grants_granted_by
|
||||
ON storage.role_grants (granted_by);
|
||||
|
||||
|
||||
-- ── 4. Backfill from access_grants ─────────────────────────────────────────
|
||||
-- For each (subject, resource) cluster in access_grants, write one
|
||||
-- role_grants row with the matching role. The CASE expression mirrors
|
||||
-- `Role::expand()` exactly — when that function changes (new role added),
|
||||
-- update both this CASE and the CHECK constraint above.
|
||||
--
|
||||
-- expires_at: take MIN across the cluster (most conservative — the role
|
||||
-- assignment expires at the earliest expiry of any of its constituent
|
||||
-- grants). granted_at: MIN (when the role assignment started). granted_by:
|
||||
-- the granter of the earliest row (preserves attribution to the admin who
|
||||
-- initially set the role up).
|
||||
|
||||
WITH cluster AS (
|
||||
SELECT subject_type,
|
||||
subject_id,
|
||||
resource_type,
|
||||
resource_id,
|
||||
array_agg(permission ORDER BY permission) AS perms,
|
||||
MIN(granted_at) AS earliest_granted_at,
|
||||
MIN(expires_at) AS earliest_expires_at
|
||||
FROM storage.access_grants
|
||||
GROUP BY 1, 2, 3, 4
|
||||
),
|
||||
earliest_grantor AS (
|
||||
SELECT DISTINCT ON (subject_type, subject_id, resource_type, resource_id)
|
||||
subject_type,
|
||||
subject_id,
|
||||
resource_type,
|
||||
resource_id,
|
||||
granted_by
|
||||
FROM storage.access_grants
|
||||
ORDER BY subject_type, subject_id, resource_type, resource_id, granted_at ASC
|
||||
)
|
||||
INSERT INTO storage.role_grants
|
||||
(subject_type, subject_id, resource_type, resource_id,
|
||||
role, granted_by, granted_at, expires_at)
|
||||
SELECT
|
||||
c.subject_type,
|
||||
c.subject_id,
|
||||
c.resource_type,
|
||||
c.resource_id,
|
||||
CASE c.perms
|
||||
WHEN ARRAY['read']::text[]
|
||||
THEN 'viewer'
|
||||
WHEN ARRAY['comment','read']::text[]
|
||||
THEN 'commenter'
|
||||
WHEN ARRAY['create','read']::text[]
|
||||
THEN 'contributor'
|
||||
WHEN ARRAY['comment','create','read','update']::text[]
|
||||
THEN 'editor'
|
||||
WHEN ARRAY['comment','create','delete','read','share','update']::text[]
|
||||
THEN 'owner'
|
||||
END AS role,
|
||||
eg.granted_by,
|
||||
c.earliest_granted_at,
|
||||
c.earliest_expires_at
|
||||
FROM cluster c
|
||||
JOIN earliest_grantor eg USING (subject_type, subject_id, resource_type, resource_id)
|
||||
ON CONFLICT (subject_type, subject_id, resource_type, resource_id) DO NOTHING;
|
||||
|
||||
|
||||
-- ── 5. Post-flight consistency check ───────────────────────────────────────
|
||||
-- Assert that the backfill landed one role_grants row per (subject,
|
||||
-- resource) cluster in access_grants. Any mismatch means a bundle pattern
|
||||
-- silently failed to match — refuses to commit, surfacing the bug.
|
||||
|
||||
DO $BODY$
|
||||
DECLARE
|
||||
expected_clusters BIGINT;
|
||||
actual_role_grants BIGINT;
|
||||
null_roles BIGINT;
|
||||
BEGIN
|
||||
SELECT count(*) INTO expected_clusters
|
||||
FROM (
|
||||
SELECT 1 FROM storage.access_grants
|
||||
GROUP BY subject_type, subject_id, resource_type, resource_id
|
||||
) c;
|
||||
|
||||
SELECT count(*) INTO actual_role_grants FROM storage.role_grants;
|
||||
|
||||
IF expected_clusters != actual_role_grants THEN
|
||||
RAISE EXCEPTION
|
||||
'D-Prep backfill consistency check failed: expected % role_grants '
|
||||
'rows (one per distinct (subject, resource) cluster in access_grants), '
|
||||
'got %. Investigate before declaring the migration successful.',
|
||||
expected_clusters, actual_role_grants;
|
||||
END IF;
|
||||
|
||||
-- Defensive: NULL role would mean the CASE expression failed to match.
|
||||
-- Pre-flight already refuses this, but double-check.
|
||||
SELECT count(*) INTO null_roles FROM storage.role_grants WHERE role IS NULL;
|
||||
IF null_roles > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'D-Prep backfill produced % role_grants rows with NULL role — '
|
||||
'a bundle pattern slipped past the pre-flight check. Investigate.',
|
||||
null_roles;
|
||||
END IF;
|
||||
END $BODY$;
|
||||
@@ -0,0 +1,72 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Cleanup #1: storage.role_grants.role — TEXT → storage.grant_role ENUM
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- D-Prep shipped `role_grants.role` as TEXT + CHECK constraint. Promoting it
|
||||
-- to a native PostgreSQL ENUM gives us three things at once:
|
||||
--
|
||||
-- 1. Index-driven sort by role strength. The ENUM values are declared in
|
||||
-- strength order — owner first, viewer last. `ORDER BY role ASC` then
|
||||
-- yields the UX-mandated "strongest first" ordering (Owner → Editor →
|
||||
-- Contributor → Commenter → Viewer) without a CASE expression. The
|
||||
-- `idx_role_grants_subject` / `idx_role_grants_resource` indexes can be
|
||||
-- extended (or composite-augmented) with the role column for index-only
|
||||
-- ordered scans.
|
||||
--
|
||||
-- 2. Type-level safety. The CHECK constraint goes away; invalid roles fail
|
||||
-- at the column type, not at row insertion. One contract instead of two
|
||||
-- (column type AND check constraint).
|
||||
--
|
||||
-- 3. Cleaner query shape. Every listing query that used the strength CASE
|
||||
-- becomes a plain `ORDER BY role` after this migration.
|
||||
--
|
||||
-- Trade-off accepted: PostgreSQL ENUMs allow ADD VALUE (with BEFORE / AFTER
|
||||
-- positional anchors) and RENAME VALUE, but not DROP VALUE or arbitrary
|
||||
-- reorder. The OxiCloud role roster is intentionally stable — new roles get
|
||||
-- appended, none get reordered or removed. Confirmed with Ed.
|
||||
--
|
||||
-- This migration must run BEFORE the access_grants drop, since it's purely
|
||||
-- about role_grants.role.
|
||||
|
||||
-- ── 1. Create the ENUM type ────────────────────────────────────────────────
|
||||
-- Declaration order = sort order. Strongest first so `ORDER BY role ASC`
|
||||
-- matches the UX requirement (max permission → least permission).
|
||||
|
||||
CREATE TYPE storage.grant_role AS ENUM (
|
||||
'owner', -- ordinal 0, sorts first
|
||||
'editor', -- ordinal 1
|
||||
'contributor', -- ordinal 2
|
||||
'commenter', -- ordinal 3
|
||||
'viewer' -- ordinal 4, sorts last
|
||||
);
|
||||
|
||||
COMMENT ON TYPE storage.grant_role IS
|
||||
'Role-keyed grant strength. Declaration order is sort order: ORDER BY '
|
||||
'role ASC yields owner → viewer (strongest → weakest), matching the '
|
||||
'share-dialog and shared-with-me UX. Adding a new role is ALTER TYPE '
|
||||
'ADD VALUE; renaming is ALTER TYPE RENAME VALUE. Dropping or reordering '
|
||||
'is not supported — adjust the roster only by append.';
|
||||
|
||||
|
||||
-- ── 2. Drop the redundant CHECK constraint ─────────────────────────────────
|
||||
-- The inline CHECK on role_grants.role was auto-named
|
||||
-- `role_grants_role_check` by PostgreSQL. Drop it before the type swap —
|
||||
-- the ENUM now enforces the same invariant at the column level.
|
||||
|
||||
ALTER TABLE storage.role_grants
|
||||
DROP CONSTRAINT IF EXISTS role_grants_role_check;
|
||||
|
||||
|
||||
-- ── 3. Convert role TEXT → storage.grant_role ──────────────────────────────
|
||||
-- USING cast: text values are guaranteed to be one of the five valid labels
|
||||
-- (the dropped CHECK enforced this; the D-Prep backfill only produced these
|
||||
-- five values). If a stray value slipped through, the cast errors out and
|
||||
-- the migration aborts — preferable to silently coercing.
|
||||
|
||||
ALTER TABLE storage.role_grants
|
||||
ALTER COLUMN role TYPE storage.grant_role
|
||||
USING role::storage.grant_role;
|
||||
|
||||
COMMENT ON COLUMN storage.role_grants.role IS
|
||||
'One of owner / editor / contributor / commenter / viewer. Expanded to '
|
||||
'a Permission bundle by the in-code role_bundle() function at engine '
|
||||
'read time. Sort order matches declaration order in storage.grant_role.';
|
||||
@@ -0,0 +1,122 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Cleanup #2: cascade triggers for storage.role_grants
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- The D-Prep migration created `storage.role_grants` but no cascade triggers.
|
||||
-- Until now, role_grants stayed consistent because the application-layer
|
||||
-- lifecycle hooks (`engine.revoke_all_for_resource` / `_subject`) wiped rows
|
||||
-- on the canonical delete paths, AND the existing `trg_cleanup_grants_*`
|
||||
-- triggers kept `storage.access_grants` clean as a defence-in-depth net.
|
||||
--
|
||||
-- The follow-up cleanup PR drops `access_grants` (and its triggers) entirely.
|
||||
-- Without this migration that drop would leave `role_grants` without any
|
||||
-- DB-level safety net — direct SQL, future codepaths that forget to call the
|
||||
-- engine hooks, and any other bypass route could orphan rows whose subject
|
||||
-- or resource has already been deleted.
|
||||
--
|
||||
-- This migration mirrors the four forward + one reverse triggers from
|
||||
-- `20260520000000_rebac_access_grants.sql` and `20260612000001_share_grant_
|
||||
-- reverse_cascade.sql`, retargeted at `storage.role_grants`. Same shape, same
|
||||
-- AFTER-DELETE semantics, same idempotent CREATE OR REPLACE patterns.
|
||||
--
|
||||
-- During the transition window (this migration applied; `access_grants` not
|
||||
-- yet dropped) both sets of triggers coexist — they target different tables
|
||||
-- and don't conflict. Once `access_grants` is dropped, the old triggers and
|
||||
-- their helper functions vanish in the same migration.
|
||||
|
||||
-- ── 1. Forward cascade: resource delete → cleanup role_grants ──────────────
|
||||
-- Fires AFTER DELETE on storage.folders / storage.files; deletes every
|
||||
-- role_grants row referencing that resource. TG_ARGV[0] discriminates which
|
||||
-- resource_type the trigger is wired for.
|
||||
|
||||
CREATE OR REPLACE FUNCTION storage.cleanup_role_grants_on_resource_delete()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
DELETE FROM storage.role_grants
|
||||
WHERE resource_type = TG_ARGV[0]
|
||||
AND resource_id = OLD.id;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cleanup_role_grants_folder ON storage.folders;
|
||||
CREATE TRIGGER trg_cleanup_role_grants_folder
|
||||
AFTER DELETE ON storage.folders
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION storage.cleanup_role_grants_on_resource_delete('folder');
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cleanup_role_grants_file ON storage.files;
|
||||
CREATE TRIGGER trg_cleanup_role_grants_file
|
||||
AFTER DELETE ON storage.files
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION storage.cleanup_role_grants_on_resource_delete('file');
|
||||
|
||||
|
||||
-- ── 2. Forward cascade: subject delete → cleanup role_grants ───────────────
|
||||
-- Fires AFTER DELETE on auth.users / storage.shares; deletes every
|
||||
-- role_grants row referencing that subject. Groups are NOT wired here —
|
||||
-- `subject_group_service::delete()` performs that cascade transactionally
|
||||
-- in application code, mirroring the historical access_grants behaviour.
|
||||
|
||||
CREATE OR REPLACE FUNCTION storage.cleanup_role_grants_on_subject_delete()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
DELETE FROM storage.role_grants
|
||||
WHERE subject_type = TG_ARGV[0]
|
||||
AND subject_id = OLD.id;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cleanup_role_grants_user ON auth.users;
|
||||
CREATE TRIGGER trg_cleanup_role_grants_user
|
||||
AFTER DELETE ON auth.users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION storage.cleanup_role_grants_on_subject_delete('user');
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cleanup_role_grants_token ON storage.shares;
|
||||
CREATE TRIGGER trg_cleanup_role_grants_token
|
||||
AFTER DELETE ON storage.shares
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION storage.cleanup_role_grants_on_subject_delete('token');
|
||||
|
||||
|
||||
-- ── 3. Reverse cascade: last-token-grant delete → cleanup storage.shares ───
|
||||
-- A caller hitting DELETE /api/grants/{id} on a token's role grant would
|
||||
-- otherwise leave the storage.shares row stranded — the token still
|
||||
-- resolves to "no access" (cascade query finds no rows), but the metadata
|
||||
-- row accumulates forever.
|
||||
--
|
||||
-- With role_grants the UNIQUE (subject, resource) constraint guarantees a
|
||||
-- token has at most ONE role grant per resource, so "the last grant for a
|
||||
-- token" collapses to "the only grant for that token". The NOT EXISTS
|
||||
-- guard still works correctly — it just always evaluates the same way for
|
||||
-- token subjects.
|
||||
--
|
||||
-- The DELETE on storage.shares is a no-op when the share row is already
|
||||
-- gone (the forward cascade `trg_cleanup_role_grants_token` is in flight
|
||||
-- and already removed it). Idempotent in both directions.
|
||||
|
||||
CREATE OR REPLACE FUNCTION storage.cleanup_share_on_last_role_grant_delete()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF OLD.subject_type = 'token' THEN
|
||||
DELETE FROM storage.shares s
|
||||
WHERE s.id = OLD.subject_id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM storage.role_grants rg
|
||||
WHERE rg.subject_type = 'token'
|
||||
AND rg.subject_id = OLD.subject_id
|
||||
);
|
||||
END IF;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cleanup_share_on_role_grant_delete ON storage.role_grants;
|
||||
CREATE TRIGGER trg_cleanup_share_on_role_grant_delete
|
||||
AFTER DELETE ON storage.role_grants
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION storage.cleanup_share_on_last_role_grant_delete();
|
||||
|
||||
COMMENT ON FUNCTION storage.cleanup_share_on_last_role_grant_delete() IS
|
||||
'Reverse cascade: deletes storage.shares row when its last token role grant is removed. Pairs with trg_cleanup_role_grants_token (forward direction).';
|
||||
@@ -0,0 +1,63 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Cleanup #3: drop storage.access_grants (and everything attached to it)
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- The final step of the role-keyed ReBAC cleanup. By the time this migration
|
||||
-- runs:
|
||||
--
|
||||
-- * Every read path goes through `storage.role_grants` (cleanup #1 / #2).
|
||||
-- * The engine no longer has a `grant()` method; `set_role()` /
|
||||
-- `clear_role()` are the only writes.
|
||||
-- * The HTTP surface (`POST /api/grants`, `PUT /api/grants/role`) only
|
||||
-- accepts role-keyed shapes.
|
||||
-- * `share_service`, `subject_group_service`, `auth_application_service`,
|
||||
-- `share_pg_repository`, and `integration_test_support` all read
|
||||
-- `role_grants` exclusively.
|
||||
-- * `storage.role_grants` has its own cascade triggers
|
||||
-- (`trg_cleanup_role_grants_*`) and reverse-cascade
|
||||
-- (`trg_cleanup_share_on_role_grant_delete`), added in cleanup #2.
|
||||
--
|
||||
-- So `access_grants` is fully unreferenced — we can drop it together with
|
||||
-- the helper triggers + functions defined in
|
||||
-- `20260520000000_rebac_access_grants.sql` and
|
||||
-- `20260612000001_share_grant_reverse_cascade.sql`.
|
||||
--
|
||||
-- Roll-back posture: this is destructive. There is no down migration. The
|
||||
-- D-Prep backfill is one-way (role-keyed rows are derived from
|
||||
-- permission-keyed clusters; the reverse reconstruction would need a fixed
|
||||
-- bundle mapping that may have shifted between releases). Recovering
|
||||
-- requires restoring from a backup taken before this migration runs.
|
||||
|
||||
-- ── 1. Drop the access_grants triggers FROM their source tables ────────────
|
||||
-- These triggers live on storage.folders / storage.files / auth.users /
|
||||
-- storage.shares. Dropping access_grants doesn't implicitly remove them
|
||||
-- (the trigger row points at the source table; the body references the
|
||||
-- target table, and that body is what breaks once access_grants is gone).
|
||||
-- Drop them explicitly so subsequent DELETEs on those source tables don't
|
||||
-- error out.
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cleanup_grants_folder ON storage.folders;
|
||||
DROP TRIGGER IF EXISTS trg_cleanup_grants_file ON storage.files;
|
||||
DROP TRIGGER IF EXISTS trg_cleanup_grants_user ON auth.users;
|
||||
DROP TRIGGER IF EXISTS trg_cleanup_grants_token ON storage.shares;
|
||||
|
||||
-- The reverse-cascade trigger is ON access_grants and goes away with the
|
||||
-- table — but the IF EXISTS makes this safe regardless of drop order.
|
||||
DROP TRIGGER IF EXISTS trg_cleanup_share_on_grant_delete ON storage.access_grants;
|
||||
|
||||
|
||||
-- ── 2. Drop the trigger helper functions ────────────────────────────────────
|
||||
-- No other code references these — the `cleanup_role_grants_*` equivalents
|
||||
-- defined in cleanup #2 carry the same behaviour against role_grants.
|
||||
|
||||
DROP FUNCTION IF EXISTS storage.cleanup_grants_on_resource_delete();
|
||||
DROP FUNCTION IF EXISTS storage.cleanup_grants_on_subject_delete();
|
||||
DROP FUNCTION IF EXISTS storage.cleanup_share_on_last_token_grant_delete();
|
||||
|
||||
|
||||
-- ── 3. Drop the table ──────────────────────────────────────────────────────
|
||||
-- CASCADE removes any remaining dependent objects (indexes, comments, and
|
||||
-- the reverse-cascade trigger if it survived step 1). With every Rust code
|
||||
-- path already routed through role_grants, nothing in the application
|
||||
-- layer will notice.
|
||||
|
||||
DROP TABLE IF EXISTS storage.access_grants CASCADE;
|
||||
@@ -11,7 +11,7 @@ use uuid::Uuid;
|
||||
use crate::application::dtos::cursor::{CursorListResponse, CursorQuery, PageCursor};
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
use crate::domain::services::authorization::{Grant, Permission, Resource, Subject};
|
||||
use crate::domain::services::authorization::{Grant, Permission, Resource, Role, Subject};
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Subject / Resource / Permission DTOs
|
||||
@@ -95,6 +95,7 @@ pub enum PermissionDto {
|
||||
Comment,
|
||||
Delete,
|
||||
Update,
|
||||
Manage,
|
||||
}
|
||||
|
||||
impl From<PermissionDto> for Permission {
|
||||
@@ -106,6 +107,7 @@ impl From<PermissionDto> for Permission {
|
||||
PermissionDto::Comment => Permission::Comment,
|
||||
PermissionDto::Delete => Permission::Delete,
|
||||
PermissionDto::Update => Permission::Update,
|
||||
PermissionDto::Manage => Permission::Manage,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,57 +121,58 @@ impl From<Permission> for PermissionDto {
|
||||
Permission::Comment => PermissionDto::Comment,
|
||||
Permission::Delete => PermissionDto::Delete,
|
||||
Permission::Update => PermissionDto::Update,
|
||||
Permission::Manage => PermissionDto::Manage,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Roles (DTO-layer sugar)
|
||||
// Roles — the load-bearing model for ReBAC grants
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// One row per role assignment in `storage.role_grants.role` (a
|
||||
// `storage.grant_role` ENUM). The engine expands the bundle at query time
|
||||
// via `Role::expand()` on the domain enum. Adding a role is two edits:
|
||||
// the variant + match arm on `Role`, and an `ALTER TYPE
|
||||
// storage.grant_role ADD VALUE 'name'` migration.
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)]
|
||||
/// Wire-format wrapper around the domain `Role` enum. Carries the
|
||||
/// serde/utoipa derives. Maps 1:1 to/from `Role` via `From`.
|
||||
///
|
||||
/// The historical `"admin"` alias for `Owner` (used during the D-Prep
|
||||
/// dual-write window for cached clients) has been retired in the cleanup
|
||||
/// PR — the OxiCloud UI emits `"owner"` exclusively. Stragglers receive
|
||||
/// a 422 on POST/PUT, which surfaces the upgrade cleanly.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Role {
|
||||
pub enum RoleDto {
|
||||
Viewer,
|
||||
//Commenter,
|
||||
Commenter,
|
||||
Contributor,
|
||||
Editor,
|
||||
//Manager,
|
||||
Admin,
|
||||
Owner,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
/// Expands a role into its constituent raw permissions. Storage and
|
||||
/// engine know nothing about roles — the server normalizes here before
|
||||
/// writing rows.
|
||||
pub fn expand(self) -> &'static [Permission] {
|
||||
match self {
|
||||
Role::Viewer => &[Permission::Read],
|
||||
/* reserved for future
|
||||
Role::Commenter => &[Permission::Read, Permission::Comment],
|
||||
*/
|
||||
Role::Editor => &[
|
||||
Permission::Read,
|
||||
Permission::Comment,
|
||||
Permission::Create,
|
||||
Permission::Update,
|
||||
],
|
||||
/* reserved for future
|
||||
Role::Manager => &[
|
||||
Permission::Read,
|
||||
Permission::Comment,
|
||||
Permission::Create,
|
||||
Permission::Update,
|
||||
Permission::Share,
|
||||
],
|
||||
*/
|
||||
Role::Admin => &[
|
||||
Permission::Read,
|
||||
Permission::Comment,
|
||||
Permission::Create,
|
||||
Permission::Update,
|
||||
Permission::Share,
|
||||
Permission::Delete,
|
||||
],
|
||||
impl From<RoleDto> for Role {
|
||||
fn from(r: RoleDto) -> Self {
|
||||
match r {
|
||||
RoleDto::Viewer => Role::Viewer,
|
||||
RoleDto::Commenter => Role::Commenter,
|
||||
RoleDto::Contributor => Role::Contributor,
|
||||
RoleDto::Editor => Role::Editor,
|
||||
RoleDto::Owner => Role::Owner,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Role> for RoleDto {
|
||||
fn from(r: Role) -> Self {
|
||||
match r {
|
||||
Role::Viewer => RoleDto::Viewer,
|
||||
Role::Commenter => RoleDto::Commenter,
|
||||
Role::Contributor => RoleDto::Contributor,
|
||||
Role::Editor => RoleDto::Editor,
|
||||
Role::Owner => RoleDto::Owner,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,17 +209,18 @@ pub enum SubjectInputDto {
|
||||
},
|
||||
}
|
||||
|
||||
/// `POST /api/grants` — accepts either `permissions` (explicit) or `role`.
|
||||
/// Server-side validation requires exactly one of the two to be present.
|
||||
/// `POST /api/grants` — create or refresh a role assignment.
|
||||
///
|
||||
/// Strictly role-keyed since the cleanup PR: callers send exactly one
|
||||
/// role; the engine writes a single row in `storage.role_grants`. The
|
||||
/// historical per-permission shape (`permissions: [...]`) was dropped —
|
||||
/// the OxiCloud UI is the only known caller and it already sends `role`.
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateGrantDto {
|
||||
pub subject: SubjectInputDto,
|
||||
pub resource: ResourceDto,
|
||||
#[serde(default)]
|
||||
pub permissions: Option<Vec<PermissionDto>>,
|
||||
#[serde(default)]
|
||||
pub role: Option<Role>,
|
||||
/// Optional expiry for every grant in this request. RFC 3339 / ISO 8601.
|
||||
pub role: RoleDto,
|
||||
/// Optional expiry for the grant. RFC 3339 / ISO 8601.
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
@@ -226,7 +230,7 @@ pub struct CreateGrantDto {
|
||||
pub struct UpdateRoleDto {
|
||||
pub subject: SubjectDto,
|
||||
pub resource: ResourceDto,
|
||||
pub role: Role,
|
||||
pub role: RoleDto,
|
||||
/// Optional expiry applied to every grant written or updated by this call.
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
@@ -241,7 +245,10 @@ pub struct GrantDto {
|
||||
pub id: Uuid,
|
||||
pub subject: SubjectDto,
|
||||
pub resource: ResourceDto,
|
||||
pub permission: PermissionDto,
|
||||
/// Role-keyed since D-Prep cleanup — one row in `storage.role_grants`
|
||||
/// is one `GrantDto`. The bundle of underlying permissions is implied
|
||||
/// by the role and recomputed client-side from the same lookup table.
|
||||
pub role: RoleDto,
|
||||
pub granted_by: Uuid,
|
||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -254,7 +261,7 @@ impl From<Grant> for GrantDto {
|
||||
id: g.id,
|
||||
subject: g.subject.into(),
|
||||
resource: g.resource.into(),
|
||||
permission: g.permission.into(),
|
||||
role: g.role.into(),
|
||||
granted_by: g.granted_by,
|
||||
granted_at: g.granted_at,
|
||||
expires_at: g.expires_at,
|
||||
@@ -430,12 +437,34 @@ pub struct SharedWithMeItemDto {
|
||||
}
|
||||
|
||||
/// Derive the closest-matching role label from a set of permissions.
|
||||
/// Maps the permission set to `"admin"`, `"editor"`, or `"viewer"`.
|
||||
///
|
||||
/// **Legacy helper for the dual-write window.** Once D-Prep ships and the
|
||||
/// engine reads `role_grants.role` directly, this function becomes unused
|
||||
/// and is dropped in the cleanup PR. Kept here so callers that still hit
|
||||
/// `access_grants` and reconstruct a role for display can stay working
|
||||
/// during the transition.
|
||||
///
|
||||
/// Emits the new five-role roster on output (`"viewer"` / `"commenter"` /
|
||||
/// `"contributor"` / `"editor"` / `"owner"`). Note this is **lossy** for
|
||||
/// permission sets that don't match a bundle exactly — but D-Prep's
|
||||
/// pre-flight refuses to migrate any such cluster, so post-migration data
|
||||
/// only contains bundle-shaped sets.
|
||||
pub fn role_from_permissions(perms: &[Permission]) -> &'static str {
|
||||
if perms.contains(&Permission::Delete) && perms.contains(&Permission::Share) {
|
||||
"admin"
|
||||
} else if perms.contains(&Permission::Create) || perms.contains(&Permission::Update) {
|
||||
let has_read = perms.contains(&Permission::Read);
|
||||
let has_comment = perms.contains(&Permission::Comment);
|
||||
let has_create = perms.contains(&Permission::Create);
|
||||
let has_update = perms.contains(&Permission::Update);
|
||||
let has_delete = perms.contains(&Permission::Delete);
|
||||
let has_share = perms.contains(&Permission::Share);
|
||||
|
||||
if has_delete && has_share {
|
||||
"owner"
|
||||
} else if has_create && has_update {
|
||||
"editor"
|
||||
} else if has_read && has_create && !has_update {
|
||||
"contributor"
|
||||
} else if has_read && has_comment && !has_create && !has_update {
|
||||
"commenter"
|
||||
} else {
|
||||
"viewer"
|
||||
}
|
||||
@@ -457,7 +486,12 @@ pub struct OutgoingResourceGrantDto {
|
||||
pub subject_id: Uuid,
|
||||
/// Human-readable label (username for users, share name for tokens).
|
||||
pub subject_display: String,
|
||||
/// Derived role label: `"viewer"` | `"editor"` | `"admin"`.
|
||||
/// Role label: `"viewer"` | `"commenter"` | `"contributor"` | `"editor"`
|
||||
/// | `"owner"`. Emitted by `role_from_permissions()` during the dual-write
|
||||
/// window; once D-Prep cleanup lands this is read directly from
|
||||
/// `storage.role_grants.role`. The legacy `"admin"` spelling is no longer
|
||||
/// emitted — clients that cached it must accept `"owner"` too (the API
|
||||
/// `Role::parse` still accepts `"admin"` on input for one release).
|
||||
pub role: String,
|
||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -13,7 +13,7 @@ use uuid::Uuid;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::services::authorization::{
|
||||
Grant, GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource,
|
||||
ResourceKind, Subject,
|
||||
ResourceKind, Role, Subject,
|
||||
};
|
||||
|
||||
pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
@@ -90,11 +90,7 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
|
||||
/// Resources explicitly granted to `subject`. Direct grants only — no
|
||||
/// cascade expansion. Used by `GET /api/grants/incoming`.
|
||||
async fn list_incoming_grants(
|
||||
&self,
|
||||
subject: Subject,
|
||||
permission_filter: Option<Permission>,
|
||||
) -> Result<Vec<Grant>, DomainError>;
|
||||
async fn list_incoming_grants(&self, subject: Subject) -> Result<Vec<Grant>, DomainError>;
|
||||
|
||||
/// Cursor-paginated list of resources explicitly granted to `subject`,
|
||||
/// optionally filtered by resource kind. Multiple permission rows for the
|
||||
@@ -139,38 +135,20 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
reverse: bool,
|
||||
) -> Result<(Vec<OutgoingResourceSummary>, Option<GrantCursor>), DomainError>;
|
||||
|
||||
/// Create a grant. Idempotent — duplicates are absorbed by the UNIQUE
|
||||
/// constraint; if the row already exists its `expires_at` is updated.
|
||||
async fn grant(
|
||||
&self,
|
||||
granted_by: Uuid,
|
||||
subject: Subject,
|
||||
permission: Permission,
|
||||
resource: Resource,
|
||||
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<Grant, DomainError>;
|
||||
|
||||
/// Update `expires_at` on every grant row for the given subject.
|
||||
/// Used when a share's expiry is changed — one call updates all
|
||||
/// permission rows for that token in a single UPDATE.
|
||||
/// Update `expires_at` for every role grant belonging to `subject`.
|
||||
/// Used by `share_service` when a token-share's expiry is refreshed —
|
||||
/// the subject (token) maps to a small fixed set of role grants, so a
|
||||
/// single UPDATE covers them. Resource-scoped expiry changes go through
|
||||
/// `set_role` (which carries `expires_at` as part of its UPSERT).
|
||||
async fn set_expiry_for_subject(
|
||||
&self,
|
||||
subject: Subject,
|
||||
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Update `expires_at` on every grant row for the given `(subject, resource)`
|
||||
/// pair. Used by `set_role` to sync the expiry of retained grants when the
|
||||
/// caller changes expiry without changing permissions.
|
||||
async fn set_expiry_on_resource(
|
||||
&self,
|
||||
subject: Subject,
|
||||
resource: Resource,
|
||||
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Revoke a specific grant by its UUID. Returns `Ok(())` whether or not
|
||||
/// the row existed (idempotent revoke).
|
||||
/// Revoke a single role grant by its UUID. Idempotent — returns `Ok(())`
|
||||
/// whether or not the row existed. The id comes from a prior listing
|
||||
/// or `find_grant_full_by_id` lookup.
|
||||
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError>;
|
||||
|
||||
/// Removes every grant whose `resource` matches. Called by lifecycle
|
||||
@@ -181,4 +159,30 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
/// Removes every grant whose `subject` matches. Called when a user/token
|
||||
/// /group is deleted. Returns the count of rows removed.
|
||||
async fn revoke_all_for_subject(&self, subject: Subject) -> Result<usize, DomainError>;
|
||||
|
||||
// ── Role-keyed grant operations ────────────────────────────────────────
|
||||
// These are the only grant write path. Lifecycle hook bulk-deletes
|
||||
// (`revoke_all_for_*` above) wipe matching rows directly, so callers
|
||||
// using those paths don't need to invoke `clear_role` separately.
|
||||
|
||||
/// Set the role for a `(subject, resource)` pair. Idempotent via the
|
||||
/// UNIQUE `(subject_type, subject_id, resource_type, resource_id)`
|
||||
/// constraint — `ON CONFLICT` updates the role + expires_at if they
|
||||
/// changed, which is exactly the right semantics for an atomic role
|
||||
/// change (e.g. promoting Viewer → Editor in one UPDATE with no race
|
||||
/// window, no DELETE+INSERT).
|
||||
async fn set_role(
|
||||
&self,
|
||||
granted_by: Uuid,
|
||||
subject: Subject,
|
||||
role: Role,
|
||||
resource: Resource,
|
||||
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<Grant, DomainError>;
|
||||
|
||||
/// Remove the role for a `(subject, resource)` pair. Idempotent —
|
||||
/// succeeds whether or not the row existed. Called after `revoke`
|
||||
/// succeeds to keep the two tables in sync during dual-write; after
|
||||
/// cleanup this is the canonical role-revocation entry point.
|
||||
async fn clear_role(&self, subject: Subject, resource: Resource) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
@@ -1388,7 +1388,7 @@ impl AuthApplicationService {
|
||||
/// Visibility rule, evaluated top-to-bottom:
|
||||
/// 1. **Self lookup** — `caller_id == target_id` always succeeds.
|
||||
/// 2. **Shared-grant relationship** — caller and target appear
|
||||
/// together on at least one row of `storage.access_grants`,
|
||||
/// together on at least one row of `storage.role_grants`,
|
||||
/// either direction (caller-as-granter / target-as-subject,
|
||||
/// or target-as-granter / caller-as-subject). Applies to both
|
||||
/// internal and external callers. This is what lets an
|
||||
@@ -1454,7 +1454,7 @@ impl AuthApplicationService {
|
||||
let related: Option<i32> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT 1
|
||||
FROM storage.access_grants
|
||||
FROM storage.role_grants
|
||||
WHERE (granted_by = $1 AND subject_type = 'user' AND subject_id = $2)
|
||||
OR (granted_by = $2 AND subject_type = 'user' AND subject_id = $1)
|
||||
LIMIT 1
|
||||
|
||||
@@ -5,7 +5,7 @@ use tokio::sync::Semaphore;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::domain::services::authorization::{Resource, Role, Subject};
|
||||
use crate::infrastructure::repositories::pg::SharePgRepository;
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
||||
@@ -254,9 +254,9 @@ impl ShareUseCase for ShareService {
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
|
||||
// Create one Read-only grant for the token subject, carrying expires_at.
|
||||
// Tokens are always read-only. The DELETE trigger `trg_cleanup_grants_token`
|
||||
// cleans up this grant when the share is later deleted.
|
||||
// Anonymous link tokens always get the Viewer role (read-only).
|
||||
// The `trg_cleanup_grants_token` trigger cleans up this grant when
|
||||
// the share row is later deleted.
|
||||
let item_id_uuid = Uuid::parse_str(saved_share.item_id())
|
||||
.map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?;
|
||||
let resource = match saved_share.item_type() {
|
||||
@@ -267,10 +267,10 @@ impl ShareUseCase for ShareService {
|
||||
.expires_at
|
||||
.and_then(|ts| chrono::DateTime::from_timestamp(ts as i64, 0));
|
||||
self.authorization
|
||||
.grant(
|
||||
.set_role(
|
||||
user_id,
|
||||
Subject::Token(saved_share.id()),
|
||||
Permission::Read,
|
||||
Role::Viewer,
|
||||
resource,
|
||||
expires_dt,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! - Name validation runs (defence-in-depth alongside the DB CHECK).
|
||||
//! - Virtual groups (e.g. `Internal`) are protected from mutation.
|
||||
//! - Audit events are emitted via `tracing::info!(target = "audit", ...)`.
|
||||
//! - Cascading delete of `storage.access_grants` rows referencing this
|
||||
//! - Cascading delete of `storage.role_grants` rows referencing this
|
||||
//! group runs in the same transaction as the group delete.
|
||||
//!
|
||||
//! See `migrations/20260612000000_subject_groups.sql` for the schema.
|
||||
@@ -172,9 +172,9 @@ impl SubjectGroupService {
|
||||
|
||||
/// Delete the group; cascades to:
|
||||
/// - `auth.subject_group_members` rows (FK CASCADE).
|
||||
/// - `storage.access_grants` rows where `subject_type='group'` and
|
||||
/// - `storage.role_grants` rows where `subject_type='group'` and
|
||||
/// `subject_id = id` (handled here, no FK exists between
|
||||
/// `access_grants` and `subject_groups`).
|
||||
/// `role_grants` and `subject_groups`).
|
||||
pub async fn delete(&self, id: Uuid, caller_id: Uuid) -> Result<(), DomainError> {
|
||||
let existing = self.get_by_id(id).await?;
|
||||
if existing.is_virtual {
|
||||
@@ -196,7 +196,7 @@ impl SubjectGroupService {
|
||||
})?;
|
||||
|
||||
let grants_deleted = sqlx::query(
|
||||
"DELETE FROM storage.access_grants
|
||||
"DELETE FROM storage.role_grants
|
||||
WHERE subject_type = 'group' AND subject_id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
@@ -519,9 +519,9 @@ mod integration_tests {
|
||||
|
||||
// ── 13. Grants are revoked atomically when a group is deleted ──────────
|
||||
//
|
||||
// The plan said "FK CASCADE", but there's no FK between `access_grants`
|
||||
// and `subject_groups` (different schemas; the cascade is handled by the
|
||||
// service's transactional DELETE). This test pins that behaviour.
|
||||
// There is no FK between `storage.role_grants` and `auth.subject_groups`
|
||||
// (different schemas); the cascade is handled by the service's
|
||||
// transactional DELETE. This test pins that behaviour.
|
||||
#[tokio::test]
|
||||
async fn test_grants_revoked_when_group_deleted() {
|
||||
let svc = make_service().await;
|
||||
@@ -534,21 +534,21 @@ mod integration_tests {
|
||||
.unwrap();
|
||||
let resource_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO storage.access_grants \
|
||||
"INSERT INTO storage.role_grants \
|
||||
(subject_type, subject_id, resource_type, resource_id, \
|
||||
permission, granted_by) \
|
||||
VALUES ('group', $1, 'folder', $2, 'read', $3)",
|
||||
role, granted_by) \
|
||||
VALUES ('group', $1, 'folder', $2, 'viewer', $3)",
|
||||
)
|
||||
.bind(group.id)
|
||||
.bind(resource_id)
|
||||
.bind(admin)
|
||||
.execute(svc.pool.as_ref())
|
||||
.await
|
||||
.expect("insert grant row");
|
||||
.expect("insert role_grants row");
|
||||
|
||||
// Sanity: the grant exists.
|
||||
let pre: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM storage.access_grants \
|
||||
"SELECT COUNT(*) FROM storage.role_grants \
|
||||
WHERE subject_type = 'group' AND subject_id = $1",
|
||||
)
|
||||
.bind(group.id)
|
||||
@@ -561,7 +561,7 @@ mod integration_tests {
|
||||
svc.delete(group.id, admin).await.unwrap();
|
||||
|
||||
let post: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM storage.access_grants \
|
||||
"SELECT COUNT(*) FROM storage.role_grants \
|
||||
WHERE subject_type = 'group' AND subject_id = $1",
|
||||
)
|
||||
.bind(group.id)
|
||||
|
||||
@@ -12,7 +12,7 @@ pub struct Share {
|
||||
item_type: ShareItemType,
|
||||
token: String,
|
||||
password_hash: Option<String>,
|
||||
/// Derived from `storage.access_grants.expires_at` — not stored on the share row.
|
||||
/// Derived from `storage.role_grants.expires_at` — not stored on the share row.
|
||||
expires_at: Option<u64>,
|
||||
created_at: u64,
|
||||
created_by: Uuid,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Subject groups are root-owned (no `owner_id`), globally named with an
|
||||
//! RFC 5321 local-part shape, and able to contain users *or* other groups.
|
||||
//! Grants in `storage.access_grants` with `subject_type = 'group'` reference
|
||||
//! Grants in `storage.role_grants` with `subject_type = 'group'` reference
|
||||
//! a row in `auth.subject_groups`.
|
||||
//!
|
||||
//! Cycle prevention and depth-cap (`MAX_GROUP_DEPTH`) are enforced at the
|
||||
|
||||
@@ -93,8 +93,8 @@ pub trait SubjectGroupRepository: Send + Sync + 'static {
|
||||
) -> Result<SubjectGroup, SubjectGroupRepositoryError>;
|
||||
|
||||
/// Delete the group. Cascades to `subject_group_members` and to
|
||||
/// `storage.access_grants` rows referencing this group as subject (via
|
||||
/// the application service — there is no FK between `access_grants` and
|
||||
/// `storage.role_grants` rows referencing this group as subject (via
|
||||
/// the application service — there is no FK between `role_grants` and
|
||||
/// `subject_groups`, so the service performs the cascade explicitly in
|
||||
/// the same transaction).
|
||||
async fn delete(&self, id: Uuid) -> Result<(), SubjectGroupRepositoryError>;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! These types are storage-agnostic — they describe the relationship between
|
||||
//! a subject (who), a resource (what), and a permission (action). The
|
||||
//! `AuthorizationEngine` port consumes them and the `PgAclEngine` implementation
|
||||
//! maps them to / from `storage.access_grants` rows.
|
||||
//! maps them to / from `storage.role_grants` rows.
|
||||
|
||||
use crate::application::dtos::cursor::PageCursor;
|
||||
use std::fmt;
|
||||
@@ -49,7 +49,7 @@ impl Subject {
|
||||
/// `"external"` is no longer accepted: PR-2 of the external-users
|
||||
/// work folded the federated-identity case into `Subject::User(uuid)`
|
||||
/// with `auth.users.is_external = TRUE`. The DB CHECK constraint
|
||||
/// on `storage.access_grants.subject_type` was narrowed to match.
|
||||
/// on `storage.role_grants.subject_type` was narrowed to match.
|
||||
pub fn from_parts(subject_type: &str, id: Uuid) -> Option<Self> {
|
||||
match subject_type {
|
||||
"user" => Some(Subject::User(id)),
|
||||
@@ -140,18 +140,29 @@ pub enum Permission {
|
||||
Delete,
|
||||
/// Modify the resource (rename, move, edit content).
|
||||
Update,
|
||||
/// Configure the resource's settings, add/remove members, change role
|
||||
/// assignments. Used by:
|
||||
/// - Drive owners managing drive membership and policies.
|
||||
/// - Group owners managing the group itself (Group-as-Resource, future).
|
||||
///
|
||||
/// Folder and file resources do not currently surface a `Manage` check;
|
||||
/// the permission lives in the enum because the role bundle (`Owner`)
|
||||
/// includes it, and the resource types that DO check it (`Drive`,
|
||||
/// `Group`) are added in subsequent PRs (see `docs/plan/drive.md`).
|
||||
Manage,
|
||||
}
|
||||
|
||||
impl Permission {
|
||||
/// Every permission, in a stable order. Used by `Role::expand()` and SQL
|
||||
/// `permission = ANY(...)` lookups.
|
||||
pub const ALL: [Permission; 6] = [
|
||||
pub const ALL: [Permission; 7] = [
|
||||
Permission::Read,
|
||||
Permission::Create,
|
||||
Permission::Share,
|
||||
Permission::Comment,
|
||||
Permission::Delete,
|
||||
Permission::Update,
|
||||
Permission::Manage,
|
||||
];
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
@@ -162,6 +173,7 @@ impl Permission {
|
||||
Permission::Comment => "comment",
|
||||
Permission::Delete => "delete",
|
||||
Permission::Update => "update",
|
||||
Permission::Manage => "manage",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +187,7 @@ impl Permission {
|
||||
"comment" => Some(Permission::Comment),
|
||||
"delete" => Some(Permission::Delete),
|
||||
"update" => Some(Permission::Update),
|
||||
"manage" => Some(Permission::Manage),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -187,7 +200,7 @@ impl fmt::Display for Permission {
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Grant — a row in storage.access_grants
|
||||
// Grant — a row in storage.role_grants
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -195,12 +208,132 @@ pub struct Grant {
|
||||
pub id: Uuid,
|
||||
pub subject: Subject,
|
||||
pub resource: Resource,
|
||||
pub permission: Permission,
|
||||
/// Role-keyed since D-Prep cleanup: one `Grant` represents the role
|
||||
/// row in `storage.role_grants` rather than a single permission. The
|
||||
/// engine and HTTP surface no longer carry per-permission rows;
|
||||
/// callers that need permissions use `role.expand()`.
|
||||
pub role: Role,
|
||||
pub granted_by: Uuid,
|
||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Role — a named bundle of permissions
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Roles are the load-bearing model for ReBAC grants since D-Prep. Each
|
||||
// `storage.role_grants` row stores one role; the engine expands the bundle
|
||||
// at read time via `Role::expand()`. Adding a role is two edits:
|
||||
// 1. a variant here + match arm in `expand()` / `as_str()` / `parse()`
|
||||
// 2. an `ALTER TYPE storage.grant_role ADD VALUE 'name'` migration
|
||||
//
|
||||
// `RoleDto` (DTO layer) carries the wire-format derives + the legacy
|
||||
// `"admin"` alias for backwards compat.
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
pub enum Role {
|
||||
Viewer,
|
||||
Commenter,
|
||||
Contributor,
|
||||
Editor,
|
||||
Owner,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
/// Expand the role into its permission bundle. Single source of truth —
|
||||
/// any code that needs "does this role include Permission X?" routes
|
||||
/// through here (or its inverse, `roles_implying`).
|
||||
pub fn expand(self) -> &'static [Permission] {
|
||||
match self {
|
||||
Role::Viewer => &[Permission::Read],
|
||||
Role::Commenter => &[Permission::Read, Permission::Comment],
|
||||
Role::Contributor => &[Permission::Read, Permission::Create],
|
||||
Role::Editor => &[
|
||||
Permission::Read,
|
||||
Permission::Comment,
|
||||
Permission::Create,
|
||||
Permission::Update,
|
||||
],
|
||||
Role::Owner => &[
|
||||
Permission::Read,
|
||||
Permission::Comment,
|
||||
Permission::Create,
|
||||
Permission::Update,
|
||||
Permission::Share,
|
||||
Permission::Delete,
|
||||
Permission::Manage,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowercase discriminator — matches the SQL `role` ENUM values in
|
||||
/// `storage.role_grants` (after the `::text` cast).
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Role::Viewer => "viewer",
|
||||
Role::Commenter => "commenter",
|
||||
Role::Contributor => "contributor",
|
||||
Role::Editor => "editor",
|
||||
Role::Owner => "owner",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a role from its SQL discriminator. Returns `None` for unknown
|
||||
/// values. The `"admin"` legacy alias is handled by `RoleDto` at the
|
||||
/// wire boundary — the database only ever stores the canonical names.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"viewer" => Some(Role::Viewer),
|
||||
"commenter" => Some(Role::Commenter),
|
||||
"contributor" => Some(Role::Contributor),
|
||||
"editor" => Some(Role::Editor),
|
||||
"owner" => Some(Role::Owner),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every role, in declaration order. Mirrors the `storage.grant_role`
|
||||
/// ENUM order in PG, which is weakest-to-strongest as written here for
|
||||
/// historical reasons (`storage.grant_role` declares strongest first).
|
||||
pub const ALL: [Role; 5] = [
|
||||
Role::Viewer,
|
||||
Role::Commenter,
|
||||
Role::Contributor,
|
||||
Role::Editor,
|
||||
Role::Owner,
|
||||
];
|
||||
}
|
||||
|
||||
impl fmt::Display for Role {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of [`Role::expand`]: returns every role whose bundle contains
|
||||
/// the given permission. Used by the engine to build the SQL
|
||||
/// `WHERE role IN (...)` filter on hot-path queries like "what drives can
|
||||
/// this caller read?".
|
||||
pub fn roles_implying(permission: Permission) -> &'static [Role] {
|
||||
use Permission::*;
|
||||
match permission {
|
||||
Read => &[
|
||||
Role::Viewer,
|
||||
Role::Commenter,
|
||||
Role::Contributor,
|
||||
Role::Editor,
|
||||
Role::Owner,
|
||||
],
|
||||
Comment => &[Role::Commenter, Role::Editor, Role::Owner],
|
||||
Create => &[Role::Contributor, Role::Editor, Role::Owner],
|
||||
Update => &[Role::Editor, Role::Owner],
|
||||
Delete => &[Role::Owner],
|
||||
Share => &[Role::Owner],
|
||||
Manage => &[Role::Owner],
|
||||
}
|
||||
}
|
||||
|
||||
impl Grant {
|
||||
pub fn is_expired(&self) -> bool {
|
||||
self.expires_at.is_some_and(|exp| exp < chrono::Utc::now())
|
||||
@@ -212,7 +345,7 @@ impl Grant {
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Resource type without an id — used to filter paginated grant queries by
|
||||
/// type. Mirrors the `resource_type` column values in `storage.access_grants`.
|
||||
/// type. Mirrors the `resource_type` column values in `storage.role_grants`.
|
||||
/// Add new variants here when new resource types are supported.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum ResourceKind {
|
||||
|
||||
@@ -38,7 +38,7 @@ impl SharePgRepository {
|
||||
|
||||
/// Maps a [`sqlx::postgres::PgRow`] to the domain [`Share`] entity.
|
||||
/// Expects columns: id, item_id, item_name, item_type, token, password_hash,
|
||||
/// expires_at (derived from access_grants subquery), created_at, created_by, access_count.
|
||||
/// expires_at (derived from role_grants subquery), created_at, created_by, access_count.
|
||||
fn row_to_entity(row: &sqlx::postgres::PgRow) -> Result<Share, DomainError> {
|
||||
let id: Uuid = row
|
||||
.try_get("id")
|
||||
@@ -54,7 +54,7 @@ impl SharePgRepository {
|
||||
DomainError::internal_error("Share", format!("Failed to read token: {e}"))
|
||||
})?;
|
||||
let password_hash: Option<String> = row.try_get("password_hash").unwrap_or(None);
|
||||
// expires_at derived from access_grants subquery (unix seconds as i64)
|
||||
// expires_at derived from role_grants subquery (unix seconds as i64)
|
||||
let expires_at: Option<i64> = row.try_get("expires_at").unwrap_or(None);
|
||||
let created_at: i64 = row.try_get("created_at").map_err(|e| {
|
||||
DomainError::internal_error("Share", format!("Failed to read created_at: {e}"))
|
||||
@@ -98,7 +98,7 @@ impl ShareStoragePort for SharePgRepository {
|
||||
RETURNING
|
||||
id, item_id, item_name, item_type, token, password_hash,
|
||||
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
|
||||
FROM storage.access_grants ag
|
||||
FROM storage.role_grants ag
|
||||
WHERE ag.subject_type = 'token' AND ag.subject_id = id) AS expires_at,
|
||||
created_at, created_by, access_count
|
||||
"#,
|
||||
@@ -127,7 +127,7 @@ impl ShareStoragePort for SharePgRepository {
|
||||
r#"
|
||||
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
|
||||
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
|
||||
FROM storage.access_grants ag
|
||||
FROM storage.role_grants ag
|
||||
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
|
||||
s.created_at, s.created_by, s.access_count
|
||||
FROM storage.shares s
|
||||
@@ -160,7 +160,7 @@ impl ShareStoragePort for SharePgRepository {
|
||||
r#"
|
||||
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
|
||||
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
|
||||
FROM storage.access_grants ag
|
||||
FROM storage.role_grants ag
|
||||
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
|
||||
s.created_at, s.created_by, s.access_count
|
||||
FROM storage.shares s
|
||||
@@ -218,7 +218,7 @@ impl ShareStoragePort for SharePgRepository {
|
||||
r#"
|
||||
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
|
||||
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
|
||||
FROM storage.access_grants ag
|
||||
FROM storage.role_grants ag
|
||||
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
|
||||
s.created_at, s.created_by, s.access_count
|
||||
FROM storage.shares s
|
||||
@@ -250,7 +250,7 @@ impl ShareStoragePort for SharePgRepository {
|
||||
RETURNING
|
||||
id, item_id, item_name, item_type, token, password_hash,
|
||||
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
|
||||
FROM storage.access_grants ag
|
||||
FROM storage.role_grants ag
|
||||
WHERE ag.subject_type = 'token' AND ag.subject_id = storage.shares.id) AS expires_at,
|
||||
created_at, created_by, access_count
|
||||
"#,
|
||||
@@ -286,7 +286,7 @@ impl ShareStoragePort for SharePgRepository {
|
||||
r#"
|
||||
SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash,
|
||||
(SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT)
|
||||
FROM storage.access_grants ag
|
||||
FROM storage.role_grants ag
|
||||
WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at,
|
||||
s.created_at, s.created_by, s.access_count,
|
||||
COUNT(*) OVER() AS total_count
|
||||
|
||||
@@ -272,8 +272,8 @@ impl SubjectGroupRepository for SubjectGroupPgRepository {
|
||||
|
||||
async fn delete(&self, id: Uuid) -> Result<(), SubjectGroupRepositoryError> {
|
||||
// The application service is responsible for clearing related
|
||||
// `storage.access_grants` rows in the same transaction (there's no
|
||||
// FK between access_grants and subject_groups). The subject_group_members
|
||||
// `storage.role_grants` rows in the same transaction (there's no
|
||||
// FK between role_grants and subject_groups). The subject_group_members
|
||||
// rows cascade automatically via FK.
|
||||
let result = sqlx::query("DELETE FROM auth.subject_groups WHERE id = $1")
|
||||
.bind(id)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
//! PostgreSQL-backed implementation of `AuthorizationEngine`.
|
||||
//!
|
||||
//! Stores grants in `storage.access_grants` (see migration
|
||||
//! `20260520000000_rebac_access_grants.sql`). Cascading is resolved at check
|
||||
//! time via PostgreSQL `ltree` `@>` (ancestor-of) on `storage.folders.lpath`,
|
||||
//! using the existing GiST index for O(log N) traversal.
|
||||
//! Stores grants in `storage.role_grants` (one role per (subject, resource)
|
||||
//! pair; the role's permission bundle is expanded in code via
|
||||
//! `Role::expand()`). Cascading is resolved at check time via PostgreSQL
|
||||
//! `ltree` `@>` (ancestor-of) on `storage.folders.lpath`, using the
|
||||
//! existing GiST index for O(log N) traversal.
|
||||
//!
|
||||
//! Owner is implicit — `storage.folders.user_id` / `storage.files.user_id`
|
||||
//! are checked first via dedicated helpers; if the caller is the owner, no
|
||||
//! SQL against `access_grants` happens.
|
||||
//! SQL against `role_grants` happens.
|
||||
//!
|
||||
//! ## Lifecycle cleanup
|
||||
//!
|
||||
@@ -43,7 +44,7 @@ use crate::domain::entities::subject_group::INTERNAL_GROUP_ID;
|
||||
use crate::domain::repositories::subject_group_repository::SubjectGroupRepository;
|
||||
use crate::domain::services::authorization::{
|
||||
Grant, GrantCursor, IncomingGrantSummary, OutgoingGrantEntry, OutgoingResourceSummary,
|
||||
Permission, Resource, ResourceKind, Subject,
|
||||
Permission, Resource, ResourceKind, Role, Subject, roles_implying,
|
||||
};
|
||||
use crate::infrastructure::repositories::pg::SubjectGroupPgRepository;
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
@@ -205,7 +206,7 @@ impl PgAclEngine {
|
||||
}
|
||||
|
||||
/// Expand a caller's `Subject` into the `(subject_types, subject_ids)`
|
||||
/// pair that should be matched in `storage.access_grants`. For User
|
||||
/// pair that should be matched in `storage.role_grants`. For User
|
||||
/// callers this is `(["user","group"], [uid, …transitive groups, INTERNAL])`;
|
||||
/// for any non-user subject (Token / External / Group as direct caller)
|
||||
/// it's a single-element pair with no cascade.
|
||||
@@ -237,6 +238,22 @@ impl PgAclEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a `Permission` into the array of role strings whose bundle
|
||||
/// includes it — bound as `ANY($N::storage.grant_role[])` so the
|
||||
/// ENUM-typed `role` column compares without an implicit text cast.
|
||||
///
|
||||
/// This is the inverse of `Role::expand()`, precomputed via
|
||||
/// `grant_dto::roles_implying()`. The mapping is small and static (≤5
|
||||
/// roles per permission today); resolving it in code keeps the SQL
|
||||
/// path simple and lets us add new roles without touching every
|
||||
/// query site.
|
||||
fn roles_implying_strings(permission: Permission) -> Vec<&'static str> {
|
||||
roles_implying(permission)
|
||||
.iter()
|
||||
.map(|r| r.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Cascading check for folders: is there a grant on any ancestor folder
|
||||
/// (including the target itself) for any of the given subject IDs and
|
||||
/// any of the given subject types?
|
||||
@@ -247,6 +264,11 @@ impl PgAclEngine {
|
||||
/// `subject_ids` is the expanded set returned by `expand_user` (or a
|
||||
/// single-element vec for non-user callers).
|
||||
///
|
||||
/// Reads `storage.role_grants` (1 row per role assignment); a permission
|
||||
/// filter `g.permission = $3` becomes `g.role = ANY($3::storage.grant_role[])` where
|
||||
/// the array is the set of roles whose bundle includes the requested
|
||||
/// permission — see `roles_implying()`.
|
||||
///
|
||||
/// Uses the GiST index on `storage.folders.lpath` for O(log N) cascade.
|
||||
async fn folder_cascade_grant_exists(
|
||||
&self,
|
||||
@@ -257,14 +279,15 @@ impl PgAclEngine {
|
||||
counters: &QueryCounters,
|
||||
) -> Result<bool, DomainError> {
|
||||
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
|
||||
let roles = Self::roles_implying_strings(permission);
|
||||
let exists: Option<i32> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT 1
|
||||
FROM storage.access_grants g
|
||||
FROM storage.role_grants g
|
||||
JOIN storage.folders gf ON gf.id = g.resource_id
|
||||
WHERE g.subject_type = ANY($1)
|
||||
AND g.subject_id = ANY($2)
|
||||
AND g.permission = $3
|
||||
AND g.role = ANY($3::storage.grant_role[])
|
||||
AND g.resource_type = 'folder'
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
AND gf.lpath @> (SELECT lpath FROM storage.folders WHERE id = $4)
|
||||
@@ -273,7 +296,7 @@ impl PgAclEngine {
|
||||
)
|
||||
.bind(subject_types)
|
||||
.bind(subject_ids)
|
||||
.bind(permission.as_str())
|
||||
.bind(&roles)
|
||||
.bind(folder_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
@@ -285,7 +308,7 @@ impl PgAclEngine {
|
||||
/// Cascading check for files: either a direct file grant OR a grant on
|
||||
/// any ancestor folder of the file's containing folder. See
|
||||
/// `folder_cascade_grant_exists` for the meaning of `subject_types` /
|
||||
/// `subject_ids`.
|
||||
/// `subject_ids` and the D-Prep role-array migration.
|
||||
async fn file_cascade_grant_exists(
|
||||
&self,
|
||||
subject_types: &[&str],
|
||||
@@ -295,27 +318,28 @@ impl PgAclEngine {
|
||||
counters: &QueryCounters,
|
||||
) -> Result<bool, DomainError> {
|
||||
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
|
||||
let roles = Self::roles_implying_strings(permission);
|
||||
let exists: Option<i32> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT 1
|
||||
FROM (
|
||||
-- direct file grant
|
||||
SELECT 1
|
||||
FROM storage.access_grants
|
||||
FROM storage.role_grants
|
||||
WHERE subject_type = ANY($1)
|
||||
AND subject_id = ANY($2)
|
||||
AND permission = $3
|
||||
AND role = ANY($3::storage.grant_role[])
|
||||
AND resource_type = 'file' AND resource_id = $4
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
UNION ALL
|
||||
-- cascading from any ancestor folder of the file's containing folder
|
||||
SELECT 1
|
||||
FROM storage.access_grants g
|
||||
FROM storage.role_grants g
|
||||
JOIN storage.folders gf ON gf.id = g.resource_id
|
||||
JOIN storage.files target_f ON target_f.id = $4
|
||||
WHERE g.subject_type = ANY($1)
|
||||
AND g.subject_id = ANY($2)
|
||||
AND g.permission = $3
|
||||
AND g.role = ANY($3::storage.grant_role[])
|
||||
AND g.resource_type = 'folder'
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
AND target_f.folder_id IS NOT NULL
|
||||
@@ -327,7 +351,7 @@ impl PgAclEngine {
|
||||
)
|
||||
.bind(subject_types)
|
||||
.bind(subject_ids)
|
||||
.bind(permission.as_str())
|
||||
.bind(&roles)
|
||||
.bind(file_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
@@ -336,39 +360,16 @@ impl PgAclEngine {
|
||||
Ok(exists.is_some())
|
||||
}
|
||||
|
||||
/// Look up a single grant by id. Returns `(resource, granted_by)` so
|
||||
/// the REST `DELETE /api/grants/{id}` handler can decide authorization
|
||||
/// without a second round-trip. Returns `Ok(None)` if no such grant.
|
||||
pub async fn find_grant_by_id(
|
||||
&self,
|
||||
grant_id: Uuid,
|
||||
) -> Result<Option<(Resource, Uuid)>, DomainError> {
|
||||
let row: Option<(String, Uuid, Uuid)> = sqlx::query_as(
|
||||
"SELECT resource_type, resource_id, granted_by FROM storage.access_grants WHERE id = $1",
|
||||
)
|
||||
.bind(grant_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("find_grant_by_id: {e}")))?;
|
||||
|
||||
let Some((rt, rid, granter)) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
let res = Resource::from_parts(&rt, rid)
|
||||
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown resource_type"))?;
|
||||
Ok(Some((res, granter)))
|
||||
}
|
||||
|
||||
/// Variant of `find_grant_by_id` that also returns the subject —
|
||||
/// needed by `POST /api/grants/{id}/notify` to resolve who to email.
|
||||
/// Returns `(subject, resource, granted_by)` or `None`.
|
||||
/// Look up a single role grant by id, returning the actors a revoke /
|
||||
/// notify handler needs to make a decision without a second round-trip.
|
||||
/// Returns `(subject, resource, granted_by)` or `None` if no such row.
|
||||
pub async fn find_grant_full_by_id(
|
||||
&self,
|
||||
grant_id: Uuid,
|
||||
) -> Result<Option<(Subject, Resource, Uuid)>, DomainError> {
|
||||
let row: Option<(String, Uuid, String, Uuid, Uuid)> = sqlx::query_as(
|
||||
"SELECT subject_type, subject_id, resource_type, resource_id, granted_by \
|
||||
FROM storage.access_grants WHERE id = $1",
|
||||
FROM storage.role_grants WHERE id = $1",
|
||||
)
|
||||
.bind(grant_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
@@ -385,8 +386,14 @@ impl PgAclEngine {
|
||||
Ok(Some((subject, resource, granter)))
|
||||
}
|
||||
|
||||
/// Row type for all full-grant SELECT queries:
|
||||
/// (id, subject_type, subject_id, resource_type, resource_id, permission, granted_by, granted_at, expires_at)
|
||||
/// Row type for `storage.role_grants` SELECTs:
|
||||
/// (id, subject_type, subject_id, resource_type, resource_id, role, granted_by, granted_at, expires_at).
|
||||
///
|
||||
/// Builds a single role-keyed `Grant` per row. `Grant` is role-keyed
|
||||
/// since the D-Prep cleanup PR — every listing method returns role
|
||||
/// rows directly; bundle expansion to per-permission Grants no longer
|
||||
/// happens here. Callers that need the permission set use
|
||||
/// `grant.role.expand()` at the call site.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn row_to_grant(
|
||||
row: (
|
||||
@@ -405,13 +412,13 @@ impl PgAclEngine {
|
||||
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown subject_type"))?;
|
||||
let resource = Resource::from_parts(&row.3, row.4)
|
||||
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown resource_type"))?;
|
||||
let permission = Permission::parse(&row.5)
|
||||
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown permission"))?;
|
||||
let role = Role::parse(&row.5)
|
||||
.ok_or_else(|| DomainError::internal_error("PgAcl", "unknown role"))?;
|
||||
Ok(Grant {
|
||||
id: row.0,
|
||||
subject,
|
||||
resource,
|
||||
permission,
|
||||
role,
|
||||
granted_by: row.6,
|
||||
granted_at: row.7,
|
||||
expires_at: row.8,
|
||||
@@ -529,15 +536,14 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
result
|
||||
}
|
||||
|
||||
async fn list_incoming_grants(
|
||||
&self,
|
||||
subject: Subject,
|
||||
permission_filter: Option<Permission>,
|
||||
) -> Result<Vec<Grant>, DomainError> {
|
||||
let perm_str = permission_filter.map(|p| p.as_str().to_string());
|
||||
async fn list_incoming_grants(&self, subject: Subject) -> Result<Vec<Grant>, DomainError> {
|
||||
let counters = QueryCounters::default();
|
||||
let (subject_types, subject_ids) = self.subject_match_set(subject, &counters).await?;
|
||||
|
||||
// `ORDER BY role ASC` exploits the `storage.grant_role` ENUM
|
||||
// declared as `(owner, editor, contributor, commenter, viewer)`,
|
||||
// so the sort order matches the UX requirement ("Owner > Editor
|
||||
// > Contributor > Commenter > Viewer") without a per-row CASE.
|
||||
let rows = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
@@ -554,18 +560,16 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
>(
|
||||
r#"
|
||||
SELECT id, subject_type, subject_id, resource_type, resource_id,
|
||||
permission, granted_by, granted_at, expires_at
|
||||
FROM storage.access_grants
|
||||
role::text, granted_by, granted_at, expires_at
|
||||
FROM storage.role_grants
|
||||
WHERE subject_type = ANY($1)
|
||||
AND subject_id = ANY($2)
|
||||
AND ($3::text IS NULL OR permission = $3)
|
||||
ORDER BY granted_at DESC
|
||||
LIMIT $4
|
||||
ORDER BY role ASC, granted_at DESC
|
||||
LIMIT $3
|
||||
"#,
|
||||
)
|
||||
.bind(&subject_types)
|
||||
.bind(&subject_ids)
|
||||
.bind(perm_str)
|
||||
.bind(MAX_GRANT_ROWS + 1)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
@@ -596,7 +600,12 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
// NULL otherwise. This lets every sort mode share a single query_as call.
|
||||
// 0 resource_type String
|
||||
// 1 resource_id Uuid
|
||||
// 2 permissions Vec<String>
|
||||
// 2 roles Vec<String> — every distinct role granting access to this
|
||||
// resource (post-D-Prep). Expanded to permissions
|
||||
// in `IncomingGrantSummary` via `Role::expand()`.
|
||||
// Multiple entries possible when a user has both
|
||||
// a direct grant and a group-mediated grant on
|
||||
// the same resource.
|
||||
// 3 granted_at DateTime<Utc>
|
||||
// 4 granted_by Uuid
|
||||
// 5 sort_str Option<String> — resource_name (name/type) or owner_name (granted_by)
|
||||
@@ -628,14 +637,20 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
// is `(["user","group"], [uid, …transitive groups, INTERNAL])` so the
|
||||
// listing includes every resource the user can reach via a group
|
||||
// grant (matching what `check()` allows). See `subject_match_set`.
|
||||
//
|
||||
// Post-D-Prep this reads `storage.role_grants` and aggregates the
|
||||
// ENUM-typed `role` column into a text array. Multiple roles can
|
||||
// appear per resource when the caller reaches it via both a direct
|
||||
// grant and a group-mediated grant — the union of role bundles
|
||||
// produces the displayed permission set in Rust below.
|
||||
const AGG: &str = r#"agg AS (
|
||||
SELECT
|
||||
resource_type,
|
||||
resource_id,
|
||||
array_agg(DISTINCT permission ORDER BY permission) AS permissions,
|
||||
array_agg(DISTINCT role::text ORDER BY role::text) AS roles,
|
||||
MIN(granted_at) AS granted_at,
|
||||
(array_agg(granted_by ORDER BY granted_at))[1] AS granted_by
|
||||
FROM storage.access_grants
|
||||
FROM storage.role_grants
|
||||
WHERE subject_type = ANY($1)
|
||||
AND subject_id = ANY($2)
|
||||
AND ($3::text[] IS NULL OR resource_type = ANY($3))
|
||||
@@ -700,7 +715,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
LEFT JOIN storage.folders f ON f.id = agg.resource_id AND agg.resource_type = 'folder'
|
||||
LEFT JOIN storage.files fi ON fi.id = agg.resource_id AND agg.resource_type = 'file'
|
||||
)
|
||||
SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int
|
||||
SELECT resource_type, resource_id, roles, granted_at, granted_by, sort_str, sort_int
|
||||
FROM named
|
||||
WHERE {where_clause}
|
||||
ORDER BY {order_clause}
|
||||
@@ -740,7 +755,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
FROM agg
|
||||
LEFT JOIN auth.users u ON u.id = agg.granted_by
|
||||
)
|
||||
SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int
|
||||
SELECT resource_type, resource_id, roles, granted_at, granted_by, sort_str, sort_int
|
||||
FROM owner_named
|
||||
WHERE {where_clause}
|
||||
ORDER BY {order_clause}
|
||||
@@ -768,7 +783,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
};
|
||||
format!(
|
||||
r#"WITH {AGG}
|
||||
SELECT resource_type, resource_id, permissions, granted_at, granted_by,
|
||||
SELECT resource_type, resource_id, roles, granted_at, granted_by,
|
||||
NULL::text AS sort_str,
|
||||
NULL::bigint AS sort_int
|
||||
FROM agg
|
||||
@@ -850,14 +865,21 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
};
|
||||
|
||||
// ── Convert rows to domain summaries ──────────────────────────────────
|
||||
// Post-D-Prep: the SQL aggregate produces a `roles` text array. We
|
||||
// expand each role's bundle and union them — direct grants and
|
||||
// group-mediated grants on the same resource collapse to a single
|
||||
// deduplicated permission set, matching the pre-pivot behaviour.
|
||||
let summaries = rows
|
||||
.into_iter()
|
||||
.filter_map(|(rt, rid, perms_str, granted_at, granted_by, _, _)| {
|
||||
.filter_map(|(rt, rid, roles_str, granted_at, granted_by, _, _)| {
|
||||
let resource_type = ResourceKind::parse(&rt)?;
|
||||
let permissions = perms_str
|
||||
let mut permissions: Vec<Permission> = roles_str
|
||||
.into_iter()
|
||||
.filter_map(|s| Permission::parse(&s))
|
||||
.filter_map(|s| Role::parse(&s))
|
||||
.flat_map(|r| r.expand().iter().copied())
|
||||
.collect();
|
||||
permissions.sort_by_key(|p| p.as_str());
|
||||
permissions.dedup();
|
||||
Some(IncomingGrantSummary {
|
||||
resource_type,
|
||||
resource_id: rid,
|
||||
@@ -872,6 +894,14 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
}
|
||||
|
||||
async fn list_grants_on_resource(&self, resource: Resource) -> Result<Vec<Grant>, DomainError> {
|
||||
// Pivoted to `storage.role_grants` (see `list_incoming_grants`).
|
||||
// Each role row expands to N permission-keyed `Grant` rows via
|
||||
// `role_row_to_grants` until the public `Grant` shape becomes
|
||||
// role-keyed.
|
||||
//
|
||||
// `ORDER BY role ASC` exploits the `storage.grant_role` ENUM's
|
||||
// declaration order (owner first → viewer last) so the share
|
||||
// dialog's "who has access" list shows strongest grants on top.
|
||||
let rows = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
@@ -888,11 +918,11 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
>(
|
||||
r#"
|
||||
SELECT id, subject_type, subject_id, resource_type, resource_id,
|
||||
permission, granted_by, granted_at, expires_at
|
||||
FROM storage.access_grants
|
||||
role::text, granted_by, granted_at, expires_at
|
||||
FROM storage.role_grants
|
||||
WHERE resource_type = $1
|
||||
AND resource_id = $2
|
||||
ORDER BY granted_at DESC
|
||||
ORDER BY role ASC, granted_at DESC
|
||||
LIMIT $3
|
||||
"#,
|
||||
)
|
||||
@@ -917,7 +947,10 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
) -> Result<(Vec<OutgoingResourceSummary>, Option<GrantCursor>), DomainError> {
|
||||
let fetch_limit = (limit as i64) + 1;
|
||||
|
||||
// Row shape — one row per (resource, subject, permission).
|
||||
// Row shape — post-D-Prep, one row per (resource, subject) since
|
||||
// `storage.role_grants` carries exactly one role per pair (UNIQUE
|
||||
// constraint). Permission bundles are expanded in the row consumer
|
||||
// via `Role::expand()`.
|
||||
// Columns:
|
||||
// 0 resource_type String
|
||||
// 1 resource_id Uuid
|
||||
@@ -926,9 +959,9 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
// 4 subject_id Uuid
|
||||
// 5 subject_display String — username or share item_name
|
||||
// 6 grant_id Uuid
|
||||
// 7 granted_at DateTime<Utc> — this (subject, perm) row
|
||||
// 7 granted_at DateTime<Utc> — this (subject, role) row
|
||||
// 8 expires_at Option<DateTime<Utc>>
|
||||
// 9 permission String
|
||||
// 9 role String — `grant_role` ENUM as text
|
||||
// 10 sort_str Option<String>
|
||||
// 11 sort_int Option<i64>
|
||||
// 12 has_password bool — token: shares.password_hash IS NOT NULL
|
||||
@@ -1015,7 +1048,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
CASE WHEN ag.resource_type = 'file' THEN fi.name END
|
||||
) AS sort_str,
|
||||
{sort_int_expr} AS sort_int
|
||||
FROM storage.access_grants ag
|
||||
FROM storage.role_grants ag
|
||||
LEFT JOIN storage.folders f ON f.id = ag.resource_id AND ag.resource_type = 'folder'
|
||||
LEFT JOIN storage.files fi ON fi.id = ag.resource_id AND ag.resource_type = 'file'
|
||||
WHERE ag.granted_by = $1
|
||||
@@ -1030,12 +1063,12 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
SELECT ag.resource_type, ag.resource_id, rp.first_shared_at,
|
||||
ag.subject_type, ag.subject_id,
|
||||
COALESCE(u.username, u.email, sg.name::text, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
|
||||
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission,
|
||||
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.role::text AS role,
|
||||
rp.sort_str, rp.sort_int,
|
||||
(sh.password_hash IS NOT NULL) AS has_password,
|
||||
COALESCE(u.is_external, FALSE) AS is_external
|
||||
FROM rp
|
||||
JOIN storage.access_grants ag
|
||||
JOIN storage.role_grants ag
|
||||
ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id
|
||||
AND ag.granted_by = $1
|
||||
LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id
|
||||
@@ -1104,7 +1137,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
ELSE 3
|
||||
END)::bigint AS sort_int,
|
||||
MIN(ag.granted_at) AS first_granted_at
|
||||
FROM storage.access_grants ag
|
||||
FROM storage.role_grants ag
|
||||
LEFT JOIN auth.users u
|
||||
ON ag.subject_type = 'user' AND u.id = ag.subject_id
|
||||
LEFT JOIN auth.subject_groups sg
|
||||
@@ -1135,13 +1168,13 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
ag.id AS grant_id,
|
||||
ag.granted_at,
|
||||
ag.expires_at,
|
||||
ag.permission,
|
||||
ag.role::text AS role,
|
||||
LOWER(rp.subject_display) AS sort_str,
|
||||
rp.sort_int,
|
||||
rp.has_password,
|
||||
rp.is_external
|
||||
FROM rp
|
||||
JOIN storage.access_grants ag
|
||||
JOIN storage.role_grants ag
|
||||
ON ag.resource_type = rp.resource_type
|
||||
AND ag.resource_id = rp.resource_id
|
||||
AND ag.subject_type = rp.subject_type
|
||||
@@ -1155,7 +1188,12 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
// Page on (role_order, subject_display, resource_id) triples so that all
|
||||
// of one person's grants within a role are contiguous — enabling aggregation
|
||||
// ("Bob on Folder A, Folder B") to work correctly across cursor pages.
|
||||
// role_order: 0 = admin (has delete+share), 1 = editor (has create or update), 2 = viewer
|
||||
//
|
||||
// role_order matches the `storage.grant_role` ENUM declaration
|
||||
// order (strongest first) via `array_position`, so
|
||||
// `sort_int ASC` matches the UX requirement: 1 = owner,
|
||||
// 2 = editor, 3 = contributor, 4 = commenter, 5 = viewer.
|
||||
// 1-based because `array_position` is.
|
||||
// Cursor: sort_int=role_order, resource_name=LOWER(subject_display), resource_id
|
||||
let (page_where, page_order) = if reverse {
|
||||
(
|
||||
@@ -1184,15 +1222,20 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
MAX(COALESCE(u.username, u.email, sh.item_name, ag.subject_id::text)) AS subject_display,
|
||||
BOOL_OR(sh.password_hash IS NOT NULL) AS has_password,
|
||||
COALESCE(BOOL_OR(u.is_external), FALSE) AS is_external,
|
||||
CASE
|
||||
WHEN BOOL_OR(ag.permission = 'delete')
|
||||
AND BOOL_OR(ag.permission = 'share') THEN 0
|
||||
WHEN BOOL_OR(ag.permission = 'create')
|
||||
OR BOOL_OR(ag.permission = 'update') THEN 1
|
||||
ELSE 2
|
||||
END::bigint AS sort_int,
|
||||
-- One role per (resource, subject) post-D-Prep
|
||||
-- (UNIQUE constraint on role_grants), so MAX
|
||||
-- returns that single row's role. `array_position`
|
||||
-- against the ENUM's declaration order produces a
|
||||
-- 1-based rank: owner=1 → viewer=5. Strength
|
||||
-- ordering tracks the ENUM declaration — adding
|
||||
-- a new role between owner and viewer doesn't
|
||||
-- need a parallel CASE update here.
|
||||
array_position(
|
||||
enum_range(NULL::storage.grant_role),
|
||||
MAX(ag.role)
|
||||
)::bigint AS sort_int,
|
||||
MIN(ag.granted_at) AS first_granted_at
|
||||
FROM storage.access_grants ag
|
||||
FROM storage.role_grants ag
|
||||
LEFT JOIN auth.users u
|
||||
ON ag.subject_type = 'user' AND u.id = ag.subject_id
|
||||
LEFT JOIN storage.shares sh
|
||||
@@ -1221,13 +1264,13 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
ag.id AS grant_id,
|
||||
ag.granted_at,
|
||||
ag.expires_at,
|
||||
ag.permission,
|
||||
ag.role::text AS role,
|
||||
LOWER(rp.subject_display) AS sort_str,
|
||||
rp.sort_int,
|
||||
rp.has_password,
|
||||
rp.is_external
|
||||
FROM rp
|
||||
JOIN storage.access_grants ag
|
||||
JOIN storage.role_grants ag
|
||||
ON ag.resource_type = rp.resource_type
|
||||
AND ag.resource_id = rp.resource_id
|
||||
AND ag.subject_type = rp.subject_type
|
||||
@@ -1259,7 +1302,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
SELECT resource_type, resource_id, MIN(granted_at) AS first_shared_at,
|
||||
NULL::text AS sort_str,
|
||||
NULL::bigint AS sort_int
|
||||
FROM storage.access_grants
|
||||
FROM storage.role_grants
|
||||
WHERE granted_by = $1
|
||||
GROUP BY resource_type, resource_id
|
||||
),
|
||||
@@ -1272,12 +1315,12 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
SELECT ag.resource_type, ag.resource_id, rp.first_shared_at,
|
||||
ag.subject_type, ag.subject_id,
|
||||
COALESCE(u.username, u.email, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
|
||||
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission,
|
||||
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.role::text AS role,
|
||||
NULL::text AS sort_str, NULL::bigint AS sort_int,
|
||||
(sh.password_hash IS NOT NULL) AS has_password,
|
||||
COALESCE(u.is_external, FALSE) AS is_external
|
||||
FROM rp
|
||||
JOIN storage.access_grants ag
|
||||
JOIN storage.role_grants ag
|
||||
ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id
|
||||
AND ag.granted_by = $1
|
||||
LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id
|
||||
@@ -1355,7 +1398,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
grant_id,
|
||||
granted_at,
|
||||
expires_at,
|
||||
perm_str,
|
||||
role_str,
|
||||
_,
|
||||
_,
|
||||
has_password,
|
||||
@@ -1364,7 +1407,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
let Some(resource_type) = ResourceKind::parse(&rt_str) else {
|
||||
continue;
|
||||
};
|
||||
let Some(perm) = Permission::parse(&perm_str) else {
|
||||
let Some(role) = Role::parse(&role_str) else {
|
||||
continue;
|
||||
};
|
||||
let key = (resource_id, subj_id);
|
||||
@@ -1384,8 +1427,10 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
},
|
||||
)
|
||||
});
|
||||
if !entry.permissions.contains(&perm) {
|
||||
entry.permissions.push(perm);
|
||||
for &perm in role.expand() {
|
||||
if !entry.permissions.contains(&perm) {
|
||||
entry.permissions.push(perm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1473,7 +1518,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
grant_id,
|
||||
granted_at,
|
||||
expires_at,
|
||||
perm_str,
|
||||
role_str,
|
||||
_,
|
||||
_,
|
||||
has_password,
|
||||
@@ -1482,7 +1527,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
let Some(resource_type) = ResourceKind::parse(&rt_str) else {
|
||||
continue;
|
||||
};
|
||||
let Some(perm) = Permission::parse(&perm_str) else {
|
||||
let Some(role) = Role::parse(&role_str) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -1506,8 +1551,10 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
has_password,
|
||||
is_external,
|
||||
});
|
||||
if !entry.permissions.contains(&perm) {
|
||||
entry.permissions.push(perm);
|
||||
for &perm in role.expand() {
|
||||
if !entry.permissions.contains(&perm) {
|
||||
entry.permissions.push(perm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1553,6 +1600,11 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
}
|
||||
|
||||
async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result<Vec<Grant>, DomainError> {
|
||||
// Pivoted to `storage.role_grants` (see `list_incoming_grants`).
|
||||
// Group membership doesn't apply on the outgoing side — we
|
||||
// filter by `granted_by` directly. Bundle expansion still
|
||||
// happens at read time via `role_row_to_grants` until the
|
||||
// public `Grant` shape becomes role-keyed.
|
||||
let rows = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
@@ -1569,10 +1621,10 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
>(
|
||||
r#"
|
||||
SELECT id, subject_type, subject_id, resource_type, resource_id,
|
||||
permission, granted_by, granted_at, expires_at
|
||||
FROM storage.access_grants
|
||||
role::text, granted_by, granted_at, expires_at
|
||||
FROM storage.role_grants
|
||||
WHERE granted_by = $1
|
||||
ORDER BY granted_at DESC
|
||||
ORDER BY role ASC, granted_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(granted_by)
|
||||
@@ -1583,11 +1635,68 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
rows.into_iter().map(Self::row_to_grant).collect()
|
||||
}
|
||||
|
||||
async fn grant(
|
||||
async fn set_expiry_for_subject(
|
||||
&self,
|
||||
subject: Subject,
|
||||
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"UPDATE storage.role_grants SET expires_at = $3 \
|
||||
WHERE subject_type = $1 AND subject_id = $2",
|
||||
)
|
||||
.bind(subject.type_str())
|
||||
.bind(subject.id())
|
||||
.bind(expires_at)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("PgAcl", format!("set_expiry_for_subject: {e}"))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM storage.role_grants WHERE id = $1")
|
||||
.bind(grant_id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke_all_for_resource(&self, resource: Resource) -> Result<usize, DomainError> {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM storage.role_grants WHERE resource_type = $1 AND resource_id = $2",
|
||||
)
|
||||
.bind(resource.type_str())
|
||||
.bind(resource.id())
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for resource: {e}")))?;
|
||||
|
||||
Ok(result.rows_affected() as usize)
|
||||
}
|
||||
|
||||
async fn revoke_all_for_subject(&self, subject: Subject) -> Result<usize, DomainError> {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM storage.role_grants WHERE subject_type = $1 AND subject_id = $2",
|
||||
)
|
||||
.bind(subject.type_str())
|
||||
.bind(subject.id())
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for subject: {e}")))?;
|
||||
|
||||
Ok(result.rows_affected() as usize)
|
||||
}
|
||||
|
||||
// ── D-Prep role_grants writes ──────────────────────────────────────────
|
||||
|
||||
async fn set_role(
|
||||
&self,
|
||||
granted_by: Uuid,
|
||||
subject: Subject,
|
||||
permission: Permission,
|
||||
role: Role,
|
||||
resource: Resource,
|
||||
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<Grant, DomainError> {
|
||||
@@ -1606,104 +1715,48 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
),
|
||||
>(
|
||||
r#"
|
||||
INSERT INTO storage.access_grants
|
||||
(subject_type, subject_id, resource_type, resource_id, permission, granted_by, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (subject_type, subject_id, resource_type, resource_id, permission)
|
||||
DO UPDATE SET expires_at = EXCLUDED.expires_at
|
||||
INSERT INTO storage.role_grants
|
||||
(subject_type, subject_id, resource_type, resource_id,
|
||||
role, granted_by, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5::storage.grant_role, $6, $7)
|
||||
ON CONFLICT (subject_type, subject_id, resource_type, resource_id)
|
||||
DO UPDATE SET role = EXCLUDED.role,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
granted_by = EXCLUDED.granted_by
|
||||
RETURNING id, subject_type, subject_id, resource_type, resource_id,
|
||||
permission, granted_by, granted_at, expires_at
|
||||
role::text, granted_by, granted_at, expires_at
|
||||
"#,
|
||||
)
|
||||
.bind(subject.type_str())
|
||||
.bind(subject.id())
|
||||
.bind(resource.type_str())
|
||||
.bind(resource.id())
|
||||
.bind(permission.as_str())
|
||||
.bind(role.as_str())
|
||||
.bind(granted_by)
|
||||
.bind(expires_at)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("insert grant: {e}")))?;
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("set_role: {e}")))?;
|
||||
|
||||
Self::row_to_grant(row)
|
||||
}
|
||||
|
||||
async fn set_expiry_for_subject(
|
||||
&self,
|
||||
subject: Subject,
|
||||
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<(), DomainError> {
|
||||
async fn clear_role(&self, subject: Subject, resource: Resource) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"UPDATE storage.access_grants SET expires_at = $3 WHERE subject_type = $1 AND subject_id = $2",
|
||||
)
|
||||
.bind(subject.type_str())
|
||||
.bind(subject.id())
|
||||
.bind(expires_at)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("set_expiry_for_subject: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_expiry_on_resource(
|
||||
&self,
|
||||
subject: Subject,
|
||||
resource: Resource,
|
||||
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"UPDATE storage.access_grants SET expires_at = $3 \
|
||||
"DELETE FROM storage.role_grants \
|
||||
WHERE subject_type = $1 AND subject_id = $2 \
|
||||
AND resource_type = $4 AND resource_id = $5",
|
||||
AND resource_type = $3 AND resource_id = $4",
|
||||
)
|
||||
.bind(subject.type_str())
|
||||
.bind(subject.id())
|
||||
.bind(expires_at)
|
||||
.bind(resource.type_str())
|
||||
.bind(resource.id())
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("PgAcl", format!("set_expiry_on_resource: {e}"))
|
||||
})?;
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("clear_role: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM storage.access_grants WHERE id = $1")
|
||||
.bind(grant_id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke_all_for_resource(&self, resource: Resource) -> Result<usize, DomainError> {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM storage.access_grants WHERE resource_type = $1 AND resource_id = $2",
|
||||
)
|
||||
.bind(resource.type_str())
|
||||
.bind(resource.id())
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for resource: {e}")))?;
|
||||
|
||||
Ok(result.rows_affected() as usize)
|
||||
}
|
||||
|
||||
async fn revoke_all_for_subject(&self, subject: Subject) -> Result<usize, DomainError> {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM storage.access_grants WHERE subject_type = $1 AND subject_id = $2",
|
||||
)
|
||||
.bind(subject.type_str())
|
||||
.bind(subject.id())
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for subject: {e}")))?;
|
||||
|
||||
Ok(result.rows_affected() as usize)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -42,7 +42,7 @@ pub fn test_db_url() -> String {
|
||||
/// OnceCell so concurrent test threads block until the first caller
|
||||
/// finishes; subsequent calls are zero-cost.
|
||||
///
|
||||
/// Order matters: `storage.access_grants` rows go first because there's
|
||||
/// Order matters: `storage.role_grants` rows go first because there's
|
||||
/// no FK from there to `auth.subject_groups` (the service's `delete`
|
||||
/// path does this transactionally; here we bypass the service).
|
||||
static CLEANUP_ONCE: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new();
|
||||
@@ -51,7 +51,7 @@ pub async fn ensure_clean_test_db(pool: &PgPool) {
|
||||
CLEANUP_ONCE
|
||||
.get_or_init(|| async {
|
||||
let _ = sqlx::query(
|
||||
"DELETE FROM storage.access_grants
|
||||
"DELETE FROM storage.role_grants
|
||||
WHERE subject_type = 'group'
|
||||
AND subject_id IN (
|
||||
SELECT id FROM auth.subject_groups WHERE name LIKE 'rust-test-%'
|
||||
|
||||
@@ -14,16 +14,16 @@ use axum::{
|
||||
use futures::future::join_all;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, warn};
|
||||
use tracing::{error, warn};
|
||||
use utoipa::IntoParams;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::dtos::cursor::PageCursor;
|
||||
use crate::application::dtos::grant_dto::{
|
||||
CreateGrantDto, CreateGrantResponseDto, GrantDto, MySharesDto, NotifyOutcomeSetDto,
|
||||
OutgoingResourceGrantDto, OutgoingResourceItemDto, PermissionDto, ResourceContentDto,
|
||||
ResourceDto, ResourceTypeDto, SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery,
|
||||
SubjectDto, SubjectInputDto, UpdateRoleDto, role_from_permissions,
|
||||
OutgoingResourceGrantDto, OutgoingResourceItemDto, ResourceContentDto, ResourceDto,
|
||||
ResourceTypeDto, SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery, SubjectDto,
|
||||
SubjectInputDto, UpdateRoleDto, role_from_permissions,
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
||||
@@ -35,7 +35,7 @@ use crate::common::errors::DomainError;
|
||||
use crate::domain::errors::ErrorKind;
|
||||
use crate::domain::services::authorization::{
|
||||
GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource, ResourceKind,
|
||||
Subject,
|
||||
Role, Subject,
|
||||
};
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
@@ -66,28 +66,7 @@ pub async fn create_grant(
|
||||
let authz = &state.authorization;
|
||||
let caller_id = auth_user.id;
|
||||
|
||||
// Validate: exactly one of permissions/role
|
||||
let permissions: Vec<Permission> = match (dto.permissions, dto.role) {
|
||||
(Some(perms), None) if !perms.is_empty() => perms.into_iter().map(Into::into).collect(),
|
||||
(None, Some(role)) => role.expand().to_vec(),
|
||||
(Some(_), Some(_)) => {
|
||||
return AppError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Provide either 'permissions' or 'role', not both",
|
||||
"InvalidInput",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
_ => {
|
||||
return AppError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Either 'permissions' (non-empty) or 'role' is required",
|
||||
"InvalidInput",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let role: Role = dto.role.into();
|
||||
let resource: Resource = dto.resource.into();
|
||||
let expires_at = dto.expires_at;
|
||||
|
||||
@@ -152,25 +131,32 @@ pub async fn create_grant(
|
||||
}
|
||||
};
|
||||
|
||||
let mut results: Vec<GrantDto> = Vec::with_capacity(permissions.len());
|
||||
for perm in permissions {
|
||||
match authz
|
||||
.grant(caller_id, subject, perm, resource, expires_at)
|
||||
.await
|
||||
{
|
||||
Ok(grant) => results.push(grant.into()),
|
||||
Err(err) => {
|
||||
error!("grant insert failed for {perm:?}: {err}");
|
||||
return AppError::from(err).into_response();
|
||||
}
|
||||
// Single role row in `storage.role_grants`. `ON CONFLICT UPDATE` in
|
||||
// the engine makes repeated POSTs with the same (subject, resource)
|
||||
// a role refresh, matching the PATCH-style semantics callers expect.
|
||||
let grant = match authz
|
||||
.set_role(caller_id, subject, role, resource, expires_at)
|
||||
.await
|
||||
{
|
||||
Ok(g) => g,
|
||||
Err(err) => {
|
||||
error!("set_role write failed: {err}");
|
||||
return AppError::from(err).into_response();
|
||||
}
|
||||
}
|
||||
info!(
|
||||
"Created {} grant(s) for subject={:?} on resource={:?} by user {}",
|
||||
results.len(),
|
||||
subject,
|
||||
resource,
|
||||
caller_id
|
||||
};
|
||||
let grants = vec![GrantDto::from(grant)];
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "role_grant.created",
|
||||
caller_id = %caller_id,
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
resource_type = resource.type_str(),
|
||||
resource_id = %resource.id(),
|
||||
role = role.as_str(),
|
||||
expires_at = ?expires_at,
|
||||
"🤝 grant created with role '{}'", role.as_str(),
|
||||
);
|
||||
|
||||
// PR N1 — route the post-grant notification through the unified
|
||||
@@ -240,7 +226,7 @@ pub async fn create_grant(
|
||||
(
|
||||
StatusCode::CREATED,
|
||||
Json(CreateGrantResponseDto {
|
||||
grants: results,
|
||||
grants,
|
||||
notification,
|
||||
}),
|
||||
)
|
||||
@@ -274,17 +260,20 @@ pub async fn revoke_grant(
|
||||
Err(_) => return AppError::not_found(format!("Grant {id} not found")).into_response(),
|
||||
};
|
||||
|
||||
// Look up the grant to find the underlying resource (and granter).
|
||||
let on_resource = match authz.find_grant_by_id(grant_id).await {
|
||||
Ok(Some((res, granter))) => (res, granter),
|
||||
// Look up the grant to find the subject, resource, and granter.
|
||||
// `find_grant_full_by_id` returns the subject too — needed for the
|
||||
// `clear_role` dual-write below (role_grants is keyed by (subject,
|
||||
// resource), not by access_grants id).
|
||||
let (subject, resource, granter) = match authz.find_grant_full_by_id(grant_id).await {
|
||||
Ok(Some(triple)) => triple,
|
||||
Ok(None) => return StatusCode::NO_CONTENT.into_response(), // idempotent
|
||||
Err(e) => return AppError::from(e).into_response(),
|
||||
};
|
||||
|
||||
// Caller is authorized if they are the granter OR have Share on the resource.
|
||||
if on_resource.1 != caller_id
|
||||
if granter != caller_id
|
||||
&& let Err(e) = authz
|
||||
.require(Subject::User(caller_id), Permission::Share, on_resource.0)
|
||||
.require(Subject::User(caller_id), Permission::Share, resource)
|
||||
.await
|
||||
{
|
||||
return AppError::from(e).into_response();
|
||||
@@ -293,7 +282,36 @@ pub async fn revoke_grant(
|
||||
if let Err(e) = authz.revoke(grant_id).await {
|
||||
return AppError::from(e).into_response();
|
||||
}
|
||||
info!("Revoked grant {grant_id} (caller {caller_id})");
|
||||
|
||||
// D-Prep dual-write: clear the role_grants row for this (subject,
|
||||
// resource). Idempotent — succeeds whether or not the row existed.
|
||||
//
|
||||
// Today's API revokes one access_grants row by id; the role_grants
|
||||
// row models the WHOLE (subject, resource) cluster. Calling clear_role
|
||||
// here effectively revokes the WHOLE role assignment in role_grants,
|
||||
// even if other per-permission access_grants rows remain. This is the
|
||||
// correct semantics for the eventual cleanup-PR model (role_grants is
|
||||
// role-keyed; once access_grants goes away, "revoke" means "drop the
|
||||
// role"). During the dual-write window the two tables can drift
|
||||
// briefly if a caller revokes only some permissions of a role, but
|
||||
// the engine still reads access_grants so behaviour is unchanged.
|
||||
if let Err(e) = authz.clear_role(subject, resource).await {
|
||||
return AppError::from(e).into_response();
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "role_grant.revoked",
|
||||
caller_id = %caller_id,
|
||||
grant_id = %grant_id,
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
resource_type = resource.type_str(),
|
||||
resource_id = %resource.id(),
|
||||
granter_id = %granter,
|
||||
self_revoke = (granter == caller_id),
|
||||
"🗑️ grant revoked",
|
||||
);
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
@@ -486,9 +504,8 @@ pub async fn set_role(
|
||||
let caller_id = auth_user.id;
|
||||
let subject: Subject = dto.subject.into();
|
||||
let resource: Resource = dto.resource.into();
|
||||
let role: Role = dto.role.into();
|
||||
let expires_at = dto.expires_at;
|
||||
let target_perms: std::collections::HashSet<Permission> =
|
||||
dto.role.expand().iter().copied().collect();
|
||||
|
||||
// Caller must have Share on the resource.
|
||||
if let Err(e) = authz
|
||||
@@ -498,84 +515,41 @@ pub async fn set_role(
|
||||
return AppError::from(e).into_response();
|
||||
}
|
||||
|
||||
// Fetch current grants on the resource for this subject.
|
||||
let current = match authz.list_grants_on_resource(resource).await {
|
||||
Ok(g) => g,
|
||||
Err(e) => return AppError::from(e).into_response(),
|
||||
};
|
||||
let current_perms: std::collections::HashSet<Permission> = current
|
||||
.iter()
|
||||
.filter(|g| g.subject == subject)
|
||||
.map(|g| g.permission)
|
||||
.collect();
|
||||
|
||||
// Diff and apply.
|
||||
let to_add: Vec<Permission> = target_perms.difference(¤t_perms).copied().collect();
|
||||
let to_remove: Vec<Permission> = current_perms.difference(&target_perms).copied().collect();
|
||||
|
||||
for perm in &to_remove {
|
||||
if let Some(g) = current
|
||||
.iter()
|
||||
.find(|g| g.subject == subject && g.permission == *perm)
|
||||
&& let Err(e) = authz.revoke(g.id).await
|
||||
{
|
||||
return AppError::from(e).into_response();
|
||||
}
|
||||
}
|
||||
for perm in &to_add {
|
||||
if let Err(e) = authz
|
||||
.grant(caller_id, subject, *perm, resource, expires_at)
|
||||
.await
|
||||
{
|
||||
return AppError::from(e).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Sync expiry on all remaining grants for this (subject, resource) pair —
|
||||
// includes newly added ones and any that were already present (retained).
|
||||
// Callers that omit expires_at will clear any existing expiry; this is
|
||||
// intentional: it keeps all permission rows for the pair consistent.
|
||||
if let Err(e) = authz
|
||||
.set_expiry_on_resource(subject, resource, expires_at)
|
||||
// Atomic role refresh. UNIQUE on (subject, resource) + ON CONFLICT
|
||||
// UPDATE in `set_role` turns this into a single UPSERT — no diff,
|
||||
// no race window. Returns the resulting role row.
|
||||
let grant = match authz
|
||||
.set_role(caller_id, subject, role, resource, expires_at)
|
||||
.await
|
||||
{
|
||||
return AppError::from(e).into_response();
|
||||
}
|
||||
|
||||
// Return the new full set.
|
||||
let after = match authz.list_grants_on_resource(resource).await {
|
||||
Ok(g) => g,
|
||||
Err(e) => return AppError::from(e).into_response(),
|
||||
};
|
||||
let mine: Vec<GrantDto> = after
|
||||
.into_iter()
|
||||
.filter(|g| g.subject == subject)
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
|
||||
info!(
|
||||
"Role applied: caller={} subject={:?} resource={:?} added={:?} removed={:?}",
|
||||
caller_id, subject, resource, to_add, to_remove
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "role_grant.role_set",
|
||||
caller_id = %caller_id,
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
resource_type = resource.type_str(),
|
||||
resource_id = %resource.id(),
|
||||
role = role.as_str(),
|
||||
expires_at = ?expires_at,
|
||||
"🔁 role set to '{}'", role.as_str(),
|
||||
);
|
||||
(StatusCode::OK, Json(mine)).into_response()
|
||||
(StatusCode::OK, Json(vec![GrantDto::from(grant)])).into_response()
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/grants/incoming
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct IncomingQuery {
|
||||
#[serde(default)]
|
||||
pub permission: Option<PermissionDto>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/grants/incoming",
|
||||
params(IncomingQuery),
|
||||
responses(
|
||||
(status = 200, description = "Direct grants targeting the caller", body = Vec<GrantDto>),
|
||||
(status = 200, description = "Direct role grants targeting the caller", body = Vec<GrantDto>),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "grants"
|
||||
@@ -583,12 +557,11 @@ pub struct IncomingQuery {
|
||||
pub async fn list_incoming(
|
||||
State(state): State<AppStateRef>,
|
||||
auth_user: AuthUser,
|
||||
Query(q): Query<IncomingQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let caller_id = auth_user.id;
|
||||
match state
|
||||
.authorization
|
||||
.list_incoming_grants(Subject::User(caller_id), q.permission.map(Into::into))
|
||||
.list_incoming_grants(Subject::User(caller_id))
|
||||
.await
|
||||
{
|
||||
Ok(grants) => {
|
||||
|
||||
@@ -23,7 +23,7 @@ use crate::application::dtos::folder_dto::{
|
||||
use crate::application::dtos::folder_listing_dto::FolderListingDto;
|
||||
use crate::application::dtos::grant_dto::{
|
||||
CreateGrantDto, GrantDto, OutgoingResourceItemDto, PermissionDto, ResourceContentDto,
|
||||
ResourceDto, ResourceTypeDto, Role, SharedWithMeDto, SharedWithMeItemDto, SubjectDto,
|
||||
ResourceDto, ResourceTypeDto, RoleDto, SharedWithMeDto, SharedWithMeItemDto, SubjectDto,
|
||||
SubjectTypeDto, UpdateRoleDto,
|
||||
};
|
||||
use crate::application::dtos::i18n_dto::{
|
||||
@@ -351,7 +351,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
ResourceTypeDto,
|
||||
ResourceDto,
|
||||
PermissionDto,
|
||||
Role,
|
||||
RoleDto,
|
||||
CreateGrantDto,
|
||||
UpdateRoleDto,
|
||||
GrantDto,
|
||||
|
||||
@@ -454,7 +454,7 @@ class MySharesList {
|
||||
);
|
||||
menu.appendChild(this._menuSeparator());
|
||||
|
||||
for (const role of /** @type {('admin'|'editor'|'viewer')[]} */ (['admin', 'editor', 'viewer'])) {
|
||||
for (const role of /** @type {('owner'|'editor'|'viewer')[]} */ (['owner', 'editor', 'viewer'])) {
|
||||
const isCurrent = grant.role === role;
|
||||
const mi = this._menuItem(isCurrent ? 'fas fa-check' : '', roleLabel(role), false, async () => {
|
||||
menu.remove();
|
||||
|
||||
@@ -23,7 +23,7 @@ import { i18n } from '../core/i18n.js';
|
||||
* @returns {'manage'|'edit'|'view'}
|
||||
*/
|
||||
function roleMod(role) {
|
||||
if (role === 'admin') return 'manage';
|
||||
if (role === 'owner') return 'manage';
|
||||
if (role === 'editor') return 'edit';
|
||||
return 'view';
|
||||
}
|
||||
@@ -31,14 +31,17 @@ function roleMod(role) {
|
||||
/**
|
||||
* Translate a role identifier into a localized human-readable label.
|
||||
* Exported so callers that just want the label (e.g. context-menu rows)
|
||||
* can reuse the same wording the chip uses.
|
||||
* can reuse the same wording the chip uses. Unknown roles fall back to
|
||||
* the raw role string — `commenter` and `contributor` exist server-side
|
||||
* but aren't surfaced in the UI today, so they'll display as-is until a
|
||||
* future UI exposure adds proper labels.
|
||||
* @param {string} role
|
||||
* @returns {string}
|
||||
*/
|
||||
export function roleLabel(role) {
|
||||
/** @type {Record<string,string>} */
|
||||
const m = {
|
||||
admin: i18n.t('share.role.canManage', 'Can manage'),
|
||||
owner: i18n.t('share.role.canManage', 'Can manage'),
|
||||
editor: i18n.t('share.role.canEdit', 'Can edit'),
|
||||
viewer: i18n.t('share.role.canView', 'Can view')
|
||||
};
|
||||
@@ -51,7 +54,7 @@ export function roleLabel(role) {
|
||||
* @returns {string}
|
||||
*/
|
||||
function roleIcon(role) {
|
||||
if (role === 'admin') return 'fa-crown';
|
||||
if (role === 'owner') return 'fa-crown';
|
||||
if (role === 'editor') return 'fa-pencil-alt';
|
||||
return 'fa-eye';
|
||||
}
|
||||
|
||||
@@ -73,13 +73,6 @@ function _looksLikeEmail(q) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(q);
|
||||
}
|
||||
|
||||
/** Permissions that belong to each role (must mirror the Rust DTO). */
|
||||
const ROLE_PERMISSIONS = {
|
||||
viewer: ['read'],
|
||||
editor: ['read', 'comment', 'create', 'update'],
|
||||
admin: ['read', 'comment', 'create', 'update', 'share', 'delete']
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch up to ~8 ReBAC subject groups whose name matches `q`. Authenticated
|
||||
* endpoint; returns `[]` on any failure so the autocomplete degrades to
|
||||
@@ -107,14 +100,20 @@ async function _searchGroups(q) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the highest role a set of grants represents for one subject.
|
||||
* Pick the displayed role for a member row. Server-side every Grant
|
||||
* carries an explicit role since the cleanup PR, so this just reads it.
|
||||
* The server may emit `commenter` or `contributor` (full enum), but the
|
||||
* picker only exposes Viewer/Editor/Owner — collapse the two unexposed
|
||||
* roles to the closest neighbour so the UI never renders an unknown
|
||||
* option.
|
||||
* @param {Grant[]} subjectGrants
|
||||
* @returns {ShareRoleEnum}
|
||||
*/
|
||||
function _roleFromGrants(subjectGrants) {
|
||||
const perms = new Set(subjectGrants.map((g) => g.permission));
|
||||
if (perms.has('delete') || perms.has('share')) return 'admin';
|
||||
if (perms.has('create') || perms.has('update')) return 'editor';
|
||||
const role = subjectGrants[0]?.role;
|
||||
if (role === 'owner' || role === 'editor' || role === 'viewer') return role;
|
||||
if (role === 'commenter') return 'viewer';
|
||||
if (role === 'contributor') return 'editor';
|
||||
return 'viewer';
|
||||
}
|
||||
|
||||
@@ -363,7 +362,7 @@ const shareModal = {
|
||||
for (const [val, label] of [
|
||||
['viewer', i18n.t('share.role.canView', 'Can view')],
|
||||
['editor', i18n.t('share.role.canEdit', 'Can edit')],
|
||||
['admin', i18n.t('share.role.canManage', 'Can manage')]
|
||||
['owner', i18n.t('share.role.canManage', 'Can manage')]
|
||||
]) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = val;
|
||||
@@ -606,7 +605,7 @@ const shareModal = {
|
||||
granted_at: '',
|
||||
granted_by: '',
|
||||
subject: { type: subjectType, id: contact.id },
|
||||
permission: /** @type {import('../core/types.js').PermissionTypeEnum} */ (ROLE_PERMISSIONS[this._stagedRole][0]),
|
||||
role: this._stagedRole,
|
||||
resource: { type: this._itemType, id: this._item?.id ?? '' }
|
||||
};
|
||||
this._localMembers.push({
|
||||
@@ -649,7 +648,7 @@ const shareModal = {
|
||||
// matching the UX contract and the kebab-menu / role-select dropdown
|
||||
// order. Renaming the labels from "Manager"/"Editor"/"Viewer" to
|
||||
// "Can manage"/"Can edit"/"Can view" left this iteration order stale.
|
||||
const groups = /** @type {ShareRoleEnum[]} */ (['admin', 'editor', 'viewer']);
|
||||
const groups = /** @type {ShareRoleEnum[]} */ (['owner', 'editor', 'viewer']);
|
||||
let memberIndex = 0;
|
||||
|
||||
for (const role of groups) {
|
||||
@@ -663,7 +662,7 @@ const shareModal = {
|
||||
header.className = 'smd-group-header';
|
||||
|
||||
const labelMap = {
|
||||
admin: i18n.t('share.role.canManage', 'Can manage'),
|
||||
owner: i18n.t('share.role.canManage', 'Can manage'),
|
||||
editor: i18n.t('share.role.canEdit', 'Can edit'),
|
||||
viewer: i18n.t('share.role.canView', 'Can view')
|
||||
};
|
||||
@@ -711,7 +710,7 @@ const shareModal = {
|
||||
for (const [val, label] of [
|
||||
['viewer', i18n.t('share.role.canView', 'Can view')],
|
||||
['editor', i18n.t('share.role.canEdit', 'Can edit')],
|
||||
['admin', i18n.t('share.role.canManage', 'Can manage')]
|
||||
['owner', i18n.t('share.role.canManage', 'Can manage')]
|
||||
]) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = val;
|
||||
|
||||
+22
-9
@@ -302,21 +302,27 @@
|
||||
* @property {String} id
|
||||
*/
|
||||
|
||||
/**
|
||||
* Server-side role enum — every grantable role the backend recognises.
|
||||
* The share modal's UI picker only exposes a subset (see `ShareRoleEnum`);
|
||||
* the wire format may carry any of these values on a Grant.
|
||||
* @typedef {'viewer'|'commenter'|'contributor'|'editor'|'owner'} GrantRoleEnum
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Grant
|
||||
* @property {string} id
|
||||
* @property {string} granted_at - ISO-8601 datetime string.
|
||||
* @property {string} granted_by
|
||||
* @property {Subject} subject
|
||||
* @property {PermissionTypeEnum} permission
|
||||
* @property {GrantRoleEnum} role - Role-keyed grant. One Grant = one role
|
||||
* assignment in `storage.role_grants`. The implied permission bundle
|
||||
* is derived client-side from the same lookup table used by
|
||||
* `Role::expand()` on the server (see `ROLE_PERMISSIONS` in shareModal).
|
||||
* @property {Resource} resource
|
||||
* @property {string|null} [expires_at] - ISO-8601 datetime string, or absent/null for no expiry.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Roles: `viewer`, `commenter`, `editor`, `manager`, `admin`
|
||||
*/
|
||||
|
||||
/**
|
||||
* Configuration for `ResourceListComponent`.
|
||||
* @typedef {Object} ResourceListConfig
|
||||
@@ -367,7 +373,7 @@
|
||||
* @property {'user'|'group'|'token'|'external'} subject_type
|
||||
* @property {string} subject_id
|
||||
* @property {string} subject_display - Username (users) or share name (tokens).
|
||||
* @property {'viewer'|'editor'|'admin'} role
|
||||
* @property {GrantRoleEnum} role - Server-emitted role string. `commenter` and `contributor` are reserved for future UI exposure; today the share modal only renders `viewer`/`editor`/`owner` (see `ShareRoleEnum`).
|
||||
* @property {string} granted_at - ISO-8601
|
||||
* @property {string|null} [expires_at] - ISO-8601 or absent.
|
||||
* @property {boolean} has_password - True when a token subject has a password set.
|
||||
@@ -453,15 +459,22 @@
|
||||
// ------------------- share modal
|
||||
|
||||
/**
|
||||
* Share roles (DTO-layer sugar for the ReBAC permission sets).
|
||||
* @typedef {'viewer'|'editor'|'admin'} ShareRoleEnum
|
||||
* Share-modal-exposed roles. The server's `Role` enum also includes
|
||||
* `commenter` and `contributor` (see `OutgoingResourceGrant.role`); those
|
||||
* are reserved for future UI exposure and are not offered as picker options
|
||||
* today. The "Can manage" UI label maps to `owner`.
|
||||
* @typedef {'viewer'|'editor'|'owner'} ShareRoleEnum
|
||||
*/
|
||||
|
||||
/**
|
||||
* One collaborator row in the share modal's People section.
|
||||
* @typedef {Object} MemberEntry
|
||||
* @property {Grant} grant - Representative grant (used for subject/resource info).
|
||||
* @property {Grant[]} _grants - All grants for this subject on the resource (may be > 1).
|
||||
* @property {Grant[]} _grants - All grants for this subject on the resource. Post-pivot
|
||||
* this is at most one entry (`storage.role_grants` UNIQUE on
|
||||
* `(subject, resource)`); the array shape is preserved so the existing
|
||||
* "revoke every grant on remove" loop in `_applyAll` still works
|
||||
* without a special-case for empty / new entries.
|
||||
* @property {ShareRoleEnum} role - Derived role label shown in the UI.
|
||||
* @property {'keep'|'remove'|'change'|'new'} _op - Pending local operation.
|
||||
* @property {string|null} [expires_at] - YYYY-MM-DD expiry date string, or null for no expiry.
|
||||
|
||||
@@ -164,7 +164,9 @@ const grants = {
|
||||
|
||||
/**
|
||||
* Create a new grant.
|
||||
* Body mirrors `CreateGrantDto`: `{ subject, resource, role }` OR `{ subject, resource, permissions }`.
|
||||
* Body mirrors `CreateGrantDto`: `{ subject, resource, role, expires_at? }`.
|
||||
* Strictly role-keyed since the cleanup PR — the per-permission shape
|
||||
* is no longer accepted.
|
||||
*
|
||||
* Response shape (PR N1 — `CreateGrantResponseDto`):
|
||||
*
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
// OxiCloud Service Worker
|
||||
// FIXME: generate cache name according build ?
|
||||
const CACHE_NAME = 'oxicloud-cache-v27';
|
||||
const CACHE_NAME = 'oxicloud-cache-v28';
|
||||
|
||||
// Only cache static assets — NOT HTML files.
|
||||
// HTML files are served network-first so browsers always get the latest
|
||||
|
||||
+22
-19
@@ -122,11 +122,11 @@ Content-Type: application/json
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
# PR N1: POST /api/grants now wraps results in
|
||||
# `CreateGrantResponseDto { grants, notification }`.
|
||||
# Cleanup PR: one role row per (subject, resource). `CreateGrantResponseDto`
|
||||
# wraps a single role-keyed Grant in `.grants[0]`.
|
||||
[Asserts]
|
||||
jsonpath "$.grants" count == 1
|
||||
jsonpath "$.grants[0].permission" == "read"
|
||||
jsonpath "$.grants[0].role" == "viewer"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@@ -148,12 +148,13 @@ Authorization: Bearer {{dave_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[?(@.resource.id=='{{shared_folder_id}}')].permission" == "read"
|
||||
jsonpath "$[?(@.resource.id=='{{shared_folder_id}}')].role" == "viewer"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — Promote Bob to Admin (adds comment, create, update, share, delete).
|
||||
# PUT /api/grants/role reconciles the row set in one call.
|
||||
# Step 9 — Promote Bob to Owner (covers comment, create, update, share,
|
||||
# delete, manage). PUT /api/grants/role replaces the role in one
|
||||
# UPSERT against `storage.role_grants`.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/api/grants/role
|
||||
Authorization: Bearer {{alice_token}}
|
||||
@@ -161,16 +162,17 @@ Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{dave_user_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{shared_folder_id}}" },
|
||||
"role": "admin"
|
||||
"role": "owner"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$" count == 6
|
||||
jsonpath "$" count == 1
|
||||
jsonpath "$[0].role" == "owner"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 10 — Bob can now rename (Manager includes update).
|
||||
# Step 10 — Bob can now rename (Owner includes update).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/api/folders/{{shared_folder_id}}/rename
|
||||
Authorization: Bearer {{dave_token}}
|
||||
@@ -194,7 +196,7 @@ HTTP 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 12 — Bob re-shares to Carol (he has Share via Admin).
|
||||
# Step 12 — Bob re-shares to Carol (he has Share via Owner).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{dave_token}}
|
||||
@@ -218,7 +220,7 @@ Authorization: Bearer {{eve_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[?(@.resource.id=='{{shared_folder_id}}')].permission" == "read"
|
||||
jsonpath "$[?(@.resource.id=='{{shared_folder_id}}')].role" == "viewer"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@@ -247,7 +249,7 @@ Content-Type: application/json
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$" count == 1
|
||||
jsonpath "$[0].permission" == "read"
|
||||
jsonpath "$[0].role" == "viewer"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@@ -263,8 +265,9 @@ HTTP 404
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 17 — Lifecycle: Alice deletes the folder. The DB trigger
|
||||
# trg_cleanup_grants_folder removes both bob's and carol's
|
||||
# grants automatically (also for the cascade-deleted child).
|
||||
# trg_cleanup_role_grants_folder removes both bob's and
|
||||
# carol's grants automatically (also for the cascade-deleted
|
||||
# child).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/folders/{{child_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
@@ -767,7 +770,7 @@ HTTP 404
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# Phase 2D — Promote adam to Admin (all 6 permissions). Delete OK.
|
||||
# Phase 2D — Promote adam to Owner (full bundle, incl. delete). Delete OK.
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
PUT {{base_url}}/api/grants/role
|
||||
Authorization: Bearer {{alice_token}}
|
||||
@@ -775,7 +778,7 @@ Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{adam_user_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{perm_folder_id}}" },
|
||||
"role": "admin"
|
||||
"role": "owner"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
@@ -788,7 +791,7 @@ HTTP 204
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# Phase 2E — Lifecycle cleanup. Alice (still the owner) trashes &
|
||||
# empties; the trigger removes all access_grants rows.
|
||||
# empties; the trigger removes all role_grants rows.
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
DELETE {{base_url}}/api/folders/{{perm_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
@@ -1176,12 +1179,12 @@ Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{frank_user_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{batch_root_id}}" },
|
||||
"role": "admin"
|
||||
"role": "owner"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
|
||||
# Frank (Admin grant = Delete) trashes batch_file_2 — item goes to
|
||||
# Frank (Owner role includes Delete) trashes batch_file_2 — item goes to
|
||||
# Alice's trash because file.user_id is unchanged (Alice is still owner).
|
||||
POST {{base_url}}/api/batch/trash
|
||||
Authorization: Bearer {{frank_token}}
|
||||
|
||||
@@ -285,7 +285,7 @@ HTTP 201
|
||||
# `CreateGrantResponseDto { grants, notification }`.
|
||||
[Asserts]
|
||||
jsonpath "$.grants" count == 1
|
||||
jsonpath "$.grants[0].permission" == "read"
|
||||
jsonpath "$.grants[0].role" == "viewer"
|
||||
jsonpath "$.grants[0].subject.type" == "group"
|
||||
jsonpath "$.grants[0].subject.id" == "{{group_a_id}}"
|
||||
|
||||
@@ -355,7 +355,7 @@ Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[?(@.resource.id=='{{perm_folder_id}}')].permission" == "read"
|
||||
jsonpath "$[?(@.resource.id=='{{perm_folder_id}}')].role" == "viewer"
|
||||
jsonpath "$[?(@.resource.id=='{{perm_folder_id}}')].subject.type" == "group"
|
||||
jsonpath "$[?(@.resource.id=='{{perm_folder_id}}')].subject.id" == "{{group_a_id}}"
|
||||
|
||||
@@ -533,7 +533,7 @@ HTTP 404
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# Phase D — Promote group A's grant to Admin (all 6 permissions).
|
||||
# Phase D — Promote group A's grant to Owner (full bundle).
|
||||
# Delete now succeeds for henry, still flowing through B → A.
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
PUT {{base_url}}/api/grants/role
|
||||
@@ -542,7 +542,7 @@ Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "group", "id": "{{group_a_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{perm_folder_id}}" },
|
||||
"role": "admin"
|
||||
"role": "owner"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
# =============================================================
|
||||
# OxiCloud — D-Prep: role_grants dual-write + new wire format
|
||||
# =============================================================
|
||||
# Pins the D-Prep refactor behaviours that don't fit naturally into
|
||||
# the existing `grants.hurl` (which is API-shape-focused). Specifically:
|
||||
#
|
||||
# 1. Wire-format role names:
|
||||
# - "owner" is accepted on POST and emitted on response
|
||||
# - "admin" is REJECTED with 422 (compat alias retired in the
|
||||
# cleanup PR — see Step 6a)
|
||||
#
|
||||
# 2. Role-keyed write proof: granting a role and then exercising a
|
||||
# permission from its bundle works → proves the row landed in
|
||||
# `storage.role_grants` and the engine read path expands the
|
||||
# bundle correctly (see `folder_cascade_grant_exists`).
|
||||
#
|
||||
# 3. Atomic role updates via PUT /api/grants/role — the role flips
|
||||
# in a single SQL update (no DELETE+INSERT race window).
|
||||
#
|
||||
# 4. Clean revoke: DELETE /api/grants/{id} clears role_grants too.
|
||||
#
|
||||
# Self-contained: creates its own users, folders, and files so it
|
||||
# can run in any position relative to other test files.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 — admin login + home folder lookup
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
admin_token: jsonpath "$.access_token"
|
||||
admin_user_id: jsonpath "$.user.id"
|
||||
|
||||
GET {{base_url}}/api/folders
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
admin_home_id: jsonpath "$[0].id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — Create two fresh test users (renee, sam) so this file
|
||||
# doesn't depend on cross-file fixtures. Use the
|
||||
# legacy-compat path that POSTs to /api/admin/users.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{ "username": "renee", "password": "ReneePassword1!", "email": "renee@example.com", "role": "user" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
renee_user_id: jsonpath "$.id"
|
||||
|
||||
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{ "username": "sam", "password": "SamPassword1!", "email": "sam@example.com", "role": "user" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
sam_user_id: jsonpath "$.id"
|
||||
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "renee", "password": "ReneePassword1!" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
renee_token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "sam", "password": "SamPassword1!" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
sam_token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — admin creates a folder "role-grants-test" + a file
|
||||
# inside it to use as the authz target throughout the file.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "role-grants-test", "parent_id": "{{admin_home_id}}" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
test_folder_id: jsonpath "$.id"
|
||||
|
||||
|
||||
POST {{base_url}}/api/files/upload
|
||||
Authorization: Bearer {{admin_token}}
|
||||
[MultipartFormData]
|
||||
folder_id: {{test_folder_id}}
|
||||
file: file,fixtures/hello.txt; text/plain
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
test_file_id: jsonpath "$.id"
|
||||
|
||||
# Rename immediately so subsequent throwaway uploads of `hello.txt`
|
||||
# to the same folder don't 409. Each throwaway upload below applies
|
||||
# the same pattern (upload → rename → use) to keep the namespace
|
||||
# clean for the next one.
|
||||
PUT {{base_url}}/api/files/{{test_file_id}}/rename
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "step3-anchor.txt" }
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — Grant renee role="owner" on the folder. New wire format.
|
||||
# Response should echo back the canonical name.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{renee_user_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{test_folder_id}}" },
|
||||
"role": "owner"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — Dual-write proof: renee (now Owner of the folder) can
|
||||
# DELETE a file inside it. Owner's bundle includes Delete;
|
||||
# the engine reads from role_grants → if dual-write didn't
|
||||
# land the row, the cascade query returns empty and the
|
||||
# delete is refused.
|
||||
#
|
||||
# We upload + delete a throwaway file to avoid removing the
|
||||
# test_file we'll need for later steps.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/files/upload
|
||||
Authorization: Bearer {{admin_token}}
|
||||
[MultipartFormData]
|
||||
folder_id: {{test_folder_id}}
|
||||
file: file,fixtures/hello.txt; text/plain
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
throwaway_file_id: jsonpath "$.id"
|
||||
|
||||
# Rename so subsequent uploads in this folder don't 409 on "hello.txt".
|
||||
PUT {{base_url}}/api/files/{{throwaway_file_id}}/rename
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "renee-throwaway.txt" }
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/files/{{throwaway_file_id}}
|
||||
Authorization: Bearer {{renee_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6a — Reject the legacy "admin" string. The cleanup PR
|
||||
# retired the `#[serde(alias = "admin")]` compat shim on
|
||||
# `RoleDto::Owner`; the deserialiser now refuses it with 422.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{sam_user_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{test_folder_id}}" },
|
||||
"role": "admin"
|
||||
}
|
||||
|
||||
HTTP 422
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6b — Grant sam Owner with the canonical role string.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{sam_user_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{test_folder_id}}" },
|
||||
"role": "owner"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
# The single role-keyed Grant returned in `.grants[0]` is the
|
||||
# `storage.role_grants` row id. Step 10's revoke uses it to
|
||||
# `clear_role` and wipe the row.
|
||||
sam_grant_id: jsonpath "$.grants[0].id"
|
||||
|
||||
|
||||
# Sam should now have Owner-equivalent access — Delete works.
|
||||
POST {{base_url}}/api/files/upload
|
||||
Authorization: Bearer {{admin_token}}
|
||||
[MultipartFormData]
|
||||
folder_id: {{test_folder_id}}
|
||||
file: file,fixtures/hello.txt; text/plain
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
sam_throwaway_id: jsonpath "$.id"
|
||||
|
||||
PUT {{base_url}}/api/files/{{sam_throwaway_id}}/rename
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "sam-throwaway.txt" }
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/files/{{sam_throwaway_id}}
|
||||
Authorization: Bearer {{sam_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — Atomic role update: demote renee from Owner to Viewer
|
||||
# via PUT /api/grants/role. Single SQL UPDATE on
|
||||
# role_grants — no DELETE+INSERT race.
|
||||
#
|
||||
# After: renee can still Read but should be refused Delete.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/api/grants/role
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{renee_user_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{test_folder_id}}" },
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# Renee can still read the file (Viewer's bundle includes Read).
|
||||
GET {{base_url}}/api/files/{{test_file_id}}
|
||||
Authorization: Bearer {{renee_token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# Renee CANNOT delete — Viewer's bundle excludes Delete; the
|
||||
# folder_cascade_grant_exists query for Delete returns empty.
|
||||
POST {{base_url}}/api/files/upload
|
||||
Authorization: Bearer {{admin_token}}
|
||||
[MultipartFormData]
|
||||
folder_id: {{test_folder_id}}
|
||||
file: file,fixtures/hello.txt; text/plain
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
post_demote_file_id: jsonpath "$.id"
|
||||
|
||||
PUT {{base_url}}/api/files/{{post_demote_file_id}}/rename
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "post-demote.txt" }
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/files/{{post_demote_file_id}}
|
||||
Authorization: Bearer {{renee_token}}
|
||||
|
||||
HTTP *
|
||||
[Asserts]
|
||||
status >= 400
|
||||
status < 500
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — Promote renee back to Editor (one role change, atomic)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/api/grants/role
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{renee_user_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{test_folder_id}}" },
|
||||
"role": "editor"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# Editor's bundle includes Update — renaming a file should work.
|
||||
PUT {{base_url}}/api/files/{{post_demote_file_id}}/rename
|
||||
Authorization: Bearer {{renee_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "renamed-by-renee.txt" }
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — My Shares response shape: the outgoing-resources endpoint
|
||||
# must emit role strings from the new roster ("viewer" /
|
||||
# "editor" / "owner"), never the legacy "admin".
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/grants/outgoing/resources
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
# The response body MUST contain "owner" (sam's role) and "editor"
|
||||
# (renee's current role after the promote). It MUST NOT contain the
|
||||
# legacy "admin" role string for any grant emitted by the server.
|
||||
body contains "\"role\":\"owner\""
|
||||
body contains "\"role\":\"editor\""
|
||||
body not contains "\"role\":\"admin\""
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 10 — Revoke: removing sam's grant. `engine.revoke()` DELETEs
|
||||
# the single `storage.role_grants` row by id.
|
||||
#
|
||||
# After: sam's Delete attempt should be refused (proof
|
||||
# the role_grants row is gone — the cascade query for
|
||||
# Delete returns empty because sam has no row pointing
|
||||
# at this folder).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
# sam_grant_id was captured at Step 6b from the create response.
|
||||
DELETE {{base_url}}/api/grants/{{sam_grant_id}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# Post-revoke: sam can no longer Delete in this folder.
|
||||
POST {{base_url}}/api/files/upload
|
||||
Authorization: Bearer {{admin_token}}
|
||||
[MultipartFormData]
|
||||
folder_id: {{test_folder_id}}
|
||||
file: file,fixtures/hello.txt; text/plain
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
post_revoke_file_id: jsonpath "$.id"
|
||||
|
||||
PUT {{base_url}}/api/files/{{post_revoke_file_id}}/rename
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "post-revoke.txt" }
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/files/{{post_revoke_file_id}}
|
||||
Authorization: Bearer {{sam_token}}
|
||||
|
||||
HTTP *
|
||||
[Asserts]
|
||||
status >= 400
|
||||
status < 500
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 11 — Teardown: clean up users + folder so this file
|
||||
# leaves no residue for the storage_cleanup_check.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/folders/{{test_folder_id}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
HTTP 204
|
||||
|
||||
DELETE {{base_url}}/api/admin/users/{{renee_user_id}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
HTTP 200
|
||||
|
||||
DELETE {{base_url}}/api/admin/users/{{sam_user_id}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
HTTP 200
|
||||
@@ -146,6 +146,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
||||
"$API_DIR/public_shares.hurl" \
|
||||
"$API_DIR/permissions.hurl" \
|
||||
"$API_DIR/grants.hurl" \
|
||||
"$API_DIR/role_grants.hurl" \
|
||||
"$API_DIR/subject_groups.hurl" \
|
||||
"$API_DIR/groups_effective_members.hurl" \
|
||||
"$API_DIR/grants_nested_groups.hurl" \
|
||||
|
||||
@@ -221,7 +221,7 @@ Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "group", "id": "{{engineers_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{shared_folder_id}}" },
|
||||
"permissions": ["read"]
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
|
||||
Reference in New Issue
Block a user