# ============================================================= # OxiCloud — ReBAC subject groups (POST/GET/PATCH/DELETE /api/groups) # ============================================================= # Exercises: # • Admin-only gate on mutations (403 for non-admin) # • CRUD happy path (create / list / get / rename / delete) # • RFC 5321 name validation (400 on invalid names) # • Membership add/remove (users + nested groups) # • Cycle prevention (400 on circular reference) # • Authorization cascade: a user reaches a resource through group # membership (the headline feature) # • Authenticated /api/groups/search (non-admin can search) # # Runs after permissions.hurl + grants.hurl (alice = admin, bob = user). # Self-contained group names ("grp-...-hurl") so it doesn't depend on # external state and won't collide with other test files. # ============================================================= # ───────────────────────────────────────────────────────────── # Step 1 — Login as admin (alice) + create a second user (grace). # ───────────────────────────────────────────────────────────── POST {{base_url}}/api/auth/login Content-Type: application/json { "username": "{{username}}", "password": "{{password}}" } HTTP 200 [Captures] alice_token: jsonpath "$.access_token" POST {{base_url}}/api/admin/users Authorization: Bearer {{alice_token}} Content-Type: application/json { "username": "grace", "password": "GracePassword1!", "email": "grace@example.com", "role": "user" } HTTP 201 [Captures] grace_user_id: jsonpath "$.user.id" POST {{base_url}}/api/auth/login Content-Type: application/json { "username": "grace", "password": "GracePassword1!" } HTTP 200 [Captures] grace_token: jsonpath "$.access_token" # ───────────────────────────────────────────────────────────── # Step 2 — Non-admin cannot create groups (403). # ───────────────────────────────────────────────────────────── POST {{base_url}}/api/groups Authorization: Bearer {{grace_token}} Content-Type: application/json { "name": "grp-forbidden-hurl" } HTTP 403 # ───────────────────────────────────────────────────────────── # Step 3 — RFC 5321 name validation: space, leading dot, non-ASCII rejected. # ───────────────────────────────────────────────────────────── POST {{base_url}}/api/groups Authorization: Bearer {{alice_token}} Content-Type: application/json { "name": "grp with space" } HTTP 400 POST {{base_url}}/api/groups Authorization: Bearer {{alice_token}} Content-Type: application/json { "name": ".leadingdot" } HTTP 400 POST {{base_url}}/api/groups Authorization: Bearer {{alice_token}} Content-Type: application/json { "name": "équipe" } HTTP 400 # ───────────────────────────────────────────────────────────── # Step 4 — CRUD happy path: create + list + get + rename. # ───────────────────────────────────────────────────────────── POST {{base_url}}/api/groups Authorization: Bearer {{alice_token}} Content-Type: application/json { "name": "grp-engineers-hurl", "description": "engineering team" } HTTP 201 [Captures] engineers_id: jsonpath "$.id" [Asserts] jsonpath "$.name" == "grp-engineers-hurl" jsonpath "$.is_virtual" == false jsonpath "$.member_count" == 0 jsonpath "$.can_manage" == true # Duplicate name rejected (case-insensitive via CITEXT). POST {{base_url}}/api/groups Authorization: Bearer {{alice_token}} Content-Type: application/json { "name": "Grp-Engineers-Hurl" } HTTP 409 # GET by id. GET {{base_url}}/api/groups/{{engineers_id}} Authorization: Bearer {{alice_token}} HTTP 200 [Asserts] jsonpath "$.id" == "{{engineers_id}}" jsonpath "$.name" == "grp-engineers-hurl" # Rename. PATCH {{base_url}}/api/groups/{{engineers_id}} Authorization: Bearer {{alice_token}} Content-Type: application/json { "name": "grp-engineering-hurl" } HTTP 200 [Asserts] jsonpath "$.name" == "grp-engineering-hurl" # ───────────────────────────────────────────────────────────── # Step 5 — Add grace as a member of the group. # ───────────────────────────────────────────────────────────── POST {{base_url}}/api/groups/{{engineers_id}}/members Authorization: Bearer {{alice_token}} Content-Type: application/json { "user_id": "{{grace_user_id}}" } HTTP 201 # GET the group again — member_count now reflects the add. GET {{base_url}}/api/groups/{{engineers_id}} Authorization: Bearer {{alice_token}} HTTP 200 [Asserts] jsonpath "$.member_count" == 1 # List members shows grace. GET {{base_url}}/api/groups/{{engineers_id}}/members Authorization: Bearer {{alice_token}} HTTP 200 [Asserts] jsonpath "$[*].kind" contains "user" jsonpath "$[*].id" contains "{{grace_user_id}}" # Idempotency: adding the same member twice is rejected with 409. POST {{base_url}}/api/groups/{{engineers_id}}/members Authorization: Bearer {{alice_token}} Content-Type: application/json { "user_id": "{{grace_user_id}}" } HTTP 409 # ───────────────────────────────────────────────────────────── # Step 6 — Cycle prevention: nest engineering inside qa, then try the loop. # ───────────────────────────────────────────────────────────── POST {{base_url}}/api/groups Authorization: Bearer {{alice_token}} Content-Type: application/json { "name": "grp-qa-hurl" } HTTP 201 [Captures] qa_id: jsonpath "$.id" # Add engineering as a member of qa (qa ∋ engineering). POST {{base_url}}/api/groups/{{qa_id}}/members Authorization: Bearer {{alice_token}} Content-Type: application/json { "group_id": "{{engineers_id}}" } HTTP 201 # Now try the reverse — engineering ∋ qa — should be rejected as a cycle. POST {{base_url}}/api/groups/{{engineers_id}}/members Authorization: Bearer {{alice_token}} Content-Type: application/json { "group_id": "{{qa_id}}" } HTTP 400 # ───────────────────────────────────────────────────────────── # Step 7 — Authorization cascade through the group. # # Alice creates a folder, grants read to the engineering group; # grace (a transitive member via engineering ⊆ qa) reaches the folder. # ───────────────────────────────────────────────────────────── GET {{base_url}}/api/folders Authorization: Bearer {{alice_token}} HTTP 200 [Captures] alice_home_id: jsonpath "$[0].id" POST {{base_url}}/api/folders Authorization: Bearer {{alice_token}} Content-Type: application/json { "name": "grp-shared-hurl", "parent_id": "{{alice_home_id}}" } HTTP 201 [Captures] shared_folder_id: jsonpath "$.id" # Grant read to the engineering group on this folder. POST {{base_url}}/api/grants Authorization: Bearer {{alice_token}} Content-Type: application/json { "subject": { "type": "group", "id": "{{engineers_id}}" }, "resource": { "type": "folder", "id": "{{shared_folder_id}}" }, "role": "viewer" } HTTP 201 # Grace (a member of engineering) can now list the folder's contents. GET {{base_url}}/api/folders/{{shared_folder_id}} Authorization: Bearer {{grace_token}} HTTP 200 # ───────────────────────────────────────────────────────────── # Step 8 — Self-defense on group remove_member. # A group must not drop to 0 transitive users once seeded — # without this guard, an admin could empty a group that owns # a shared drive (D3a), leaving the drive with no effective # Owner. Conservative-by-default: the rule applies to every # group, not just drive-owning ones. # # So the first attempt to remove grace (the sole member) is # refused with 400. We then seed the group with a second user, # re-attempt the removal, and assert it now succeeds — the # authz cascade tests below depend on grace being out of the # group. # # Note: the authz cache has a 30s TTL — Hurl tests run within # seconds so grace may still see the folder during the cache # window. We assert the membership removal succeeded; the # post-TTL denial is exercised by the Rust integration tests, # not here (test runtime cost). # ───────────────────────────────────────────────────────────── # 8a — First removal refused: grace is the sole transitive user. DELETE {{base_url}}/api/groups/{{engineers_id}}/members/user/{{grace_user_id}} Authorization: Bearer {{alice_token}} HTTP 400 # 8b — Seed engineering with a second user so the removal can succeed. POST {{base_url}}/api/admin/users Authorization: Bearer {{alice_token}} Content-Type: application/json { "username": "grp_helper", "password": "GrpHelperPwd1!", "email": "grp_helper@example.com", "role": "user" } HTTP 201 [Captures] helper_user_id: jsonpath "$.user.id" POST {{base_url}}/api/groups/{{engineers_id}}/members Authorization: Bearer {{alice_token}} Content-Type: application/json { "user_id": "{{helper_user_id}}" } HTTP 201 # 8c — Grace removal now succeeds: engineering still has grp_helper. DELETE {{base_url}}/api/groups/{{engineers_id}}/members/user/{{grace_user_id}} Authorization: Bearer {{alice_token}} HTTP 204 # 8d — Confirming the invariant still holds: removing the last user # (grp_helper) is again refused. DELETE {{base_url}}/api/groups/{{engineers_id}}/members/user/{{helper_user_id}} Authorization: Bearer {{alice_token}} HTTP 400 # ───────────────────────────────────────────────────────────── # Step 9 — Authenticated /api/groups/search (no admin role required). # ───────────────────────────────────────────────────────────── GET {{base_url}}/api/groups/search?q=engineering Authorization: Bearer {{grace_token}} HTTP 200 [Asserts] jsonpath "$[*].name" contains "grp-engineering-hurl" # Virtual groups (Internal, future Everyone, …) are surfaced by the # share-target search so they can be selected as grant subjects. # Ordering puts virtuals first; see `subject_group_pg_repository::list`. GET {{base_url}}/api/groups/search?q=Internal Authorization: Bearer {{grace_token}} HTTP 200 [Asserts] jsonpath "$[*].name" contains "Internal" jsonpath "$[?(@.name=='Internal')].is_virtual" == true # ───────────────────────────────────────────────────────────── # Step 10 — Internal virtual group is immutable. # ───────────────────────────────────────────────────────────── # Internal group's well-known UUID is 00000000-0000-0000-0000-000000000001. POST {{base_url}}/api/groups/00000000-0000-0000-0000-000000000001/members Authorization: Bearer {{alice_token}} Content-Type: application/json { "user_id": "{{grace_user_id}}" } HTTP 403 DELETE {{base_url}}/api/groups/00000000-0000-0000-0000-000000000001 Authorization: Bearer {{alice_token}} HTTP 403 # ───────────────────────────────────────────────────────────── # Step 11 — Sole-Owner group-delete guard (D3b). # # A group that is the only `Role::Owner` of a shared drive must NOT be # deletable — wiping it would orphan the drive (no live Owner grant # left). Symmetric to the last-owner-protection rule on `set_role` / # `remove_member` from the membership API side; this guard catches # the same invariant from the group-lifecycle side. # # Setup: admin creates a shared drive owned by `grp-engineering-hurl`, # then tries to delete the group. Refused with 409. Promote a second # Owner (a user), then the group delete succeeds. # ───────────────────────────────────────────────────────────── POST {{base_url}}/api/drives Authorization: Bearer {{alice_token}} Content-Type: application/json { "kind": "shared", "name": "grp-guarded-drive-hurl", "owner": { "type": "group", "id": "{{engineers_id}}" } } HTTP 201 [Captures] guarded_drive_id: jsonpath "$.id" # 11a — Group delete refused while it's the sole Owner of the drive. DELETE {{base_url}}/api/groups/{{engineers_id}} Authorization: Bearer {{alice_token}} HTTP 409 # 11b — Add Grace as a co-Owner of the drive via the admin endpoint. # Alice (the OxiCloud admin) created the drive but doesn't # auto-grant herself a role on it, so she lacks `Manage` on the # user-facing `/api/drives/{id}/members` — the admin route # bypasses that check for exactly this case. POST {{base_url}}/api/admin/drives/{{guarded_drive_id}}/members Authorization: Bearer {{alice_token}} Content-Type: application/json { "subject": { "type": "user", "id": "{{grace_user_id}}" }, "role": "owner" } HTTP 201 # 11c — Group delete now succeeds — the drive still has Grace as Owner. DELETE {{base_url}}/api/groups/{{engineers_id}} Authorization: Bearer {{alice_token}} HTTP 204 # 11d — Cleanup: trash the drive (no content) so subsequent test files # don't see a dangling shared drive. After 11c, Grace is the # only remaining Owner via her direct grant, so she's the one # who can delete via the user-facing route. DELETE {{base_url}}/api/drives/{{guarded_drive_id}} Authorization: Bearer {{grace_token}} HTTP 204 # ───────────────────────────────────────────────────────────── # Step 12 — Cleanup: delete qa group. # ───────────────────────────────────────────────────────────── DELETE {{base_url}}/api/groups/{{qa_id}} Authorization: Bearer {{alice_token}} HTTP 204 # Confirm gone. GET {{base_url}}/api/groups/{{engineers_id}} Authorization: Bearer {{alice_token}} HTTP 404