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