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:
Edouard Vanbelle
2026-06-26 01:48:39 +02:00
parent ddb131da8b
commit 66f2aaa250
12 changed files with 1533 additions and 72 deletions
@@ -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
+12 -18
View File
@@ -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
View File
@@ -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(
+287
View File
@@ -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,
+41 -10
View File
@@ -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.