716 lines
29 KiB
Rust
716 lines
29 KiB
Rust
//! D2 — drive membership management service.
|
|
//!
|
|
//! Translates `POST/PATCH/DELETE /api/drives/{id}/members` into role-grant
|
|
//! writes on `resource_type='drive'`, layering D2's business rules on top:
|
|
//!
|
|
//! - **Personal-drive guard** (§2): drives with `kind='personal'` are
|
|
//! single-user single-owner by invariant; any member mutation is refused
|
|
//! at the service edge with `403`. Listing a personal drive's members is
|
|
//! still allowed (returns exactly the owner row) so the UI can render the
|
|
//! same shape across drive kinds without per-kind branching.
|
|
//!
|
|
//! - **Last-owner protection** (shared drives): removing or demoting the
|
|
//! final `Role::Owner` would leave the drive unmanageable. Refused at the
|
|
//! service edge — the caller has to promote someone else to Owner first,
|
|
//! or delete the drive.
|
|
//!
|
|
//! - **Authorization**: caller must hold `Permission::Manage` on the drive
|
|
//! to mutate; `Permission::Read` to list. `AuthorizationEngine::require`
|
|
//! emits the canonical `authz.denied` audit line on rejection.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use uuid::Uuid;
|
|
|
|
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
|
use crate::common::errors::DomainError;
|
|
use crate::domain::entities::drive::DriveKind;
|
|
use crate::domain::repositories::drive_repository::{DriveRepository, DriveRepositoryError};
|
|
use crate::domain::repositories::subject_group_repository::SubjectGroupRepository;
|
|
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 {
|
|
drive_repo: Arc<DrivePgRepository>,
|
|
authz: Arc<PgAclEngine>,
|
|
/// Needed to validate that a Group owner subject is non-empty at
|
|
/// create-drive time — refusing creation with an empty group avoids
|
|
/// 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 {
|
|
pub fn new(
|
|
drive_repo: Arc<DrivePgRepository>,
|
|
authz: Arc<PgAclEngine>,
|
|
group_repo: Arc<SubjectGroupPgRepository>,
|
|
user_repo: Arc<UserPgRepository>,
|
|
) -> Self {
|
|
Self {
|
|
drive_repo,
|
|
authz,
|
|
group_repo,
|
|
user_repo,
|
|
}
|
|
}
|
|
|
|
/// `POST /api/drives` — create a shared drive owned by a group.
|
|
///
|
|
/// **AuthZ (D3a)**: OxiCloud-admin only. The plan (`drive.md §6`)
|
|
/// reads "admin or group owner triggers" — D3a starts with the
|
|
/// admin-only path; group-owner triggering can extend the gate
|
|
/// later without changing the wire shape or the service method
|
|
/// signature. `caller_is_admin` is resolved by the HTTP handler
|
|
/// from `CurrentUser.role` and passed in; the service trusts it
|
|
/// (defense-in-depth check stays at the route layer).
|
|
///
|
|
/// Audit log: `drive.created` with the drive id, the owner group,
|
|
/// and the granted_by (the admin caller).
|
|
pub async fn create_shared_drive(
|
|
&self,
|
|
caller_id: Uuid,
|
|
caller_is_admin: bool,
|
|
name: &str,
|
|
owner_subject: Subject,
|
|
quota_bytes: Option<i64>,
|
|
) -> Result<crate::domain::repositories::drive_repository::DriveWithRootName, DomainError> {
|
|
if !caller_is_admin {
|
|
tracing::info!(
|
|
target: "audit",
|
|
event = "drive_create.rejected",
|
|
reason = "not_admin",
|
|
caller_id = %caller_id,
|
|
owner_type = owner_subject.type_str(),
|
|
owner_id = %owner_subject.id(),
|
|
"👮🏻♂️ refused shared-drive create: caller is not an OxiCloud admin",
|
|
);
|
|
return Err(DomainError::access_denied(
|
|
"Drive",
|
|
"Only OxiCloud administrators can create shared drives.",
|
|
));
|
|
}
|
|
|
|
// Token subjects are share-link identities, not entities that can
|
|
// own things. Refuse at the service edge.
|
|
if matches!(owner_subject, Subject::Token(_)) {
|
|
tracing::info!(
|
|
target: "audit",
|
|
event = "drive_create.rejected",
|
|
reason = "invalid_owner_kind",
|
|
caller_id = %caller_id,
|
|
owner_type = "token",
|
|
"👮🏻♂️ refused shared-drive create: owner cannot be a Token subject",
|
|
);
|
|
return Err(DomainError::validation_error(
|
|
"Drive owner must be a user or a group, not a token.",
|
|
));
|
|
}
|
|
|
|
// Group owners must be non-empty — otherwise the drive is created
|
|
// with no transitive Owner-user from day one. Per Ed's invariant:
|
|
// "a drive must always remain with at least one Owner-user". User
|
|
// owners trivially satisfy this.
|
|
if let Subject::Group(gid) = owner_subject {
|
|
let n = self.group_repo.count_members(gid).await.map_err(|e| {
|
|
DomainError::internal_error("Drive", format!("group lookup failed: {e:?}"))
|
|
})?;
|
|
if n < 1 {
|
|
tracing::info!(
|
|
target: "audit",
|
|
event = "drive_create.rejected",
|
|
reason = "owner_group_empty",
|
|
caller_id = %caller_id,
|
|
owner_group_id = %gid,
|
|
"👮🏻♂️ refused shared-drive create: owner group has no members",
|
|
);
|
|
return Err(DomainError::validation_error(
|
|
"Owner group has no members — the drive would have no effective Owner.",
|
|
));
|
|
}
|
|
}
|
|
|
|
let trimmed = name.trim();
|
|
if trimmed.is_empty() {
|
|
return Err(DomainError::validation_error("Drive name is required."));
|
|
}
|
|
|
|
let drive = self
|
|
.drive_repo
|
|
.create_shared_drive_atomic(trimmed, owner_subject, quota_bytes, caller_id)
|
|
.await
|
|
.map_err(|e| DomainError::internal_error("Drive", format!("create failed: {e:?}")))?;
|
|
|
|
tracing::info!(
|
|
target: "audit",
|
|
event = "drive.created",
|
|
kind = "shared",
|
|
drive_id = %drive.drive.id,
|
|
owner_type = owner_subject.type_str(),
|
|
owner_id = %owner_subject.id(),
|
|
granted_by = %caller_id,
|
|
"🆕 shared drive created '{}' owned by {} {}",
|
|
trimmed, owner_subject.type_str(), owner_subject.id(),
|
|
);
|
|
|
|
Ok(drive)
|
|
}
|
|
|
|
/// `GET /api/drives/{id}/members` — every role grant on the drive.
|
|
pub async fn list_members(
|
|
&self,
|
|
caller_id: Uuid,
|
|
drive_id: Uuid,
|
|
) -> Result<Vec<Grant>, DomainError> {
|
|
let resource = Resource::Drive(drive_id);
|
|
self.authz
|
|
.require(Subject::User(caller_id), Permission::Read, resource)
|
|
.await?;
|
|
self.authz.list_grants_on_resource(resource).await
|
|
}
|
|
|
|
/// `POST /api/drives/{id}/members` (create) or
|
|
/// `PATCH /api/drives/{id}/members/{subject_id}` (role change).
|
|
///
|
|
/// `set_role` is idempotent — `(subject, resource)` is unique — so the
|
|
/// two HTTP shapes share one service method. Returns the resulting grant.
|
|
///
|
|
/// `caller_is_admin = true` skips the per-drive `Manage` check
|
|
/// (used by `/api/admin/drives/{id}/members` so an admin who
|
|
/// created the drive for someone else can still edit owners).
|
|
/// Personal-drive guard and last-owner protection still apply —
|
|
/// admin bypass is about *access*, not invariants. The audit log
|
|
/// flags admin-driven changes via `via_admin = true` so a reader
|
|
/// can tell what fired the mutation. The caller (HTTP handler) is
|
|
/// authoritative for `caller_is_admin`; the route gate is the
|
|
/// source of truth and the service trusts the flag.
|
|
pub async fn set_member_role(
|
|
&self,
|
|
caller_id: Uuid,
|
|
caller_is_admin: bool,
|
|
drive_id: Uuid,
|
|
subject: Subject,
|
|
role: Role,
|
|
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
|
) -> Result<Grant, DomainError> {
|
|
let resource = Resource::Drive(drive_id);
|
|
if !caller_is_admin {
|
|
self.authz
|
|
.require(Subject::User(caller_id), Permission::Manage, resource)
|
|
.await?;
|
|
}
|
|
|
|
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
|
|
// owner" is refused. Applies to admin-bypass too: orphaning a
|
|
// shared drive is the same category error regardless of who fires
|
|
// the request.
|
|
if !matches!(role, Role::Owner) {
|
|
self.refuse_if_last_owner_change(drive_id, subject, caller_id)
|
|
.await?;
|
|
}
|
|
|
|
let grant = self
|
|
.authz
|
|
.set_role(caller_id, subject, role, resource, expires_at)
|
|
.await?;
|
|
|
|
// Drop the entire drive-role cache for this drive so the new
|
|
// grant is visible on the very next `check` — without this, a
|
|
// caller that gets Owner via `POST /api/drives/{id}/members`
|
|
// then immediately acts on drive content (WebDAV cross-drive
|
|
// MOVE, admin-driven cleanup, drive management) hits the
|
|
// stale "no role for this subject on this drive" entry
|
|
// seeded at some earlier `check`. TTL rescues eventually,
|
|
// but the storage_cleanup_check.sh drain pattern hits this
|
|
// race within a single test-second and fails on `authz.denied`
|
|
// for admin's cascade to files inside.
|
|
self.authz
|
|
.invalidate_drive_role_cache_for_drive(drive_id)
|
|
.await;
|
|
// Same freshness contract for the repo's readable-drives cache:
|
|
// the subject's drive list changed with this grant.
|
|
match subject {
|
|
Subject::User(uid) => self.drive_repo.invalidate_readable_for_user(uid).await,
|
|
_ => self.drive_repo.invalidate_readable_all(),
|
|
}
|
|
|
|
// D6 §11: canonical `drive.member_added` audit event covers
|
|
// every successful membership write (add + role-refresh, since
|
|
// the underlying `set_role` is UPSERT — distinguishing the two
|
|
// would require an additional read and bring no extra ops
|
|
// value). `via_admin` carries the bypass signal that used to
|
|
// live in a separate `drive_membership.set_via_admin` event;
|
|
// log aggregators now have one canonical name per operation.
|
|
tracing::info!(
|
|
target: "audit",
|
|
event = "drive.member_added",
|
|
drive_id = %drive_id,
|
|
subject_type = subject.type_str(),
|
|
subject_id = %subject.id(),
|
|
role = role.as_str(),
|
|
via_admin = caller_is_admin,
|
|
by = %caller_id,
|
|
expires_at = ?expires_at,
|
|
"🤝 drive member added",
|
|
);
|
|
Ok(grant)
|
|
}
|
|
|
|
/// `DELETE /api/drives/{id}/members/{subject_id}`. Idempotent — removing
|
|
/// a subject with no current grant succeeds (matches `clear_role`).
|
|
///
|
|
/// `caller_is_admin` mirrors `set_member_role`: skips the per-drive
|
|
/// `Manage` check but keeps personal-drive guard + last-owner
|
|
/// protection. Audit emits `drive_membership.removed_via_admin`
|
|
/// when the bypass fires.
|
|
pub async fn remove_member(
|
|
&self,
|
|
caller_id: Uuid,
|
|
caller_is_admin: bool,
|
|
drive_id: Uuid,
|
|
subject: Subject,
|
|
) -> Result<(), DomainError> {
|
|
let resource = Resource::Drive(drive_id);
|
|
if !caller_is_admin {
|
|
self.authz
|
|
.require(Subject::User(caller_id), Permission::Manage, resource)
|
|
.await?;
|
|
}
|
|
|
|
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?;
|
|
|
|
self.authz.clear_role(subject, resource).await?;
|
|
|
|
// Mirror of `set_member_role`'s cache invalidation: after
|
|
// clearing a role we MUST drop the `drive_role_cache` entries
|
|
// targeting this drive, otherwise the just-removed subject's
|
|
// former role stays visible until TTL expires. Same anti-drift
|
|
// reason as the sibling add path above.
|
|
self.authz
|
|
.invalidate_drive_role_cache_for_drive(drive_id)
|
|
.await;
|
|
// And the repo's readable-drives cache: the drive must vanish
|
|
// from the removed subject's list immediately.
|
|
match subject {
|
|
Subject::User(uid) => self.drive_repo.invalidate_readable_for_user(uid).await,
|
|
_ => self.drive_repo.invalidate_readable_all(),
|
|
}
|
|
|
|
// D6 §11: canonical `drive.member_removed` audit event covers
|
|
// every successful removal (owner-driven or admin bypass).
|
|
// `via_admin` replaces the separate
|
|
// `drive_membership.removed_via_admin` event — single name,
|
|
// one boolean field for the bypass signal.
|
|
tracing::info!(
|
|
target: "audit",
|
|
event = "drive.member_removed",
|
|
drive_id = %drive_id,
|
|
subject_type = subject.type_str(),
|
|
subject_id = %subject.id(),
|
|
via_admin = caller_is_admin,
|
|
by = %caller_id,
|
|
"👋 drive member removed",
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// `DELETE /api/drives/{id}` and `DELETE /api/admin/drives/{id}`.
|
|
///
|
|
/// Policy (drive.md §6 + memos):
|
|
/// - Caller must hold `Permission::Manage` on the drive — typically
|
|
/// the Owner. `caller_is_admin = true` bypasses this check; the
|
|
/// route gate is the access control then. Audit emits
|
|
/// `drive.deleted_via_admin` when the bypass fires.
|
|
/// - The user's default Personal drive (`drives.default_for_user
|
|
/// IS NOT NULL`) is refused with `405` — deleting your home is a
|
|
/// category error. Secondary personal drives + shared drives
|
|
/// follow the same content-empty rule below.
|
|
/// - The drive must be empty (no live folders other than the root,
|
|
/// no live files). Trashed rows are excluded — owners can
|
|
/// delete a drive whose trash bin still holds rows; the trash GC
|
|
/// cleans them up after the retention window. Non-empty drives
|
|
/// return `409 Conflict` so the UI can prompt the owner to
|
|
/// move/trash content first.
|
|
///
|
|
/// On success the drive row, its root folder, and every
|
|
/// `role_grants` row scoped to the drive are removed in one
|
|
/// transaction.
|
|
pub async fn delete_drive(
|
|
&self,
|
|
caller_id: Uuid,
|
|
caller_is_admin: bool,
|
|
drive_id: Uuid,
|
|
) -> Result<(), DomainError> {
|
|
let resource = Resource::Drive(drive_id);
|
|
if !caller_is_admin {
|
|
self.authz
|
|
.require(Subject::User(caller_id), Permission::Manage, resource)
|
|
.await?;
|
|
}
|
|
|
|
let drive = self.drive_repo.get_by_id(drive_id).await.map_err(|e| {
|
|
DomainError::internal_error("Drive", format!("Failed to fetch drive: {e:?}"))
|
|
})?;
|
|
|
|
if drive.drive.default_for_user.is_some() {
|
|
tracing::info!(
|
|
target: "audit",
|
|
event = "drive_delete.rejected",
|
|
reason = "default_personal_drive",
|
|
drive_id = %drive_id,
|
|
by = %caller_id,
|
|
"👮🏻♂️ refused delete on default personal drive {drive_id}",
|
|
);
|
|
return Err(DomainError::operation_not_supported(
|
|
"Drive",
|
|
"The default Personal drive cannot be deleted.",
|
|
));
|
|
}
|
|
|
|
let empty = self.drive_repo.is_empty(drive_id).await.map_err(|e| {
|
|
DomainError::internal_error("Drive", format!("Failed to check emptiness: {e:?}"))
|
|
})?;
|
|
if !empty {
|
|
tracing::info!(
|
|
target: "audit",
|
|
event = "drive_delete.rejected",
|
|
reason = "drive_not_empty",
|
|
drive_id = %drive_id,
|
|
by = %caller_id,
|
|
"👮🏻♂️ refused delete on non-empty drive {drive_id}",
|
|
);
|
|
return Err(DomainError::new(
|
|
crate::common::errors::ErrorKind::Conflict,
|
|
"Drive",
|
|
"Drive is not empty — move or trash its contents before deleting.",
|
|
));
|
|
}
|
|
|
|
self.drive_repo
|
|
.delete_atomic(drive_id)
|
|
.await
|
|
.map_err(|e| DomainError::internal_error("Drive", format!("delete failed: {e:?}")))?;
|
|
|
|
// Drop every cached drive-role entry for this drive so the next
|
|
// /api/drives listing for any subject doesn't show a row pointing
|
|
// at a deleted drive_id. Single-key cache invalidations are safe
|
|
// even when no entry matches.
|
|
self.authz
|
|
.invalidate_drive_role_cache_for_drive(drive_id)
|
|
.await;
|
|
|
|
tracing::info!(
|
|
target: "audit",
|
|
event = if caller_is_admin {
|
|
"drive.deleted_via_admin"
|
|
} else {
|
|
"drive.deleted"
|
|
},
|
|
drive_id = %drive_id,
|
|
by = %caller_id,
|
|
"🗑 drive deleted",
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// `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.
|
|
/// Audit emits `drive.policy_changed` with the post-merge bag for
|
|
/// steady-state observability.
|
|
///
|
|
/// Ed's call, 2026-07-17: intentional deviation from the AGENTS.md
|
|
/// "AuthZ in service layer" rule for this specific endpoint —
|
|
/// the handler-layer admin check stays, this method stays trusting.
|
|
/// See memory `feedback_drive_policies_admin_at_handler`.
|
|
pub async fn update_policies(
|
|
&self,
|
|
caller_id: Uuid,
|
|
drive_id: Uuid,
|
|
partial: serde_json::Value,
|
|
) -> Result<crate::domain::entities::drive::DrivePolicies, DomainError> {
|
|
let merged = self
|
|
.drive_repo
|
|
.update_policies(drive_id, &partial)
|
|
.await
|
|
.map_err(|e| match e {
|
|
DriveRepositoryError::NotFound(_) => {
|
|
DomainError::not_found("Drive", drive_id.to_string())
|
|
}
|
|
other => DomainError::internal_error(
|
|
"Drive",
|
|
format!("update_policies failed: {other:?}"),
|
|
),
|
|
})?;
|
|
|
|
// Flush the cached typed policy view so the very next mutating
|
|
// authz check on any resource in this drive sees the fresh
|
|
// `read_only` value (and every other policy field). Without this,
|
|
// a policy change would take up to `DRIVE_POLICIES_CACHE_TTL` (30 s)
|
|
// to take effect on the hot path — unacceptable for the read_only
|
|
// freeze, which admins expect to be effective immediately.
|
|
self.authz
|
|
.invalidate_drive_policies_cache_for_drive(drive_id)
|
|
.await;
|
|
|
|
tracing::info!(
|
|
target: "audit",
|
|
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,
|
|
include_in_photo_index = merged.include_in_photo_index,
|
|
include_in_music_index = merged.include_in_music_index,
|
|
read_only = merged.read_only,
|
|
"📜 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
|
|
/// a category error (§2). Returns `Forbidden` with an audit line.
|
|
async fn refuse_if_personal(&self, drive_id: Uuid, op: &str) -> Result<(), DomainError> {
|
|
let drive = self.drive_repo.get_by_id(drive_id).await.map_err(|e| {
|
|
DomainError::internal_error("Drive", format!("Failed to fetch drive: {e:?}"))
|
|
})?;
|
|
if matches!(drive.drive.kind, DriveKind::Personal) {
|
|
tracing::info!(
|
|
target: "audit",
|
|
event = "drive_membership.rejected",
|
|
reason = "personal_drive_immutable",
|
|
operation = %op,
|
|
drive_id = %drive_id,
|
|
"👮🏻♂️ refused {op} on personal drive {drive_id}",
|
|
);
|
|
return Err(DomainError::operation_not_supported(
|
|
"Drive",
|
|
"Personal drives have a fixed single-owner membership and cannot be modified.",
|
|
));
|
|
}
|
|
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
|
|
/// (no one can ever grant permissions again).
|
|
async fn refuse_if_last_owner_change(
|
|
&self,
|
|
drive_id: Uuid,
|
|
subject: Subject,
|
|
caller_id: Uuid,
|
|
) -> Result<(), DomainError> {
|
|
let resource = Resource::Drive(drive_id);
|
|
let grants = self.authz.list_grants_on_resource(resource).await?;
|
|
|
|
// `subject` must currently BE an owner — otherwise no demotion risk.
|
|
let subject_is_owner = grants
|
|
.iter()
|
|
.any(|g| g.subject == subject && matches!(g.role, Role::Owner));
|
|
if !subject_is_owner {
|
|
return Ok(());
|
|
}
|
|
|
|
let owner_count = grants
|
|
.iter()
|
|
.filter(|g| matches!(g.role, Role::Owner))
|
|
.count();
|
|
if owner_count <= 1 {
|
|
tracing::info!(
|
|
target: "audit",
|
|
event = "drive_membership.rejected",
|
|
reason = "last_owner",
|
|
drive_id = %drive_id,
|
|
caller_id = %caller_id,
|
|
subject_type = subject.type_str(),
|
|
subject_id = %subject.id(),
|
|
"👮🏻♂️ refused last-owner removal on drive {drive_id}",
|
|
);
|
|
return Err(DomainError::validation_error(
|
|
"A shared drive must keep at least one Owner — promote another \
|
|
member to Owner first, or delete the drive.",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|