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
+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