feat(drive): add drive policie
- add policy forbid_external_sharing
- add policy forbid_sharing
- add polocy forbid_cross_drive_move
- add policy forbid_owner_role_change
This commit is contained in:
+40
-8
@@ -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 |
|
||||
|
||||
|
||||
@@ -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<SubjectGroupPgRepository>,
|
||||
/// 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<UserPgRepository>,
|
||||
}
|
||||
|
||||
impl DriveManagementService {
|
||||
@@ -46,11 +52,13 @@ impl DriveManagementService {
|
||||
drive_repo: Arc<DrivePgRepository>,
|
||||
authz: Arc<PgAclEngine>,
|
||||
group_repo: Arc<SubjectGroupPgRepository>,
|
||||
user_repo: Arc<UserPgRepository>,
|
||||
) -> 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<crate::domain::entities::drive::DrivePolicies, DomainError> {
|
||||
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<Role>,
|
||||
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
|
||||
|
||||
@@ -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<Arc<dyn ResourceAccessHook>>,
|
||||
/// 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<Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>>,
|
||||
}
|
||||
|
||||
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<dyn crate::domain::repositories::drive_repository::DriveRepository>,
|
||||
) -> 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<FileLifecycleService>,
|
||||
/// 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<Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>>,
|
||||
}
|
||||
|
||||
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<dyn crate::domain::repositories::drive_repository::DriveRepository>,
|
||||
) -> 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
|
||||
|
||||
@@ -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?),
|
||||
|
||||
+26
-9
@@ -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(
|
||||
|
||||
@@ -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<Uuid>,
|
||||
pub resource_type: Option<&'static str>,
|
||||
pub resource_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
@@ -226,6 +226,28 @@ pub trait DriveRepository: Send + Sync + 'static {
|
||||
folder_id: Uuid,
|
||||
) -> Result<crate::domain::entities::drive::DrivePolicies, DriveRepositoryError>;
|
||||
|
||||
/// 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<Uuid, DriveRepositoryError>;
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -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<Uuid, DriveRepositoryError> {
|
||||
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,
|
||||
|
||||
@@ -414,18 +414,29 @@ pub struct UpdateDrivePoliciesDto {
|
||||
pub forbid_public_links: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub forbid_cross_drive_move: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub forbid_owner_role_change: Option<bool>,
|
||||
}
|
||||
|
||||
/// `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 = <admin_user_id>`
|
||||
/// 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<Uuid>,
|
||||
axum::Json(dto): axum::Json<UpdateDrivePoliciesDto>,
|
||||
) -> 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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user