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:
@@ -0,0 +1,657 @@
|
||||
# =============================================================
|
||||
# OxiCloud — ReBAC grant cascading through nested subject groups
|
||||
# =============================================================
|
||||
# Mirrors the comprehensive permission walk from grants.hurl Phase 2,
|
||||
# but the grant target is a *parent group* and the test user reaches
|
||||
# the resource via a chain:
|
||||
#
|
||||
# henry ∈ group B ∈ group A ←— grant lives here
|
||||
#
|
||||
# Each role tier (no-grant → viewer → editor → admin) is exercised
|
||||
# on the same engine-aware endpoints as grants.hurl Phase 2. Also
|
||||
# verifies the listing-side group expansion (the "Shared with me"
|
||||
# feed: /api/grants/incoming + /api/grants/incoming/resources) so
|
||||
# the user sees folders reached via group cascade.
|
||||
#
|
||||
# Runs after subject_groups.hurl. Self-contained user ("henry"),
|
||||
# self-contained group names ("grp-...-nested-hurl"), unique folder
|
||||
# names so the test doesn't depend on or leak external state.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 — Login as admin (alice) + create fresh user henry.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
alice_token: jsonpath "$.access_token"
|
||||
|
||||
GET {{base_url}}/api/folders
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
alice_home_id: jsonpath "$[0].id"
|
||||
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "username": "henry", "password": "HenryPassword1!", "email": "henry@example.com", "role": "user" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
henry_user_id: jsonpath "$.id"
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "henry", "password": "HenryPassword1!" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
henry_token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — Build the nested-group chain henry ∈ B ⊂ A.
|
||||
#
|
||||
# A (grp-grant-parent-nested-hurl)
|
||||
# └── B (grp-grant-child-nested-hurl)
|
||||
# └── henry
|
||||
#
|
||||
# Grant is on A. The recursive CTE in `expand_user` walks
|
||||
# B → A so any grant on A applies to henry.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/groups
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "grp-grant-parent-nested-hurl", "description": "outer group A" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
group_a_id: jsonpath "$.id"
|
||||
|
||||
POST {{base_url}}/api/groups
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "grp-grant-child-nested-hurl", "description": "inner group B" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
group_b_id: jsonpath "$.id"
|
||||
|
||||
# A ∋ B (B is a sub-group of A)
|
||||
POST {{base_url}}/api/groups/{{group_a_id}}/members
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "group_id": "{{group_b_id}}" }
|
||||
|
||||
HTTP 201
|
||||
|
||||
# B ∋ henry
|
||||
POST {{base_url}}/api/groups/{{group_b_id}}/members
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "user_id": "{{henry_user_id}}" }
|
||||
|
||||
HTTP 201
|
||||
|
||||
# Sanity: A's direct members include B (group), not henry.
|
||||
GET {{base_url}}/api/groups/{{group_a_id}}/members
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[*].kind" contains "group"
|
||||
jsonpath "$[*].id" contains "{{group_b_id}}"
|
||||
jsonpath "$[*].id" not contains "{{henry_user_id}}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — Alice creates the test resources:
|
||||
# parent folder + child folder + JPEG (auto-thumbnailed).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "nested-perm-folder", "parent_id": "{{alice_home_id}}" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
perm_folder_id: jsonpath "$.id"
|
||||
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "nested-perm-child", "parent_id": "{{perm_folder_id}}" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
perm_child_id: jsonpath "$.id"
|
||||
|
||||
# Use a fixture unique to this test. Sharing fixtures with grants.hurl
|
||||
# would re-dedup against blob rows whose disk files were already cleaned
|
||||
# up by that test's lifecycle phase (pre-existing dedup ref-count
|
||||
# bookkeeping bug — see thumbnail_dedup memory). A unique fixture keeps
|
||||
# this test independent of that issue.
|
||||
POST {{base_url}}/api/files/upload
|
||||
Authorization: Bearer {{alice_token}}
|
||||
[MultipartFormData]
|
||||
folder_id: {{perm_folder_id}}
|
||||
file: file,fixtures/nested-groups-logo.jpg; image/jpeg
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
perm_file_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# Phase A — Henry has NO grant (nothing on A, nothing on B, no
|
||||
# inheritance). Every engine-aware endpoint denies.
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
|
||||
# ── Folder reads ─────────────────────────────────────────────
|
||||
GET {{base_url}}/api/folders/{{perm_folder_id}}/contents
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
GET {{base_url}}/api/folders/{{perm_folder_id}}/resources
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
GET {{base_url}}/api/folders/{{perm_folder_id}}/listing
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
GET {{base_url}}/api/folders/{{perm_folder_id}}/download
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
# ── File reads ───────────────────────────────────────────────
|
||||
GET {{base_url}}/api/files?folder_id={{perm_folder_id}}
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
GET {{base_url}}/api/files/{{perm_file_id}}
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
GET {{base_url}}/api/files/{{perm_file_id}}/metadata
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
GET {{base_url}}/api/files/{{perm_file_id}}/thumbnail/icon
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
# ── Folder mutations ─────────────────────────────────────────
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "henry-attack", "parent_id": "{{perm_folder_id}}" }
|
||||
|
||||
HTTP 404
|
||||
|
||||
PUT {{base_url}}/api/folders/{{perm_folder_id}}/rename
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "henry-rename-attempt" }
|
||||
|
||||
HTTP 404
|
||||
|
||||
DELETE {{base_url}}/api/folders/{{perm_folder_id}}
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
# ── File mutations ───────────────────────────────────────────
|
||||
POST {{base_url}}/api/files/upload
|
||||
Authorization: Bearer {{henry_token}}
|
||||
[MultipartFormData]
|
||||
folder_id: {{perm_folder_id}}
|
||||
file: file,fixtures/hello.txt; text/plain
|
||||
|
||||
HTTP 404
|
||||
|
||||
PUT {{base_url}}/api/files/{{perm_file_id}}/rename
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "henry-file-rename" }
|
||||
|
||||
HTTP 404
|
||||
|
||||
PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/icon
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: image/png
|
||||
file,fixtures/blue-image.png;
|
||||
|
||||
HTTP 404
|
||||
|
||||
DELETE {{base_url}}/api/files/{{perm_file_id}}
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
# ── Chunked upload: cannot start session in alice's folder ──
|
||||
POST {{base_url}}/api/uploads
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"filename": "henry-chunked-attack.mp4",
|
||||
"folder_id": "{{perm_folder_id}}",
|
||||
"content_type": "video/mp4",
|
||||
"total_size": 2760653,
|
||||
"chunk_size": 3000000
|
||||
}
|
||||
|
||||
HTTP 404
|
||||
|
||||
# ── Incoming-list expansion: nothing yet. ───────────────────
|
||||
GET {{base_url}}/api/grants/incoming
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[*].resource.id" not contains "{{perm_folder_id}}"
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# Phase B — Alice grants Viewer to GROUP A. Cascade goes:
|
||||
# grant(A, read) → henry (via B ⊂ A) → folder + child + file.
|
||||
# Read endpoints succeed; mutations still denied.
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "group", "id": "{{group_a_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{perm_folder_id}}" },
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Asserts]
|
||||
jsonpath "$" count == 1
|
||||
jsonpath "$[0].permission" == "read"
|
||||
jsonpath "$[0].subject.type" == "group"
|
||||
jsonpath "$[0].subject.id" == "{{group_a_id}}"
|
||||
|
||||
# ── Read endpoints now succeed ──────────────────────────────
|
||||
GET {{base_url}}/api/folders/{{perm_folder_id}}/contents
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$" count == 1
|
||||
jsonpath "$[0].id" == "{{perm_child_id}}"
|
||||
|
||||
GET {{base_url}}/api/folders/{{perm_folder_id}}/resources
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
GET {{base_url}}/api/folders/{{perm_folder_id}}/listing
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
GET {{base_url}}/api/folders/{{perm_folder_id}}/download
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
header "Content-Type" contains "zip"
|
||||
|
||||
GET {{base_url}}/api/files?folder_id={{perm_folder_id}}
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$" count == 1
|
||||
jsonpath "$[0].id" == "{{perm_file_id}}"
|
||||
|
||||
GET {{base_url}}/api/files/{{perm_file_id}}
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
GET {{base_url}}/api/files/{{perm_file_id}}/metadata
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
GET {{base_url}}/api/files/{{perm_file_id}}/thumbnail/icon
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
header "Content-Type" startsWith "image/"
|
||||
|
||||
# ── Folder cascade through ltree: child also readable. ──────
|
||||
GET {{base_url}}/api/folders/{{perm_child_id}}/contents
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
# ── Incoming-list expansion: henry now sees the folder grant
|
||||
# in his "Shared with me" feed even though the grant subject
|
||||
# is group A (not henry). This validates the listing-side
|
||||
# expansion added alongside the cascade check.
|
||||
GET {{base_url}}/api/grants/incoming
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[?(@.resource.id=='{{perm_folder_id}}')].permission" == "read"
|
||||
jsonpath "$[?(@.resource.id=='{{perm_folder_id}}')].subject.type" == "group"
|
||||
jsonpath "$[?(@.resource.id=='{{perm_folder_id}}')].subject.id" == "{{group_a_id}}"
|
||||
|
||||
GET {{base_url}}/api/grants/incoming/resources?limit=50
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.items[?(@.resource.id=='{{perm_folder_id}}')].resource_type" == "folder"
|
||||
jsonpath "$.items[?(@.resource.id=='{{perm_folder_id}}')].permissions" includes "read"
|
||||
|
||||
# ── Mutations still denied (Viewer has no Update/Create/Delete) ─
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "henry-attack-2", "parent_id": "{{perm_folder_id}}" }
|
||||
|
||||
HTTP 404
|
||||
|
||||
PUT {{base_url}}/api/folders/{{perm_folder_id}}/rename
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "henry-rename-as-viewer" }
|
||||
|
||||
HTTP 404
|
||||
|
||||
PUT {{base_url}}/api/files/{{perm_file_id}}/rename
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "henry-file-rename-as-viewer" }
|
||||
|
||||
HTTP 404
|
||||
|
||||
PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/icon
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: image/png
|
||||
file,fixtures/blue-image.png;
|
||||
|
||||
HTTP 404
|
||||
|
||||
POST {{base_url}}/api/files/upload
|
||||
Authorization: Bearer {{henry_token}}
|
||||
[MultipartFormData]
|
||||
folder_id: {{perm_folder_id}}
|
||||
file: file,fixtures/hello.txt; text/plain
|
||||
|
||||
HTTP 404
|
||||
|
||||
DELETE {{base_url}}/api/files/{{perm_file_id}}
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
DELETE {{base_url}}/api/folders/{{perm_folder_id}}
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
# Viewer cannot start a chunked upload (no Create grant).
|
||||
POST {{base_url}}/api/uploads
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"filename": "viewer-chunked-attempt.mp4",
|
||||
"folder_id": "{{perm_folder_id}}",
|
||||
"content_type": "video/mp4",
|
||||
"total_size": 2760653,
|
||||
"chunk_size": 3000000
|
||||
}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# Phase C — Promote group A's grant to Editor (read + comment +
|
||||
# create + update). Create/Update succeed; Delete still denied.
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
PUT {{base_url}}/api/grants/role
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "group", "id": "{{group_a_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{perm_folder_id}}" },
|
||||
"role": "editor"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
|
||||
# Update — folder + file rename succeed.
|
||||
PUT {{base_url}}/api/folders/{{perm_folder_id}}/rename
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "renamed-by-henry-as-editor" }
|
||||
|
||||
HTTP 200
|
||||
|
||||
PUT {{base_url}}/api/files/{{perm_file_id}}/rename
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "henry-renamed-logo.jpg" }
|
||||
|
||||
HTTP 200
|
||||
|
||||
# Thumbnail push (Update) succeeds.
|
||||
PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/preview
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: image/png
|
||||
file,fixtures/blue-image.png;
|
||||
|
||||
HTTP 201
|
||||
|
||||
# Create — folder + file upload + chunked upload all succeed.
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "henry-created-child", "parent_id": "{{perm_folder_id}}" }
|
||||
|
||||
HTTP 201
|
||||
|
||||
POST {{base_url}}/api/files/upload
|
||||
Authorization: Bearer {{henry_token}}
|
||||
[MultipartFormData]
|
||||
folder_id: {{perm_folder_id}}
|
||||
file: file,fixtures/hello.txt; text/plain
|
||||
|
||||
HTTP 201
|
||||
|
||||
POST {{base_url}}/api/uploads
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"filename": "henry-chunked-video.mp4",
|
||||
"folder_id": "{{perm_folder_id}}",
|
||||
"content_type": "video/mp4",
|
||||
"total_size": 2760653,
|
||||
"chunk_size": 3000000
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
henry_upload_id: jsonpath "$.upload_id"
|
||||
|
||||
PATCH {{base_url}}/api/uploads/{{henry_upload_id}}?chunk_index=0
|
||||
Authorization: Bearer {{henry_token}}
|
||||
Content-Type: application/octet-stream
|
||||
file,fixtures/free_video_over_1MB.mp4;
|
||||
|
||||
HTTP 200
|
||||
|
||||
POST {{base_url}}/api/uploads/{{henry_upload_id}}/complete
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
henry_chunked_file_id: jsonpath "$.file_id"
|
||||
|
||||
# Alice (the owner) sees the file in the folder listing.
|
||||
GET {{base_url}}/api/files?folder_id={{perm_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[?(@.id=='{{henry_chunked_file_id}}')].name" == "henry-chunked-video.mp4"
|
||||
|
||||
# Editor still cannot delete.
|
||||
DELETE {{base_url}}/api/files/{{perm_file_id}}
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
DELETE {{base_url}}/api/folders/{{perm_folder_id}}
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# Phase D — Promote group A's grant to Admin (all 6 permissions).
|
||||
# Delete now succeeds for henry, still flowing through B → A.
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
PUT {{base_url}}/api/grants/role
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "group", "id": "{{group_a_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{perm_folder_id}}" },
|
||||
"role": "admin"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
|
||||
DELETE {{base_url}}/api/files/{{perm_file_id}}
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# Phase E — Grant lives on the INNER group B (not A).
|
||||
# Same user, same chain, but the grant is one hop closer.
|
||||
# Confirms the recursive walk works for direct-membership
|
||||
# grants as well as parent-group grants.
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# First demote group A to viewer so only one access_grants row remains
|
||||
# for that (subject, resource) pair, capturing the id directly from
|
||||
# the PUT response so we don't have to filter henry's incoming list.
|
||||
PUT {{base_url}}/api/grants/role
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "group", "id": "{{group_a_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{perm_folder_id}}" },
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
folder_grant_id: jsonpath "$[0].id"
|
||||
[Asserts]
|
||||
jsonpath "$" count == 1
|
||||
|
||||
# Delete that single remaining grant on group A.
|
||||
DELETE {{base_url}}/api/grants/{{folder_grant_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
# Confirm access is gone.
|
||||
GET {{base_url}}/api/folders/{{perm_folder_id}}/contents
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
# New grant targeting B directly.
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "group", "id": "{{group_b_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{perm_folder_id}}" },
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
|
||||
GET {{base_url}}/api/folders/{{perm_folder_id}}/contents
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
GET {{base_url}}/api/grants/incoming
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[?(@.resource.id=='{{perm_folder_id}}')].subject.id" == "{{group_b_id}}"
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# Phase F — Lifecycle cleanup.
|
||||
# Alice (still the owner) deletes the folder; the
|
||||
# trg_cleanup_grants_folder trigger removes the group grant.
|
||||
# Then delete the groups themselves.
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
DELETE {{base_url}}/api/folders/{{perm_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
DELETE {{base_url}}/api/trash/empty
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
# Henry's incoming list no longer contains this folder.
|
||||
# Note: the user-groups Moka cache has a 30s TTL, but the grant row
|
||||
# itself is gone (trigger fires synchronously on folder delete), so
|
||||
# the listing query — which now expands subjects to include groups
|
||||
# — won't find anything to match on, cache hit or miss.
|
||||
GET {{base_url}}/api/grants/incoming
|
||||
Authorization: Bearer {{henry_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[*].resource.id" not contains "{{perm_folder_id}}"
|
||||
|
||||
# Delete the groups.
|
||||
DELETE {{base_url}}/api/groups/{{group_a_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
DELETE {{base_url}}/api/groups/{{group_b_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
GET {{base_url}}/api/groups/{{group_a_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 404
|
||||
+3
-1
@@ -99,7 +99,9 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
||||
"$API_DIR/dedup_blob_cleanup.hurl" \
|
||||
"$API_DIR/contacts.hurl" \
|
||||
"$API_DIR/permissions.hurl" \
|
||||
"$API_DIR/grants.hurl"
|
||||
"$API_DIR/grants.hurl" \
|
||||
"$API_DIR/subject_groups.hurl" \
|
||||
"$API_DIR/grants_nested_groups.hurl"
|
||||
|
||||
#bash "$API_DIR/dedup_bulk_upload.sh"
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user