feat(drive): add policy forbid_public_links
This commit is contained in:
@@ -24,7 +24,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
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;
|
||||
@@ -373,6 +373,62 @@ 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).
|
||||
/// 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.
|
||||
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)
|
||||
.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:?}"),
|
||||
),
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = if caller_is_admin {
|
||||
"drive.policy_changed_via_admin"
|
||||
} else {
|
||||
"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,
|
||||
"📜 drive policies updated",
|
||||
);
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
// ── Business rules ──────────────────────────────────────────────────────
|
||||
|
||||
/// Personal drives are single-user single-owner; any member mutation is
|
||||
|
||||
@@ -4,8 +4,10 @@ use thiserror::Error;
|
||||
use tokio::sync::Semaphore;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::domain::services::authorization::{Resource, Role, Subject};
|
||||
use crate::infrastructure::repositories::pg::DrivePgRepository;
|
||||
use crate::infrastructure::repositories::pg::SharePgRepository;
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
||||
@@ -80,6 +82,10 @@ pub struct ShareService {
|
||||
share_repository: Arc<SharePgRepository>,
|
||||
file_repository: Arc<FileBlobReadRepository>,
|
||||
folder_repository: Arc<FolderDbRepository>,
|
||||
/// Drive repository — D5 enforcement reads the drive's `policies`
|
||||
/// JSONB before any per-resource action that a policy can gate
|
||||
/// (e.g. `forbid_public_links` for token-share creation).
|
||||
drive_repository: Arc<DrivePgRepository>,
|
||||
password_hasher: Arc<Argon2PasswordHasher>,
|
||||
/// ReBAC engine — used to create/revoke token grants that mirror public
|
||||
/// share links so that `GET /api/grants/outgoing` reflects them.
|
||||
@@ -90,11 +96,13 @@ pub struct ShareService {
|
||||
}
|
||||
|
||||
impl ShareService {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
config: Arc<AppConfig>,
|
||||
share_repository: Arc<SharePgRepository>,
|
||||
file_repository: Arc<FileBlobReadRepository>,
|
||||
folder_repository: Arc<FolderDbRepository>,
|
||||
drive_repository: Arc<DrivePgRepository>,
|
||||
password_hasher: Arc<Argon2PasswordHasher>,
|
||||
authorization: Arc<PgAclEngine>,
|
||||
) -> Self {
|
||||
@@ -103,6 +111,7 @@ impl ShareService {
|
||||
share_repository,
|
||||
file_repository,
|
||||
folder_repository,
|
||||
drive_repository,
|
||||
password_hasher,
|
||||
authorization,
|
||||
hash_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HASHES)),
|
||||
@@ -234,6 +243,40 @@ impl ShareUseCase for ShareService {
|
||||
|
||||
self.verify_item_exists(&dto.item_id, &item_type).await?;
|
||||
|
||||
// D5: `forbid_public_links` policy gate. The drive owner can
|
||||
// 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`.
|
||||
let item_uuid = Uuid::parse_str(&dto.item_id)
|
||||
.map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?;
|
||||
let policies = match item_type {
|
||||
ShareItemType::File => self.drive_repository.get_policies_for_file(item_uuid).await,
|
||||
ShareItemType::Folder => {
|
||||
self.drive_repository
|
||||
.get_policies_for_folder(item_uuid)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.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 password_hash = match dto.password {
|
||||
Some(p) => Some(self.hash_password_async(&p).await?),
|
||||
None => None,
|
||||
|
||||
Reference in New Issue
Block a user