refactor(role): use grant only

- remove permission centric mode
    - finalize migration drop all tables with permissions
    - ensure roles are ENUM (owner is always displayed first)
This commit is contained in:
Edouard Vanbelle
2026-06-18 01:42:32 +02:00
parent cc6f53528b
commit 72129af0bd
26 changed files with 800 additions and 744 deletions
+22 -19
View File
@@ -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}}
+4 -4
View File
@@ -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
+30 -23
View File
@@ -4,17 +4,15 @@
# Pins the D-Prep refactor behaviours that don't fit naturally into
# the existing `grants.hurl` (which is API-shape-focused). Specifically:
#
# 1. New wire-format role names:
# 1. Wire-format role names:
# - "owner" is accepted on POST and emitted on response
# - "admin" still accepted on POST for one release (compat shim
# in `Role::parse`); the server normalises it to Owner
# - "admin" is REJECTED with 422 (compat alias retired in the
# cleanup PR — see Step 6a)
#
# 2. Dual-write proof: granting a role and then exercising a
# 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` because the engine now reads from there
# for authz decisions (see `folder_cascade_grant_exists` post-
# D-Prep). If dual-write failed, the engine would see no row and
# reject the check.
# `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).
@@ -177,10 +175,9 @@ HTTP 204
# ─────────────────────────────────────────────────────────────
# Step 6 — Legacy "admin" string compat. The server's Role::parse
# accepts "admin" and normalises to Owner during the D-Prep
# dual-write window (one release). Verify by granting sam
# with role="admin" — sam should then be able to Delete too.
# 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}}
@@ -191,15 +188,26 @@ Content-Type: application/json
"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]
# Capture the first per-permission grant id from the response so
# Step 10's revoke doesn't have to query the My Shares endpoint
# with awkward JSONPath filtering. Any single grant_id works:
# the revoke handler calls `clear_role` which wipes the entire
# role_grants row for (sam, test_folder), so the engine reads
# return false for sam afterwards regardless of how many
# access_grants rows still exist.
# 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"
@@ -326,9 +334,8 @@ body not contains "\"role\":\"admin\""
# ─────────────────────────────────────────────────────────────
# Step 10 — Revoke: removing sam's grant. The handler clears the
# access_grants rows AND calls clear_role to wipe the
# role_grants row in the same flow.
# 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
@@ -336,7 +343,7 @@ body not contains "\"role\":\"admin\""
# at this folder).
# ─────────────────────────────────────────────────────────────
# sam_grant_id was captured at Step 6 from the create response.
# sam_grant_id was captured at Step 6b from the create response.
DELETE {{base_url}}/api/grants/{{sam_grant_id}}
Authorization: Bearer {{admin_token}}
+1 -1
View File
@@ -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