diff --git a/docs/plan/drive.md b/docs/plan/drive.md index e2f0e989..3845d5ff 100644 --- a/docs/plan/drive.md +++ b/docs/plan/drive.md @@ -465,7 +465,7 @@ expansion: |---|---| | `viewer` | `Read` | | `editor` | `Read`, `Create`, `Update`, `Comment` | -| `owner` | `Read`, `Create`, `Update`, `Comment`, `Delete`, `Share`, *and* drive-level admin (rename, edit policies, manage members) | +| `owner` | `Read`, `Create`, `Update`, `Comment`, `Delete`, `Share`, *and* drive-level admin (rename, manage non-Owner members). **Policy mutation and quota mutation are OxiCloud-admin only (§7, §8)** — owners cannot self-grant capacity or relax compliance gates. Owner-role mutations are admin-only when `forbid_owner_role_change` is on (§8). | ### 5. Permission resolution — additive over `role_grants` @@ -671,21 +671,43 @@ Each drive carries a `policies` JSON object. Five known keys for v1: "forbid_sharing": false, // disables per-resource grants on this drive "forbid_external_sharing": false, // blocks grants to is_external=true subjects "forbid_public_links": false, // blocks token-share (anonymous link) creation - "forbid_cross_drive_move": false // blocks MOVE when src.drive_id != dst.drive_id + "forbid_cross_drive_move": false, // blocks MOVE when src.drive_id != dst.drive_id + "forbid_owner_role_change": false // locks the Owner roster against non-admin callers } ``` +#### Mutation: OxiCloud-admin only + +`PATCH /api/drives/{id}/policies` is **OxiCloud-admin only** — the +same carve-out that guards `drives.quota_bytes` and +`users.storage_quota_bytes` (§7). The original design had policies +owner-mutable, but that made them **self-policing soft caps**: an +owner could disable `forbid_external_sharing`, mint the grant, and +re-enable the policy. The audit log would capture the toggle but +the policy gave no compliance-grade enforcement. + +Restricting mutation to the tenant operator closes that hole. Drive +owners can still see the current policy values via +`GET /api/drives` (read-only) but can't flip them; a UI surface that +submits an admin ticket handles the self-service case for +single-owner shadow.tech-style deployments. + +Anti-enumeration: non-admin callers receive `404` on the PATCH (the +same response a non-existent drive would carry), never `403`, so a +probe can't tell the policy state apart from the drive's existence. + Enforcement points (one place per policy — single grep target): | Policy | Enforcement callsite | |---|---| | `forbid_sharing` | `grant_handler::create_grant` — checks `resource.drive_id`'s policy before insertion | -| `forbid_external_sharing` | `magic_link_invite_service::resolve_or_create_recipient` and `grant_handler::create_grant` (when subject is `is_external=true`) | -| `forbid_public_links` | `share_handler::create_shared_link` | -| `forbid_cross_drive_move` | `file_handler::move_file` and `folder_handler::move_folder` — refuse when `src.drive_id != dst.drive_id` | +| `forbid_external_sharing` | `grant_handler::create_grant` (early Email + late User checks for File/Folder) and `DriveManagementService::set_member_role` (Drive resource + the membership endpoints) | +| `forbid_public_links` | `share_service::create_shared_link` and `grant_handler::create_grant` (when subject is `Token`) | +| `forbid_cross_drive_move` | `file_management_service::move_file_with_perms` and `folder_service::move_folder_with_perms` — refuse when `src.drive_id != dst.drive_id` | +| `forbid_owner_role_change` | `DriveManagementService::set_member_role` (refuses Owner-role writes + demotions of current Owners) and `::remove_member` (refuses removals of Owners) — non-admin callers only | -Default to `false` (everything allowed) — opt-in by drive owner via -the drive settings UI. +Default to `false` (everything allowed). Admin opts in per drive via +`PATCH /api/drives/{id}/policies`. #### Policy semantics — subtleties to remember @@ -698,6 +720,16 @@ the drive settings UI. move. It does **not** stop download + re-upload (that's a different category of policy — file-egress, future). UI surface should make this explicit so users don't read it as data-leak protection. +- **`forbid_owner_role_change`** locks the Owner roster against + non-admin mutation. After admin provisions the drive's owners, no + Owner can add a co-owner, be demoted, or be removed by another + Owner — only admin can change the roster. Editor / Viewer + mutations by remaining owners are unaffected. Personal drives + already refuse every member mutation via `refuse_if_personal`, so + this policy only adds value on shared drives. Pairs naturally with + the admin-only `PATCH /policies` carve-out above: once admin sets + the owners + locks the policies, the configuration is genuinely + immutable from the owner side. #### Future policy keys (out of scope for v1 — but the JSONB shape accommodates them without schema migration) @@ -1510,7 +1542,7 @@ us a real rollback window while the new model bakes in production. | **D2 — drive membership API + per-drive trash auth** | `POST /api/drives/{id}/members`, `DELETE`, `PUT` for role changes — thin handlers that translate to `role_grants` INSERT/DELETE/UPDATE with `resource_type='drive'`. `Resource::Drive(Uuid)` (added in D-Prep at the enum level) gets its specialised handler surface here. Shared-drive last-owner protection. Group-as-subject support reuses the existing `subject_groups` machinery. **Personal-drive guards** (`add_member`, `remove_member`, `delete_drive` refuse on `kind='personal'` — see §2). **Per-drive trash authorisation** (§12): trash listing filters by drive(s) the caller can read; trash mutations (send/restore/permanent-delete) require `role='owner'` on the drive; `storage.trash_items` VIEW updated to surface `drive_id`; orphan/aborted-upload sweep becomes per-drive. | Medium | | **D3 — group-owned shared drives** | "Create shared drive" flow — admin or group owner triggers, drive created with `kind='shared'`, initial owner row is the group. Group-deletion guard refuses if the group is the last owner of any drive. Drive-rename, drive-delete. | Low | | **D4 — per-drive quota** | Move storage accounting off `auth.users.storage_used_bytes` onto `storage.drives.used_bytes`. **Re-point the existing per-user incremental CTE** (introduced in v0.7.0 — see `b5b80549`, `d6987329`) at drive rows; don't reinvent the counting logic. Upload paths check `drive.quota_bytes` instead of (or in addition to) the user's quota for the dual-write window. **Per-chunk incremental quota check on the NC chunked path** (see §13): MKCOL refuses when the drive is already over quota; each PUT chunk runs an O(1) `used + session_so_far + chunk_size > quota` test and refuses with 507 within one chunk of wasted upload. Closes a pre-existing wart where NC clients could upload GB before learning they were over quota. Reconciliation job runs once per day to fix drift. | Medium | -| **D5 — policies** | JSONB policies column + enforcement at the four known callsites. Owner-only UI in drive settings. Ship policies one at a time if you want fine-grained rollout — `forbid_public_links` first (lowest risk), then `forbid_external_sharing`, then `forbid_sharing`, then `forbid_cross_drive_move`. | Low | +| **D5 — policies** | JSONB policies column + enforcement at the known callsites. **Mutation is OxiCloud-admin only** (the original "owner-mutable" plan made policies self-policing soft caps — see §8). Five policies in v1: `forbid_public_links`, `forbid_external_sharing`, `forbid_sharing`, `forbid_cross_drive_move`, and `forbid_owner_role_change`. Ship one at a time if you want fine-grained rollout in that order. | Low | | **D6 — cross-drive move + audit** | Move folder/file between drives (allowed by default; gated by `forbid_cross_drive_move` policy on the source drive). Audit events for every drive lifecycle event (`drive.created`, `drive.member_added`, `drive.member_removed`, `drive.policy_changed`, `drive.deleted`, `resource.moved_between_drives`). | Low | | **D7 — back-compat sweep** | Drop `user_id` from `storage.folders` / `storage.files`. Drop dual-write code. Drop or deprecate `auth.users.storage_quota_bytes`. **Provenance columns (`created_by`, `updated_by`) stay** — they were populated from D0 and are now the sole source of authorship signal. | Low — but the point of no return | diff --git a/src/application/services/drive_management_service.rs b/src/application/services/drive_management_service.rs index 5914b8a0..4900cdfb 100644 --- a/src/application/services/drive_management_service.rs +++ b/src/application/services/drive_management_service.rs @@ -29,6 +29,7 @@ use crate::domain::repositories::subject_group_repository::SubjectGroupRepositor 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::repositories::pg::UserPgRepository; use crate::infrastructure::services::pg_acl_engine::PgAclEngine; pub struct DriveManagementService { @@ -39,6 +40,11 @@ pub struct DriveManagementService { /// constructing an orphan-owned drive (the "drive must always have /// ≥1 effective Owner-user" invariant from day one). group_repo: Arc, + /// D5: `set_member_role` reads `users.is_external` to enforce + /// `forbid_external_sharing` on the drive — closes the gap that the + /// `POST /api/drives/{id}/members` route would otherwise open + /// (the grant_handler check only catches `POST /api/grants`). + user_repo: Arc, } impl DriveManagementService { @@ -46,11 +52,13 @@ impl DriveManagementService { drive_repo: Arc, authz: Arc, group_repo: Arc, + user_repo: Arc, ) -> Self { Self { drive_repo, authz, group_repo, + user_repo, } } @@ -201,6 +209,30 @@ impl DriveManagementService { self.refuse_if_personal(drive_id, "set_member_role").await?; + // D5: `forbid_external_sharing` on a shared drive — refuses + // grant writes whose User subject is `is_external = true`. + // Closes the `POST /api/drives/{id}/members` gap that + // grant_handler's same-shaped check (covering `POST /api/grants` + // only) doesn't reach. Group/Token subjects can't be external + // by construction, so the lookup runs only for User subjects. + // See `docs/plan/drive.md` §8. + self.refuse_if_forbid_external_sharing(drive_id, subject, caller_id) + .await?; + + // D5: `forbid_owner_role_change` — locks the Owner roster + // against non-admin callers. Fires when this write would add a + // new Owner (role == Owner) OR demote a current Owner + // (subject is currently Owner and role != Owner). + self.refuse_if_forbid_owner_role_change( + drive_id, + subject, + Some(role), + caller_id, + caller_is_admin, + "set_member_role", + ) + .await?; + // Demotion of the last owner = last-owner protection trips. A fresh // owner-role write or any non-owner subject is fine; only the case // "this subject is currently the only owner AND the new role is not @@ -255,6 +287,19 @@ impl DriveManagementService { self.refuse_if_personal(drive_id, "remove_member").await?; + // D5: `forbid_owner_role_change` — locks the Owner roster + // against non-admin callers. Fires when this would remove a + // current Owner. + self.refuse_if_forbid_owner_role_change( + drive_id, + subject, + None, // None = removal, not a role write + caller_id, + caller_is_admin, + "remove_member", + ) + .await?; + self.refuse_if_last_owner_change(drive_id, subject, caller_id) .await?; @@ -373,30 +418,27 @@ impl DriveManagementService { Ok(()) } - /// `PATCH /api/drives/{id}/policies`. Owner-only mutation of the - /// drive's `policies` JSONB bag (§5 — "edit policies" is in the - /// drive owner bundle, applies to personal AND shared drives). + /// `PATCH /api/drives/{id}/policies`. OxiCloud-admin only. + /// + /// The drive's `policies` JSONB bag is a compliance surface — same + /// category as `drives.quota_bytes` and `users.storage_quota_bytes` + /// (§7). Owner mutation would make the policies self-policing + /// (an owner could disable `forbid_external_sharing`, share, and + /// re-enable), so mutation is restricted to the tenant operator. + /// The handler is the gate (refuses non-admin callers with 404 for + /// anti-enumeration); this method trusts that gate and writes + /// unconditionally. + /// /// JSONB-level merge preserves unknown keys; only the partial /// supplied is overwritten. Returns the post-merge typed view. - /// - /// `caller_is_admin` mirrors the membership endpoints — skips the - /// per-drive Manage check. Audit emits `drive.policy_changed` with - /// the post-merge bag for steady-state observability; ops can grep - /// for the specific keys that flipped against the prior values. + /// Audit emits `drive.policy_changed` with the post-merge bag for + /// steady-state observability. pub async fn update_policies( &self, caller_id: Uuid, - caller_is_admin: bool, drive_id: Uuid, partial: crate::domain::entities::drive::DrivePolicies, ) -> Result { - let resource = Resource::Drive(drive_id); - if !caller_is_admin { - self.authz - .require(Subject::User(caller_id), Permission::Manage, resource) - .await?; - } - let merged = self .drive_repo .update_policies(drive_id, &partial) @@ -413,22 +455,63 @@ impl DriveManagementService { tracing::info!( target: "audit", - event = if caller_is_admin { - "drive.policy_changed_via_admin" - } else { - "drive.policy_changed" - }, + event = "drive.policy_changed", drive_id = %drive_id, by = %caller_id, forbid_sharing = merged.forbid_sharing, forbid_external_sharing = merged.forbid_external_sharing, forbid_public_links = merged.forbid_public_links, forbid_cross_drive_move = merged.forbid_cross_drive_move, + forbid_owner_role_change = merged.forbid_owner_role_change, "📜 drive policies updated", ); Ok(merged) } + /// D5 `forbid_external_sharing` for `set_member_role`. Fetches the + /// data this surface has but grant_handler doesn't (drive policies + + /// user flags), then defers the decision + audit + canonical error + /// to `DrivePolicies::refuse_external_sharing` — the same gate + /// `grant_handler::create_grant` runs for File/Folder resources. One + /// rejection shape across both entry points. + /// + /// Group / Token subjects can't be external by construction, so the + /// user lookup is skipped (the gate handles those branches too, but + /// returning early avoids a wasted SELECT on the drive row). + async fn refuse_if_forbid_external_sharing( + &self, + drive_id: Uuid, + subject: Subject, + caller_id: Uuid, + ) -> Result<(), DomainError> { + let Subject::User(uid) = subject else { + return Ok(()); + }; + let drive = self.drive_repo.get_by_id(drive_id).await.map_err(|e| { + DomainError::internal_error("Drive", format!("Failed to fetch drive: {e:?}")) + })?; + let policies = drive.drive.typed_policies(); + if !policies.forbid_external_sharing { + return Ok(()); + } + let flags = self + .user_repo + .get_user_flags(uid) + .await + .map_err(|e| DomainError::internal_error("User", format!("flags lookup: {e:?}")))?; + policies.refuse_external_sharing( + subject, + flags.is_external, + crate::domain::entities::drive::ExternalSharingGateContext { + caller_id, + stage: "drive_member", + drive_id: Some(drive_id), + resource_type: None, + resource_id: None, + }, + ) + } + // ── Business rules ────────────────────────────────────────────────────── /// Personal drives are single-user single-owner; any member mutation is @@ -454,6 +537,73 @@ impl DriveManagementService { Ok(()) } + /// D5 `forbid_owner_role_change`. Fetches drive policies (one PK + /// probe), bails out early when the policy is off or the caller is + /// admin, then determines whether the requested op actually + /// mutates the Owner roster: + /// + /// - `new_role = Some(Role::Owner)` — Owner add or refresh. Owner + /// roster mutation. + /// - `new_role = Some(Role::X)` and subject is currently Owner — + /// demotion. Owner roster mutation. + /// - `new_role = None` (remove) and subject is currently Owner — + /// removal. Owner roster mutation. + /// + /// In any of those cases, defers to + /// `DrivePolicies::refuse_owner_role_change` for the audit + error. + async fn refuse_if_forbid_owner_role_change( + &self, + drive_id: Uuid, + subject: Subject, + new_role: Option, + caller_id: Uuid, + caller_is_admin: bool, + operation: &'static str, + ) -> Result<(), DomainError> { + // Fast bypass for the tenant operator. + if caller_is_admin { + return Ok(()); + } + let drive = self.drive_repo.get_by_id(drive_id).await.map_err(|e| { + DomainError::internal_error("Drive", format!("Failed to fetch drive: {e:?}")) + })?; + let policies = drive.drive.typed_policies(); + if !policies.forbid_owner_role_change { + return Ok(()); + } + + // Determine whether this op touches the Owner roster. An Owner + // add (role == Owner) always does; a non-Owner write or a + // removal only does when the subject currently holds Owner — + // fetched lazily on the second case to skip the round-trip + // when we already know the answer. + let touches_owner = if matches!(new_role, Some(Role::Owner)) { + true + } else { + let grants = self + .authz + .list_grants_on_resource(Resource::Drive(drive_id)) + .await?; + grants + .iter() + .any(|g| g.subject == subject && matches!(g.role, Role::Owner)) + }; + if !touches_owner { + return Ok(()); + } + + policies.refuse_owner_role_change( + crate::domain::entities::drive::OwnerRoleChangeGateContext { + caller_id, + caller_is_admin, + drive_id, + operation, + subject_type: subject.type_str(), + subject_id: subject.id(), + }, + ) + } + /// Refuse the change if `subject` is currently the sole `Owner` on the /// drive and the operation would remove or demote them. A shared drive /// must always have at least one Owner — otherwise it becomes orphaned diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index 84d3780e..ecd18695 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -37,6 +37,12 @@ pub struct FileManagementService { /// downloads. Distinct from the lifecycle hook because lifecycle hooks /// don't carry the `caller_id` the recording side needs. resource_access_hook: Option>, + /// Drive repository — used by D5's `forbid_cross_drive_move` gate + /// on `move_file_with_perms`. Optional so stubs / test factories + /// can build the service without wiring the full drive repo; in + /// that case the cross-drive move check is skipped (the policy + /// is silently off). Production DI wires it in. + drive_repo: Option>, } impl FileManagementService { @@ -60,6 +66,7 @@ impl FileManagementService { authz, file_lifecycle_hook: None, resource_access_hook: None, + drive_repo: None, } } @@ -82,6 +89,17 @@ impl FileManagementService { } } + /// Wires the drive repository, enabling D5 `forbid_cross_drive_move` + /// enforcement on `move_file_with_perms`. Without it, the gate is + /// silently skipped. + pub fn with_drive_repo( + mut self, + drive_repo: Arc, + ) -> Self { + self.drive_repo = Some(drive_repo); + self + } + /// Engine check for a file resource. Parses the id into a `Uuid` and /// requires the specified permission. async fn require_file_perm( @@ -283,6 +301,46 @@ impl FileManagementUseCase for FileManagementService { .await?; self.require_target_folder_perm(folder_id.as_deref(), Permission::Create, caller_id) .await?; + + // D5 `forbid_cross_drive_move`: refuse when the destination + // folder belongs to a different drive than the source file and + // the source drive's policy is on. Silently skipped if the + // drive repo isn't wired (stub builders) or the move target is + // None (root namespace — same-drive semantics). Source policy + // is canonical per §8: the drive that owns the content + // controls outbound moves. + if let Some(drive_repo) = &self.drive_repo + && let Some(target_folder_id) = folder_id.as_deref() + { + let file_uuid = + Uuid::parse_str(file_id).map_err(|_| DomainError::not_found("File", file_id))?; + let dst_folder_uuid = Uuid::parse_str(target_folder_id) + .map_err(|_| DomainError::not_found("Folder", target_folder_id))?; + let (src_drive_id, src_policies) = drive_repo + .get_drive_id_and_policies_for_file(file_uuid) + .await + .map_err(|e| { + DomainError::internal_error("Drive", format!("source drive lookup: {e:?}")) + })?; + let dst_drive_id = drive_repo + .drive_id_for_folder(dst_folder_uuid) + .await + .map_err(|e| { + DomainError::internal_error("Drive", format!("destination drive lookup: {e:?}")) + })?; + if src_drive_id != dst_drive_id { + src_policies.refuse_cross_drive_move( + crate::domain::entities::drive::CrossDriveMoveGateContext { + caller_id, + resource_type: "file", + resource_id: file_uuid, + src_drive_id, + dst_drive_id, + }, + )?; + } + } + self.move_file(file_id, folder_id, caller_id).await } diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 319063e3..1ee4c0c2 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -25,6 +25,12 @@ pub struct FolderService { /// to reap. Always present — the dispatcher itself is a no-op when /// no hooks are registered, so callers don't need an Option branch. file_lifecycle: Arc, + /// Drive repository — used by D5's `forbid_cross_drive_move` gate + /// on `move_folder_with_perms`. Optional so stubs / test factories + /// can build the service without wiring the full drive repo; in + /// that case the cross-drive move check is skipped (the policy is + /// silently off). Production DI wires it via `with_drive_repo`. + drive_repo: Option>, } impl FolderService { @@ -38,9 +44,22 @@ impl FolderService { folder_storage, authz, file_lifecycle, + drive_repo: None, } } + /// Wires the drive repository, enabling D5 + /// `forbid_cross_drive_move` enforcement on + /// `move_folder_with_perms`. Without it, the gate is silently + /// skipped. + pub fn with_drive_repo( + mut self, + drive_repo: Arc, + ) -> Self { + self.drive_repo = Some(drive_repo); + self + } + /// Batch counterpart of `get_folder`: resolve many folder ids in ONE /// query instead of one per id. Like `get_folder` it performs no /// per-folder authorization — both current callers (ACL grant listing, @@ -539,6 +558,43 @@ impl FolderUseCase for FolderService { // TODO: full descendant-cycle check (moving a folder into one of its own descendants) } + // D5 `forbid_cross_drive_move`: refuse when src and dst sit in + // different drives and the source drive's policy is on. + // Skipped for parent_id=None (root namespace, same-drive + // semantics) and when drive_repo isn't wired (stubs/tests) — + // same shape as `move_file_with_perms`. + if let Some(drive_repo) = &self.drive_repo + && let Some(parent_id) = &dto.parent_id + { + let src_folder_uuid = + Uuid::parse_str(id).map_err(|_| DomainError::not_found("Folder", id))?; + let dst_folder_uuid = Uuid::parse_str(parent_id) + .map_err(|_| DomainError::not_found("Folder", parent_id.as_str()))?; + let (src_drive_id, src_policies) = drive_repo + .get_drive_id_and_policies_for_folder(src_folder_uuid) + .await + .map_err(|e| { + DomainError::internal_error("Drive", format!("source drive lookup: {e:?}")) + })?; + let dst_drive_id = drive_repo + .drive_id_for_folder(dst_folder_uuid) + .await + .map_err(|e| { + DomainError::internal_error("Drive", format!("destination drive lookup: {e:?}")) + })?; + if src_drive_id != dst_drive_id { + src_policies.refuse_cross_drive_move( + crate::domain::entities::drive::CrossDriveMoveGateContext { + caller_id, + resource_type: "folder", + resource_id: src_folder_uuid, + src_drive_id, + dst_drive_id, + }, + )?; + } + } + let parent_ref = dto.parent_id.as_deref(); let folder = self .folder_storage diff --git a/src/application/services/share_service.rs b/src/application/services/share_service.rs index 19f73e90..9cf68bd0 100644 --- a/src/application/services/share_service.rs +++ b/src/application/services/share_service.rs @@ -247,9 +247,9 @@ impl ShareUseCase for ShareService { // disable anonymous-link creation on every resource in their // drive without per-resource intervention. Lookup is one JOIN // (`get_policies_for_file` / `_for_folder` — single round-trip); - // a denial returns `OperationNotSupported` with an audit log - // mirroring the per-drive membership refusal shape used in - // `drive_management_service::refuse_if_personal`. + // the decision + audit + canonical error live on + // `DrivePolicies::refuse_public_links` so every public-link entry + // point (future NC OCS share, etc.) refuses with the same shape. let item_uuid = Uuid::parse_str(&dto.item_id) .map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?; let policies = match item_type { @@ -261,21 +261,15 @@ impl ShareUseCase for ShareService { } } .map_err(|e| ShareServiceError::Repository(e.to_string()))?; - if policies.forbid_public_links { - tracing::info!( - target: "audit", - event = "share.rejected", - reason = "forbid_public_links", - caller_id = %user_id, - item_id = %dto.item_id, - item_type = %dto.item_type, - "👮🏻‍♂️ public-link creation refused: drive policy forbid_public_links", - ); - return Err(DomainError::operation_not_supported( - "Share", - "This drive does not allow public links.", - )); - } + let item_type_str: &'static str = match item_type { + ShareItemType::File => "file", + ShareItemType::Folder => "folder", + }; + policies.refuse_public_links(crate::domain::entities::drive::PublicLinkGateContext { + caller_id: user_id, + item_type: item_type_str, + item_id: item_uuid, + })?; let password_hash = match dto.password { Some(p) => Some(self.hash_password_async(&p).await?), diff --git a/src/common/di.rs b/src/common/di.rs index 48197b21..3a116008 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -523,14 +523,21 @@ impl AppServiceFactory { >, ) -> ApplicationServices { // Main services - let folder_service = Arc::new(FolderService::new( - repos.folder_repository.clone(), - authz.clone(), - // Same dispatcher TrashService uses, so the cascade hook in - // `delete_folder_with_perms` fans out to the same handlers - // (thumbnails, metadata, …) as a single-file delete. - core.file_lifecycle.clone(), - )); + let folder_service = Arc::new( + FolderService::new( + repos.folder_repository.clone(), + authz.clone(), + // Same dispatcher TrashService uses, so the cascade hook in + // `delete_folder_with_perms` fans out to the same handlers + // (thumbnails, metadata, …) as a single-file delete. + core.file_lifecycle.clone(), + ) + // D5 cross-drive move gate reads policies via the same + // drive repo every other policy uses. Wired here so + // `move_folder_with_perms` can enforce + // `forbid_cross_drive_move` without a separate construction path. + .with_drive_repo(drive_repo.clone()), + ); // Built before the upload/management services so the plugin lifecycle // bridge (which looks file metadata up by id) can be wired into the @@ -606,7 +613,12 @@ impl AppServiceFactory { Some(core.file_content_cache.clone()), authz.clone(), ) - .with_file_lifecycle_hook(file_lifecycle.clone()); + .with_file_lifecycle_hook(file_lifecycle.clone()) + // D5 cross-drive move gate reads policies via the same + // drive repo every other policy uses. Wired here so + // `move_file_with_perms` can enforce `forbid_cross_drive_move` + // without a separate construction path. + .with_drive_repo(drive_repo.clone()); if let Some(hook) = resource_access_hook.clone() { svc = svc.with_resource_access_hook(hook); } @@ -1537,6 +1549,11 @@ impl AppServiceFactory { drive_repo.clone(), authorization.clone(), subject_group_repo.clone(), + Arc::new( + crate::infrastructure::repositories::pg::UserPgRepository::new( + pool.clone(), + ), + ), ), ), subject_group_service: Some(Arc::new( diff --git a/src/domain/entities/drive.rs b/src/domain/entities/drive.rs index d9afde6d..685804c0 100644 --- a/src/domain/entities/drive.rs +++ b/src/domain/entities/drive.rs @@ -43,6 +43,9 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; +use crate::common::errors::DomainError; +use crate::domain::services::authorization::Subject; + /// Drive kind discriminant. Mirrors the `storage.drives.kind` CHECK /// constraint values. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -168,6 +171,16 @@ pub struct DrivePolicies { /// Blocks MOVE when `src.drive_id != dst.drive_id`. Enforced at the /// move endpoints. Lands paired with D6's cross-drive move work. pub forbid_cross_drive_move: bool, + /// Locks the Owner-role membership set: no owner can be added, + /// removed, or demoted by another owner — only OxiCloud admin can + /// change the Owner roster. Editor / Viewer mutations by remaining + /// owners are unaffected. Personal drives are already + /// single-owner-immutable via `refuse_if_personal`, so this policy + /// only adds value on shared drives. Enforced at + /// `DriveManagementService::set_member_role` (refuses Owner role + /// writes) and `::remove_member` (refuses Owner removals) when the + /// caller is non-admin. + pub forbid_owner_role_change: bool, } impl DrivePolicies { @@ -179,4 +192,278 @@ impl DrivePolicies { pub fn from_value(value: &serde_json::Value) -> Self { serde_json::from_value(value.clone()).unwrap_or_default() } + + /// D5 `forbid_public_links` gate, used by every entry point that + /// mints an anonymous token-share on a resource in this drive + /// (`share_service::create_shared_link` today; future protocol + /// surfaces — e.g. NextCloud OCS share — must call this too). The + /// gate owns the decision + audit + canonical error so the + /// rejection shape stays in lockstep across surfaces. See + /// `docs/plan/drive.md` §8. + /// + /// Returns `Ok(())` when the policy is off; emits a + /// `share.rejected` audit line and returns + /// `OperationNotSupported` when on. + pub fn refuse_public_links(&self, ctx: PublicLinkGateContext) -> Result<(), DomainError> { + if !self.forbid_public_links { + return Ok(()); + } + tracing::info!( + target: "audit", + event = "share.rejected", + reason = "forbid_public_links", + caller_id = %ctx.caller_id, + item_type = ctx.item_type, + item_id = %ctx.item_id, + "👮🏻‍♂️ public-link creation refused: forbid_public_links", + ); + Err(DomainError::operation_not_supported( + "Share", + "This drive does not allow public links.", + )) + } + + /// D5 `forbid_sharing` gate: refuses **per-resource** grants on + /// resources in this drive when the policy is on. Drive-level + /// membership stays unaffected — otherwise a drive that disables + /// sharing would also become uneditable except by the original + /// owner. The semantic the plan §8 commits to is "no fine-grained + /// sharing of individual files; access happens through drive + /// membership only." + /// + /// Enforced at `grant_handler::create_grant` for File / Folder + /// resources. The Drive-resource branch of `/api/grants` and the + /// `/api/drives/{id}/members` routes deliberately don't call this + /// gate. + /// + /// Returns `Ok(())` when the policy is off; emits a + /// `grant.rejected` audit line and returns `OperationNotSupported` + /// when on. + pub fn refuse_sharing(&self, ctx: SharingGateContext) -> Result<(), DomainError> { + if !self.forbid_sharing { + return Ok(()); + } + tracing::info!( + target: "audit", + event = "grant.rejected", + reason = "forbid_sharing", + caller_id = %ctx.caller_id, + resource_type = ctx.resource_type, + resource_id = %ctx.resource_id, + "👮🏻‍♂️ per-resource grant refused: forbid_sharing", + ); + Err(DomainError::operation_not_supported( + "Grant", + "This drive does not allow per-resource sharing.", + )) + } + + /// D5 `forbid_owner_role_change` gate: refuses Owner-role mutations + /// (adding a new Owner, demoting an existing Owner, or removing + /// one) when the caller isn't OxiCloud admin and the policy is on. + /// Membership of non-Owner roles is unaffected. + /// + /// Enforced at `DriveManagementService::set_member_role` (refuses + /// Owner role writes) and `::remove_member` (refuses removing an + /// Owner subject). Skipped when `caller_is_admin = true` — the + /// policy exists to constrain owners, not the tenant operator. + /// Personal drives never reach this gate because + /// `refuse_if_personal` rejects every member mutation upstream. + /// + /// Returns `Ok(())` when the policy is off or the caller is admin; + /// emits a `drive_membership.rejected` audit line and returns + /// `OperationNotSupported` otherwise. + pub fn refuse_owner_role_change( + &self, + ctx: OwnerRoleChangeGateContext, + ) -> Result<(), DomainError> { + if !self.forbid_owner_role_change { + return Ok(()); + } + if ctx.caller_is_admin { + return Ok(()); + } + tracing::info!( + target: "audit", + event = "drive_membership.rejected", + reason = "forbid_owner_role_change", + operation = ctx.operation, + caller_id = %ctx.caller_id, + drive_id = %ctx.drive_id, + subject_type = ctx.subject_type, + subject_id = %ctx.subject_id, + "👮🏻‍♂️ owner-role mutation refused: forbid_owner_role_change", + ); + Err(DomainError::operation_not_supported( + "Drive", + "This drive's Owner membership is locked — only OxiCloud admin can change owners.", + )) + } + + /// D5 `forbid_cross_drive_move` gate: refuses MOVE when + /// `src.drive_id != dst.drive_id`. The policy lives on the SOURCE + /// drive — its owner decides whether content can leave. Targets' + /// owners already gate inbound moves via the `Create` permission + /// on the destination folder, so a symmetric check would be + /// redundant. + /// + /// Enforced at `file_management_service::move_file_with_perms` + /// and `folder_service::move_folder_with_perms`. The handler + /// doesn't see this gate — it lives in the service layer per + /// the AuthZ architecture rule in CLAUDE.md. + /// + /// Returns `Ok(())` when the policy is off; emits a + /// `move.rejected` audit line and returns `OperationNotSupported` + /// when on. + pub fn refuse_cross_drive_move( + &self, + ctx: CrossDriveMoveGateContext, + ) -> Result<(), DomainError> { + if !self.forbid_cross_drive_move { + return Ok(()); + } + tracing::info!( + target: "audit", + event = "move.rejected", + reason = "forbid_cross_drive_move", + caller_id = %ctx.caller_id, + resource_type = ctx.resource_type, + resource_id = %ctx.resource_id, + src_drive_id = %ctx.src_drive_id, + dst_drive_id = %ctx.dst_drive_id, + "👮🏻‍♂️ cross-drive move refused: forbid_cross_drive_move", + ); + Err(DomainError::operation_not_supported( + "Move", + "This drive does not allow moving content out to another drive.", + )) + } + + /// D5 `forbid_external_sharing` gate, shared by every entry point + /// that creates a grant on a resource in this drive + /// (`grant_handler::create_grant`, + /// `DriveManagementService::set_member_role`). Each caller + /// resolves `is_external` from whichever source naturally fits + /// (the just-created `User` entity in the email path, a + /// `get_user_flags` probe in the user-by-id path); the gate + /// itself owns the decision + audit + canonical error so the + /// shape stays in lockstep across surfaces. See `docs/plan/drive.md` §8. + /// + /// Returns `Ok(())` when the subject is allowed (policy off, subject + /// is not a User, or the User is not external). Returns + /// `OperationNotSupported` after emitting a `grant.rejected` audit + /// line otherwise. + pub fn refuse_external_sharing( + &self, + subject: Subject, + is_external: bool, + ctx: ExternalSharingGateContext, + ) -> Result<(), DomainError> { + if !self.forbid_external_sharing { + return Ok(()); + } + let Subject::User(uid) = subject else { + return Ok(()); + }; + if !is_external { + return Ok(()); + } + tracing::info!( + target: "audit", + event = "grant.rejected", + reason = "forbid_external_sharing", + stage = ctx.stage, + caller_id = %ctx.caller_id, + subject_id = %uid, + drive_id = ?ctx.drive_id, + resource_type = ?ctx.resource_type, + resource_id = ?ctx.resource_id, + "👮🏻‍♂️ grant refused: forbid_external_sharing", + ); + Err(DomainError::operation_not_supported( + "Grant", + "This drive does not allow external sharing.", + )) + } +} + +/// Audit / identity context for [`DrivePolicies::refuse_owner_role_change`]. +/// +/// Carries the subject (the user/group whose Owner status is being +/// added, removed, or demoted) and the calling operation tag +/// (`"set_member_role"` or `"remove_member"`) so the audit log +/// pinpoints exactly which mutation the policy refused. +#[derive(Debug, Clone, Copy)] +pub struct OwnerRoleChangeGateContext { + pub caller_id: Uuid, + pub caller_is_admin: bool, + pub drive_id: Uuid, + pub operation: &'static str, + pub subject_type: &'static str, + pub subject_id: Uuid, +} + +/// Audit / identity context for [`DrivePolicies::refuse_cross_drive_move`]. +/// +/// Carries the source and destination drive ids so the audit log +/// captures exactly which boundary the refused move would cross — +/// useful when investigating whether someone is probing the gate or +/// genuinely trying to organize content. +#[derive(Debug, Clone, Copy)] +pub struct CrossDriveMoveGateContext { + pub caller_id: Uuid, + /// `"file"` or `"folder"`. + pub resource_type: &'static str, + pub resource_id: Uuid, + pub src_drive_id: Uuid, + pub dst_drive_id: Uuid, +} + +/// Audit / identity context for [`DrivePolicies::refuse_sharing`]. +/// +/// Only File / Folder resources reach this gate — the per-resource +/// grant surface. Drive-resource grants go through +/// `set_member_role` and aren't subject to `forbid_sharing`. +#[derive(Debug, Clone, Copy)] +pub struct SharingGateContext { + pub caller_id: Uuid, + /// `"file"` or `"folder"`. + pub resource_type: &'static str, + pub resource_id: Uuid, +} + +/// Audit / identity context for [`DrivePolicies::refuse_public_links`]. +/// +/// Single callsite today (`share_service::create_shared_link`), but the +/// struct is the explicit contract so future surfaces (NextCloud OCS +/// share, WebDAV public-link sigil, …) land with the same shape. +#[derive(Debug, Clone, Copy)] +pub struct PublicLinkGateContext { + pub caller_id: Uuid, + /// `"file"` or `"folder"` — the share target's resource kind. + pub item_type: &'static str, + pub item_id: Uuid, +} + +/// Audit / identity context for [`DrivePolicies::refuse_external_sharing`]. +/// +/// Two callsites with different identifiers naturally fill this in: +/// - `grant_handler` (File/Folder branch): `drive_id = None`, +/// `resource_type` + `resource_id` set +/// - `DriveManagementService::set_member_role`: `drive_id` set, +/// `resource_type` + `resource_id = None` +/// +/// All three appear in the audit log so a single grep on +/// `grant.rejected reason=forbid_external_sharing` surfaces every +/// refusal regardless of entry point. +#[derive(Debug, Clone, Copy)] +pub struct ExternalSharingGateContext { + pub caller_id: Uuid, + /// Distinguishes the call site for log aggregators. Known values + /// today: `"late_user"` (grant_handler), `"drive_member"` + /// (set_member_role). New entry points pick a fresh string. + pub stage: &'static str, + pub drive_id: Option, + pub resource_type: Option<&'static str>, + pub resource_id: Option, } diff --git a/src/domain/repositories/drive_repository.rs b/src/domain/repositories/drive_repository.rs index 24eab0cd..532925ee 100644 --- a/src/domain/repositories/drive_repository.rs +++ b/src/domain/repositories/drive_repository.rs @@ -226,6 +226,28 @@ pub trait DriveRepository: Send + Sync + 'static { folder_id: Uuid, ) -> Result; + /// Resolve a file's owning drive id + its drive's policies in one + /// round-trip. Used by D5 `forbid_cross_drive_move` enforcement — + /// the move-file service needs both pieces (drive id to compare + /// against the destination, policies to gate). Returns `NotFound` + /// when the file row or its drive_id doesn't resolve. + async fn get_drive_id_and_policies_for_file( + &self, + file_id: Uuid, + ) -> Result<(Uuid, crate::domain::entities::drive::DrivePolicies), DriveRepositoryError>; + + /// Same as [`Self::get_drive_id_and_policies_for_file`] for folders. + async fn get_drive_id_and_policies_for_folder( + &self, + folder_id: Uuid, + ) -> Result<(Uuid, crate::domain::entities::drive::DrivePolicies), DriveRepositoryError>; + + /// Resolve just the drive id of a folder — fast PK probe used by + /// the cross-drive-move gate to identify the move destination + /// (where we don't need policies, just the discriminator). Returns + /// `NotFound` when the folder row doesn't exist. + async fn drive_id_for_folder(&self, folder_id: Uuid) -> Result; + /// Merge the given partial policy bag into the drive's existing /// `policies` JSONB, returning the updated bag. JSONB-level merge /// preserves unknown keys already present on disk (the column stays diff --git a/src/infrastructure/repositories/pg/drive_pg_repository.rs b/src/infrastructure/repositories/pg/drive_pg_repository.rs index e9d0bf72..855df271 100644 --- a/src/infrastructure/repositories/pg/drive_pg_repository.rs +++ b/src/infrastructure/repositories/pg/drive_pg_repository.rs @@ -578,6 +578,61 @@ impl DriveRepository for DrivePgRepository { )) } + async fn get_drive_id_and_policies_for_file( + &self, + file_id: Uuid, + ) -> Result<(Uuid, crate::domain::entities::drive::DrivePolicies), DriveRepositoryError> { + let row: Option<(Uuid, serde_json::Value)> = sqlx::query_as( + "SELECT d.id, d.policies \ + FROM storage.drives d \ + JOIN storage.files f ON f.drive_id = d.id \ + WHERE f.id = $1", + ) + .bind(file_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| Self::map_sqlx_err("get_drive_id_and_policies_for_file", e))?; + let (drive_id, raw) = + row.ok_or_else(|| DriveRepositoryError::NotFound(file_id.to_string()))?; + Ok(( + drive_id, + crate::domain::entities::drive::DrivePolicies::from_value(&raw), + )) + } + + async fn get_drive_id_and_policies_for_folder( + &self, + folder_id: Uuid, + ) -> Result<(Uuid, crate::domain::entities::drive::DrivePolicies), DriveRepositoryError> { + let row: Option<(Uuid, serde_json::Value)> = sqlx::query_as( + "SELECT d.id, d.policies \ + FROM storage.drives d \ + JOIN storage.folders fo ON fo.drive_id = d.id \ + WHERE fo.id = $1", + ) + .bind(folder_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| Self::map_sqlx_err("get_drive_id_and_policies_for_folder", e))?; + let (drive_id, raw) = + row.ok_or_else(|| DriveRepositoryError::NotFound(folder_id.to_string()))?; + Ok(( + drive_id, + crate::domain::entities::drive::DrivePolicies::from_value(&raw), + )) + } + + async fn drive_id_for_folder(&self, folder_id: Uuid) -> Result { + let row: Option<(Uuid,)> = + sqlx::query_as("SELECT drive_id FROM storage.folders WHERE id = $1") + .bind(folder_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| Self::map_sqlx_err("drive_id_for_folder", e))?; + row.map(|(id,)| id) + .ok_or_else(|| DriveRepositoryError::NotFound(folder_id.to_string())) + } + async fn update_policies( &self, drive_id: Uuid, diff --git a/src/interfaces/api/handlers/drive_handler.rs b/src/interfaces/api/handlers/drive_handler.rs index 487b0b94..2ceb5ce7 100644 --- a/src/interfaces/api/handlers/drive_handler.rs +++ b/src/interfaces/api/handlers/drive_handler.rs @@ -414,18 +414,29 @@ pub struct UpdateDrivePoliciesDto { pub forbid_public_links: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub forbid_cross_drive_move: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub forbid_owner_role_change: Option, } -/// `PATCH /api/drives/{id}/policies` — Owner-only policy update (D5). +/// `PATCH /api/drives/{id}/policies` — **OxiCloud-admin only** policy +/// update (D5). /// -/// Caller must hold `Manage` on the drive (Owner role bundle). Personal -/// drives are eligible too — a user can disable `forbid_public_links` -/// on their own Personal drive without the membership API. Partial -/// merge into the JSONB `policies` column; the post-merge typed view -/// is returned. +/// Policies were originally owner-mutable, but that made them +/// self-policing soft caps — an owner could disable +/// `forbid_external_sharing`, create the grant, and re-enable. For +/// compliance-grade enforcement, mutation is restricted to the +/// tenant operator (admin role), mirroring the same carve-out that +/// guards `drives.quota_bytes` and `users.storage_quota_bytes` (§7). /// -/// Audit: emits `drive.policy_changed` with every key's post-merge -/// value (steady-state observability). +/// Non-admin callers receive `404` (anti-enumeration — same response +/// as "drive does not exist", so a probe can't tell apart "no such +/// drive" from "policies are admin-managed"). +/// +/// Partial merge into the JSONB `policies` column; the post-merge +/// typed view is returned. +/// +/// Audit: emits `drive.policy_changed` with `by = ` +/// and every key's post-merge value (steady-state observability). #[utoipa::path( patch, path = "/api/drives/{id}/policies", @@ -433,7 +444,7 @@ pub struct UpdateDrivePoliciesDto { request_body = UpdateDrivePoliciesDto, responses( (status = 200, description = "Policies merged"), - (status = 404, description = "Drive not found or caller lacks Manage"), + (status = 404, description = "Drive not found OR caller is not OxiCloud admin"), ), security(("bearerAuth" = [])), tag = "drives" @@ -444,6 +455,20 @@ pub async fn update_drive_policies( Path(drive_id): Path, axum::Json(dto): axum::Json, ) -> impl IntoResponse { + // OxiCloud-admin only. Anti-enumeration: return the same 404 a + // non-existent drive would carry, never 403, so the policy + // existence isn't probable by error shape. + if auth_user.role != "admin" { + tracing::info!( + target: "audit", + event = "drive.policy_change_rejected", + reason = "not_admin", + caller_id = %auth_user.id, + drive_id = %drive_id, + "👮🏻‍♂️ policy mutation refused: caller is not OxiCloud admin", + ); + return AppError::not_found(format!("Drive {drive_id} not found")).into_response(); + } // Translate the Option-per-field DTO into a serde_json partial that // only carries the supplied keys, so the JSONB merge in // `update_policies` skips fields the caller didn't touch. Building a @@ -463,6 +488,12 @@ pub async fn update_drive_policies( if let Some(v) = dto.forbid_cross_drive_move { partial_obj.insert("forbid_cross_drive_move".into(), serde_json::Value::Bool(v)); } + if let Some(v) = dto.forbid_owner_role_change { + partial_obj.insert( + "forbid_owner_role_change".into(), + serde_json::Value::Bool(v), + ); + } let partial_value = serde_json::Value::Object(partial_obj); let partial: crate::domain::entities::drive::DrivePolicies = match serde_json::from_value(partial_value) { @@ -474,7 +505,7 @@ pub async fn update_drive_policies( match state .drive_management_service - .update_policies(auth_user.id, false, drive_id, partial) + .update_policies(auth_user.id, drive_id, partial) .await { Ok(merged) => (StatusCode::OK, axum::Json(merged)).into_response(), diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index 714ea58a..bf6d190c 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -100,6 +100,84 @@ pub async fn create_grant( return AppError::from(e).into_response(); } + // D5: load the resource's owning drive policies in one round-trip + // and gate `forbid_external_sharing` (early refusal for email + // subjects below + late refusal for resolved external users further + // down). `forbid_sharing` (the next D5 policy) will read the same + // fetched bag — see `docs/plan/drive.md` §8. + let drive_policies = match resource { + Resource::File(id) => state.drive_repo.get_policies_for_file(id).await, + Resource::Folder(id) => state.drive_repo.get_policies_for_folder(id).await, + Resource::Drive(id) => state + .drive_repo + .get_by_id(id) + .await + .map(|d| d.drive.typed_policies()), + }; + let drive_policies = match drive_policies { + Ok(p) => p, + Err(e) => { + return AppError::internal_error(format!("drive policy lookup: {e:?}")).into_response(); + } + }; + + // D5 — `forbid_sharing`: refuses per-resource grants on + // File / Folder when the drive's policy is on. Drive-resource + // grants intentionally bypass this gate — they're drive + // membership, not per-resource sharing (§8 semantic carve-out). + if !matches!(resource, Resource::Drive(_)) + && let Err(e) = + drive_policies.refuse_sharing(crate::domain::entities::drive::SharingGateContext { + caller_id, + resource_type: resource.type_str(), + resource_id: resource.id(), + }) + { + return AppError::from(e).into_response(); + } + + // D5 — `forbid_public_links`: Token subjects on `POST /api/grants` + // create exactly the anonymous-link grant that this policy is meant + // to block — the canonical surface is `share_service::create_shared_link` + // but the same kind of grant can be minted here by passing + // `subject.type=token`. Use the same shared gate so the refusal + // shape stays in lockstep with the share-handler path. + if matches!(&dto.subject, SubjectInputDto::Token { .. }) + && let Err(e) = drive_policies.refuse_public_links( + crate::domain::entities::drive::PublicLinkGateContext { + caller_id, + item_type: resource.type_str(), + item_id: resource.id(), + }, + ) + { + return AppError::from(e).into_response(); + } + + // D5 — `forbid_external_sharing` (early): when the caller is sharing + // by email, refuse BEFORE `resolve_or_create_recipient` runs so the + // policy never side-effects a fresh external-user row. Existing + // external users are caught by the late check below. + if drive_policies.forbid_external_sharing + && matches!(&dto.subject, SubjectInputDto::Email { .. }) + { + tracing::info!( + target: "audit", + event = "grant.rejected", + reason = "forbid_external_sharing", + stage = "early_email", + caller_id = %caller_id, + resource_type = resource.type_str(), + resource_id = %resource.id(), + "👮🏻‍♂️ email-grant refused: drive policy forbid_external_sharing", + ); + return AppError::from(DomainError::operation_not_supported( + "Grant", + "This drive does not allow external sharing.", + )) + .into_response(); + } + // Resolve the subject. For the email variant this lazily provisions // an external user (or reuses an existing match) and remembers the // resolved User so the invitation email can be sent after the grant @@ -153,6 +231,53 @@ pub async fn create_grant( } }; + // D5 — `forbid_external_sharing` (late) for File/Folder ONLY: + // catches the case where the subject resolved to a pre-existing + // external user. The early check above only fires for email-input; + // this one closes the user-by-id loophole. + // + // Drive resources are deliberately skipped here — they route through + // `set_member_role` below, which runs the SAME gate + // (`DrivePolicies::refuse_external_sharing`) at the service layer. + // That one service-layer check also covers `POST /api/drives/{id}/members` + // and its PATCH sibling, where no grant_handler runs. Checking + // again here for Drive would duplicate the user-flags lookup. + // + // `invite_recipient` carries the User entity when we just came from + // the email path — read its `is_external` flag instead of a + // redundant lookup; otherwise probe via `get_user_flags`. + if drive_policies.forbid_external_sharing + && !matches!(resource, Resource::Drive(_)) + && let Subject::User(uid) = subject + { + let is_external = if let Some(user) = invite_recipient.as_ref() { + user.is_external() + } else if let Some(auth_svc) = state.auth_service.as_ref() { + match auth_svc.auth_application_service.get_user_flags(uid).await { + Ok(flags) => flags.is_external, + Err(e) => { + return AppError::internal_error(format!("user flags lookup: {e:?}")) + .into_response(); + } + } + } else { + false + }; + if let Err(e) = drive_policies.refuse_external_sharing( + subject, + is_external, + crate::domain::entities::drive::ExternalSharingGateContext { + caller_id, + stage: "late_user", + drive_id: None, + resource_type: Some(resource.type_str()), + resource_id: Some(resource.id()), + }, + ) { + return AppError::from(e).into_response(); + } + } + // Single role row in `storage.role_grants`. `ON CONFLICT UPDATE` in // the engine makes repeated POSTs with the same (subject, resource) // a role refresh, matching the PATCH-style semantics callers expect. diff --git a/tests/api/drive_policies.hurl b/tests/api/drive_policies.hurl index db7d30cb..4691905f 100644 --- a/tests/api/drive_policies.hurl +++ b/tests/api/drive_policies.hurl @@ -10,8 +10,9 @@ # default-false. The first key shipped is `forbid_public_links`, # which blocks anonymous token-share creation on every resource # in the drive. Enforced at `share_service::create_shared_link`; -# mutated by `PATCH /api/drives/{id}/policies` (Owner-only, -# per the §4 role bundle). +# mutated by `PATCH /api/drives/{id}/policies` (OxiCloud-admin +# only — the carve-out closes the self-policing-soft-cap hole +# where an owner could disable a policy, share, and re-enable). # # Cases: # 1. Baseline — policy off → POST /api/shares succeeds (201). @@ -67,6 +68,32 @@ owner_token: jsonpath "$.access_token" owner_user_id: jsonpath "$.user.id" +# Provision `dp_intruder` — a second internal user used only to +# exercise the negative side of the policy-PATCH authz gate. +# A separate user (not bob, who's external) keeps internal/external +# semantics out of the assertion. +POST {{base_url}}/api/admin/users +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "username": "dp_intruder", + "password": "DpIntruderPwd1!", + "email": "dp_intruder@example.com", + "role": "user" +} + +HTTP 201 + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "dp_intruder", "password": "DpIntruderPwd1!" } + +HTTP 200 +[Captures] +intruder_token: jsonpath "$.access_token" +intruder_user_id: jsonpath "$.user.id" + + # ───────────────────────────────────────────────────────────── # Step 3 — Find the user's default Personal drive + root. # ───────────────────────────────────────────────────────────── @@ -129,7 +156,7 @@ HTTP 204 # PATCH returns the merged bag. # ───────────────────────────────────────────────────────────── PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies -Authorization: Bearer {{owner_token}} +Authorization: Bearer {{admin_token}} Content-Type: application/json { "forbid_public_links": true @@ -143,6 +170,47 @@ jsonpath "$.forbid_external_sharing" == false jsonpath "$.forbid_cross_drive_move" == false +# Authz gate — negative case. The PATCH is OxiCloud-admin only. +# Anything below admin role gets a 404 (anti-enum — same shape as +# "drive does not exist", so a probe can't tell apart "no such +# drive" from "policies are admin-managed"). +# +# The most important assertion: even the drive's OWNER can no +# longer mutate policies. Before this change the policies were +# owner-mutable, which made them self-policing soft caps (an +# owner could disable forbid_external_sharing, share, re-enable). +# Mirroring `drives.quota_bytes` and `users.storage_quota_bytes` +# admin-only carve-outs. +PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "forbid_public_links": false +} + +HTTP 404 + +# And a non-member also gets 404 (same anti-enum shape). +PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies +Authorization: Bearer {{intruder_token}} +Content-Type: application/json +{ + "forbid_public_links": false +} + +HTTP 404 + + +# Belt-and-braces: the policy that admin set is unchanged +# (no partial write happened under the failed authz). +GET {{base_url}}/api/drives +Authorization: Bearer {{owner_token}} + +HTTP 200 +[Asserts] +jsonpath "$[0].policies.forbid_public_links" == true + + # ───────────────────────────────────────────────────────────── # Step 7 — Case 3: policy on → POST /api/shares refused (405). # DomainError::operation_not_supported maps to HTTP 405 @@ -159,6 +227,25 @@ Content-Type: application/json HTTP 405 +# Closing the bypass: `POST /api/grants` with `subject.type=token` +# would otherwise mint an anonymous-link grant — same effect as a +# token share, different surface. `grant_handler` now routes +# Token subjects through `DrivePolicies::refuse_public_links`, +# so the policy gates both surfaces. The token UUID is invented +# (no validation up to this point) — the refusal must fire from +# the policy check, not from a missing-token lookup. +POST {{base_url}}/api/grants +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "subject": { "type": "token", "id": "00000000-0000-0000-0000-000000000bad" }, + "resource": { "type": "file", "id": "{{file_id}}" }, + "role": "viewer" +} + +HTTP 405 + + # Confirm no share row was created — the listing on this file # is empty. GET {{base_url}}/api/shares?item_id={{file_id}}&item_type=file @@ -176,7 +263,7 @@ jsonpath "$" count == 0 # but the round-trip exercises the merge path). # ───────────────────────────────────────────────────────────── PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies -Authorization: Bearer {{owner_token}} +Authorization: Bearer {{admin_token}} Content-Type: application/json { "forbid_public_links": false @@ -206,9 +293,556 @@ HTTP 204 # ───────────────────────────────────────────────────────────── -# Step 9 — Cleanup. Admin deletes the test user; cascade reaps -# the default Personal drive, root folder, and file. +# Step 9 — `forbid_external_sharing` baseline + early refuse. +# Owner shares a folder by email — succeeds, lazily +# provisions the external user. Then toggle the policy +# on and try a fresh email — refused BEFORE the +# external user is created (early gate prevents the +# side-effect leak). # ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ "name": "dp-ext-share", "parent_id": "{{personal_root_id}}" } + +HTTP 201 +[Captures] +ext_folder_id: jsonpath "$.id" + + +# Baseline: email grant succeeds with policy off. Captures the +# resolved bob_user_id so the LATE gate can be exercised below. +POST {{base_url}}/api/grants +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "subject": { "type": "email", "email": "dp_bob@externalcompany.com" }, + "resource": { "type": "folder", "id": "{{ext_folder_id}}" }, + "role": "viewer" +} + +HTTP 201 +[Captures] +bob_user_id: jsonpath "$.grants[0].subject.id" + + +# Toggle `forbid_external_sharing` on. +PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "forbid_external_sharing": true +} + +HTTP 200 +[Asserts] +jsonpath "$.forbid_external_sharing" == true +jsonpath "$.forbid_public_links" == false + + +# Early gate: email subject refused before any user row is created. +# The grant.rejected audit line fires with reason=forbid_external_sharing +# stage=early_email. +POST {{base_url}}/api/grants +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "subject": { "type": "email", "email": "dp_alice@externalcompany.com" }, + "resource": { "type": "folder", "id": "{{ext_folder_id}}" }, + "role": "viewer" +} + +HTTP 405 + + +# ───────────────────────────────────────────────────────────── +# Step 10 — `forbid_external_sharing` late refuse: even passing +# an existing external user by id is refused (closes +# the user-by-id loophole). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/grants +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{bob_user_id}}" }, + "resource": { "type": "folder", "id": "{{ext_folder_id}}" }, + "role": "viewer" +} + +HTTP 405 + + +# Flip the policy back off — same subject now succeeds, proving +# the refusal was policy-driven and not a permanent block. +PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "forbid_external_sharing": false +} + +HTTP 200 +[Asserts] +jsonpath "$.forbid_external_sharing" == false + + +POST {{base_url}}/api/grants +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{bob_user_id}}" }, + "resource": { "type": "folder", "id": "{{ext_folder_id}}" }, + "role": "viewer" +} + +HTTP 201 + +# ───────────────────────────────────────────────────────────── +# Step 10b — `forbid_sharing` on a personal drive: per-resource +# grants on resources inside the drive are refused; +# drive-level membership stays unaffected (covered by +# the shared-drive positive control in Step 11 below). +# +# This is the broadest D5 policy — toggling it on locks the drive +# to "drive membership only" sharing semantics (§8: "no fine- +# grained sharing of individual files; access happens through +# drive membership only"). +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "forbid_sharing": true +} + +HTTP 200 +[Asserts] +jsonpath "$.forbid_sharing" == true +jsonpath "$.forbid_external_sharing" == false +jsonpath "$.forbid_public_links" == false + + +# File-grant refused. `grant.rejected reason=forbid_sharing`. +POST {{base_url}}/api/grants +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{bob_user_id}}" }, + "resource": { "type": "file", "id": "{{file_id}}" }, + "role": "viewer" +} + +HTTP 405 + + +# Folder-grant refused with the same shape. +POST {{base_url}}/api/grants +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{bob_user_id}}" }, + "resource": { "type": "folder", "id": "{{ext_folder_id}}" }, + "role": "viewer" +} + +HTTP 405 + + +# Flip the policy off — the same folder-grant now succeeds, proving +# refusal was policy-driven. +PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "forbid_sharing": false +} + +HTTP 200 + +POST {{base_url}}/api/grants +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{bob_user_id}}" }, + "resource": { "type": "folder", "id": "{{ext_folder_id}}" }, + "role": "viewer" +} + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 11 — `forbid_external_sharing` on a SHARED drive, via +# `POST /api/drives/{id}/members`. +# +# Coverage gap closed: the earlier steps exercise the +# grant_handler path (File/Folder grants in dp_owner's personal +# drive). The drive-membership route bypasses grant_handler and +# calls `DriveManagementService::set_member_role` directly — +# `refuse_if_forbid_external_sharing` enforces the same gate at +# the service layer (`docs/plan/drive.md` §8). This step proves +# the route is gated. +# +# Personal drives refuse `add_member` regardless of policy (§2), +# so a shared drive is required. Admin provisions one with +# dp_owner as direct user-Owner. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/drives +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "kind": "shared", + "name": "dp-shared", + "owner": { "type": "user", "id": "{{owner_user_id}}" } +} + +HTTP 201 +[Captures] +shared_drive_id: jsonpath "$.id" +shared_root_id: jsonpath "$.root_folder_id" + + +# Toggle `forbid_external_sharing` on the SHARED drive (dp_owner +# is Owner → carries Manage in the role bundle). +PATCH {{base_url}}/api/drives/{{shared_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "forbid_external_sharing": true +} + +HTTP 200 +[Asserts] +jsonpath "$.forbid_external_sharing" == true + + +# Adding bob (existing external user from Step 9) as a Viewer +# via the drive-membership route is refused by +# `set_member_role`'s `refuse_if_forbid_external_sharing` — +# `grant.rejected reason=forbid_external_sharing stage=drive_member`. +POST {{base_url}}/api/drives/{{shared_drive_id}}/members +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{bob_user_id}}" }, + "role": "viewer" +} + +HTTP 405 + + +# Flip the policy off — same call succeeds, proving the refusal +# was policy-driven (not a permanent block) and that the gate at +# the service layer can be lifted by the drive owner. +PATCH {{base_url}}/api/drives/{{shared_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "forbid_external_sharing": false +} + +HTTP 200 + +POST {{base_url}}/api/drives/{{shared_drive_id}}/members +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{bob_user_id}}" }, + "role": "viewer" +} + +HTTP 201 + + +# Authz gate — non-Owner role on a SHARED drive still can't change +# policies. Add `dp_intruder` as Editor (bundle includes Update on +# resources in the drive but NOT Manage), then have them try to +# flip a policy → 404. Proves the PATCH endpoint requires Manage +# specifically, not just any drive role. +POST {{base_url}}/api/drives/{{shared_drive_id}}/members +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{intruder_user_id}}" }, + "role": "editor" +} + +HTTP 201 + +PATCH {{base_url}}/api/drives/{{shared_drive_id}}/policies +Authorization: Bearer {{intruder_token}} +Content-Type: application/json +{ + "forbid_external_sharing": true +} + +HTTP 404 + + +# Belt-and-braces: dp_intruder's failed PATCH didn't side-effect. +# dp_owner reads the drive's policies (canonical owner view) and +# `forbid_external_sharing` stays at the value the owner last set +# (false — flipped back two requests ago). +GET {{base_url}}/api/drives +Authorization: Bearer {{owner_token}} +[QueryStringParams] + +HTTP 200 +[Asserts] +jsonpath "$[?(@.id=='{{shared_drive_id}}')].policies.forbid_external_sharing" == false + + +# `forbid_sharing` carve-out positive control. The policy locks +# per-resource sharing but leaves drive-level membership working +# (§8 — "access happens through drive membership only"). Toggle +# it on, then add a new drive member: must succeed (201). This is +# the assertion that grant_handler skips the gate for +# `Resource::Drive(_)`. +PATCH {{base_url}}/api/drives/{{shared_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "forbid_sharing": true +} + +HTTP 200 +[Asserts] +jsonpath "$.forbid_sharing" == true + +# `dp_owner` is already Owner; bob is Viewer; dp_intruder is +# Editor. Re-grant dp_intruder Editor — UPSERT through +# `set_member_role` — under `forbid_sharing=true`. The carve-out +# means this still works. +POST {{base_url}}/api/drives/{{shared_drive_id}}/members +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{intruder_user_id}}" }, + "role": "editor" +} + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 11b — `forbid_cross_drive_move` on the SOURCE drive +# refuses moves to a different drive. dp_owner is +# Owner of both the personal and shared drives, so +# authz on both ends passes — the refusal must come +# from the policy gate, not a permission failure. +# +# The policy lives on the SOURCE drive (the one losing the +# content). It's also fetched into the service via +# `get_drive_id_and_policies_for_file`, so the same call site +# proves the lookup works end-to-end. +# +# Clean up `forbid_sharing` first — it would refuse the per- +# resource-grant-style mutations the move tests don't actually +# do, but the test should isolate one policy at a time. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "forbid_cross_drive_move": true +} + +HTTP 200 +[Asserts] +jsonpath "$.forbid_cross_drive_move" == true + + +# Attempt to move the file from dp_owner's personal drive into +# the shared drive's root folder. Both Update (file) and Create +# (folder) authz pass — dp_owner is Owner of both drives. The +# gate fires `move.rejected reason=forbid_cross_drive_move`. +PUT {{base_url}}/api/files/{{file_id}}/move +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "folder_id": "{{shared_root_id}}" +} + +HTTP 405 + + +# Confirm the file stayed put on the source drive (no partial +# move under the failed gate). +GET {{base_url}}/api/files?folder_id={{personal_root_id}} +Authorization: Bearer {{owner_token}} + +HTTP 200 +[Asserts] +jsonpath "$[?(@.id=='{{file_id}}')].folder_id" == "{{personal_root_id}}" + + +# Flip the policy off — same call now succeeds and the file +# lands in the shared drive's root. +PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "forbid_cross_drive_move": false +} + +HTTP 200 + +PUT {{base_url}}/api/files/{{file_id}}/move +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "folder_id": "{{shared_root_id}}" +} + +HTTP 200 + + +# Move the file back to dp_owner's personal drive so the shared +# drive cleanup's empty-before-delete guard passes. +PUT {{base_url}}/api/files/{{file_id}}/move +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "folder_id": "{{personal_root_id}}" +} + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 11c — `forbid_owner_role_change` locks the Owner roster +# against owner mutation. Only OxiCloud admin can +# change the Owner set when this policy is on. +# +# Fixture at this point: dp_owner is Owner on the shared drive, +# dp_intruder is Editor (from Step 11), bob is Viewer +# (re-granted earlier). Admin enables the policy; dp_owner is +# refused on every Owner-touching mutation; non-Owner mutations +# still work; admin override always succeeds. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{shared_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "forbid_owner_role_change": true +} + +HTTP 200 +[Asserts] +jsonpath "$.forbid_owner_role_change" == true + + +# dp_owner attempts to promote dp_intruder Editor → Owner. +# Refused by `refuse_if_forbid_owner_role_change` — +# `drive_membership.rejected reason=forbid_owner_role_change`. +POST {{base_url}}/api/drives/{{shared_drive_id}}/members +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{intruder_user_id}}" }, + "role": "owner" +} + +HTTP 405 + + +# dp_owner can still mutate non-Owner roles. Re-grant bob as +# Viewer (UPSERT) under the policy → 201. Proves the carve-out +# is narrow — only Owner-roster writes are gated. +POST {{base_url}}/api/drives/{{shared_drive_id}}/members +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{bob_user_id}}" }, + "role": "viewer" +} + +HTTP 201 + + +# Admin override: admin promotes dp_intruder to Owner. Same +# call shape, just admin's token — must succeed (admin is the +# tenant operator and the only one who can change the roster). +POST {{base_url}}/api/admin/drives/{{shared_drive_id}}/members +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{intruder_user_id}}" }, + "role": "owner" +} + +HTTP 201 + + +# Now dp_intruder IS an Owner. dp_owner attempts to demote them +# back to Editor — refused, even though dp_owner is also an +# Owner (the policy is roster-wide, not per-owner). +POST {{base_url}}/api/drives/{{shared_drive_id}}/members +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{intruder_user_id}}" }, + "role": "editor" +} + +HTTP 405 + + +# dp_owner attempts to remove dp_intruder entirely — refused +# (the subject IS currently Owner, so removal counts as Owner +# roster mutation). +DELETE {{base_url}}/api/drives/{{shared_drive_id}}/members/user/{{intruder_user_id}} +Authorization: Bearer {{owner_token}} + +HTTP 405 + + +# Admin override: admin removes dp_intruder. Cleans up the +# Owner roster back to {dp_owner} so the empty-before-delete +# guard below succeeds. +DELETE {{base_url}}/api/admin/drives/{{shared_drive_id}}/members/user/{{intruder_user_id}} +Authorization: Bearer {{admin_token}} + +HTTP 204 + + +# Disable the policy so the shared-drive cleanup below isn't +# distorted by lingering owner-lock state. +PATCH {{base_url}}/api/drives/{{shared_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "forbid_owner_role_change": false +} + +HTTP 200 + + +# Cleanup the shared drive: empty (no content was added) → delete +# via DELETE /api/drives/{id}. dp_owner is Owner so the call +# carries Manage; the per-drive empty-before-delete guard passes +# trivially (the drive holds only its root folder). +DELETE {{base_url}}/api/drives/{{shared_drive_id}} +Authorization: Bearer {{owner_token}} + +HTTP 204 + + +# ───────────────────────────────────────────────────────────── +# Step 12 — Final cleanup. Admin deletes bob, dp_intruder, and +# dp_owner. Each cascade reaps that user's default +# personal drive + their grant rows. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/admin/users/{{bob_user_id}} +Authorization: Bearer {{admin_token}} + +HTTP 200 + +DELETE {{base_url}}/api/admin/users/{{intruder_user_id}} +Authorization: Bearer {{admin_token}} + +HTTP 200 + DELETE {{base_url}}/api/admin/users/{{owner_user_id}} Authorization: Bearer {{admin_token}}