feat(group): 1st implementation of Groups

this implements first version (manageable only by admin right now)

    routes:

        GET /api/groups
        List subject groups (paginated). Admin-only.

        POST /api/groups
        Create a new ReBAC subject group. Admin-only. The name must match the RFC 5321 local-part shape and be globally unique (case-insensitive).

        GET /api/groups/search
        Search non-virtual groups by name substring. Authenticated only (no admin role required) — backs the share-dialog recipient autocomplete.

        GET /api/groups/{id}
        Fetch a single group's details. Admin-only.

        DELETE /api/groups/{id}
        Delete a group. Cascades to `subject_group_members` (FK) and to `access_grants` rows referencing this group as a subject. Admin-only.

        PATCH /api/groups/{id}
        Update a group's metadata. Admin-only. v1 only persists name renames.

        GET /api/groups/{id}/effective-members
        List every user transitively reached through this group (members of members of members, etc.). Used by admin / audit tooling. Admin-only.

        GET /api/groups/{id}/members
        List the *direct* members of a group (one level only). Admin-only.

        POST /api/groups/{id}/members
        Add a member to a group. Exactly one of `user_id` / `group_id` must be provided. Adding a group-member runs a write-time cycle check and a nesting-depth check (max 8). Admin-only.

        DELETE /api/groups/{id}/members/group/{gid}
        Remove a nested group-member from a group. Admin-only.

        DELETE /api/groups/{id}/members/user/{uid}
        Remove a user-member from a group. Admin-only.

fix hurl

groups

round

groups
This commit is contained in:
Edouard Vanbelle
2026-05-30 23:35:47 +02:00
parent 41356b6490
commit 09985f8a95
54 changed files with 6421 additions and 145 deletions
+305
View File
@@ -0,0 +1,305 @@
# =============================================================
# 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 "$.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}}" },
"permissions": ["read"]
}
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 — Remove grace from engineering, then re-check access (after cache TTL).
# 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).
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/groups/{{engineers_id}}/members/user/{{grace_user_id}}
Authorization: Bearer {{alice_token}}
HTTP 204
# ─────────────────────────────────────────────────────────────
# 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 — Cleanup: delete engineering (cascades to qa membership + grants).
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/groups/{{engineers_id}}
Authorization: Bearer {{alice_token}}
HTTP 204
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