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:
@@ -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>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user