feat(drive): add /api/drive

- permit shared drive creation from oxicloud admin (for now)
    - prepare other personal drive creation (Not implemented), need to validate
    quota policies and strategy first
    - add hurl test to verify permissions
This commit is contained in:
Edouard Vanbelle
2026-06-23 23:16:02 +02:00
parent 184520c17a
commit a5b24a7453
11 changed files with 1181 additions and 28 deletions
@@ -25,18 +25,134 @@ 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::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::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>,
}
impl DriveManagementService {
pub fn new(drive_repo: Arc<DrivePgRepository>, authz: Arc<PgAclEngine>) -> Self {
Self { drive_repo, authz }
pub fn new(
drive_repo: Arc<DrivePgRepository>,
authz: Arc<PgAclEngine>,
group_repo: Arc<SubjectGroupPgRepository>,
) -> Self {
Self {
drive_repo,
authz,
group_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.
@@ -37,6 +37,13 @@ pub struct SubjectGroupService {
/// make it not dyn-compatible (matches the convention used by other
/// services in this layer).
user_storage: Arc<UserPgRepository>,
/// Used by `add_member` / `remove_member` to drop stale
/// `user_groups_cache` entries for affected users so newly-added
/// (or newly-removed) group memberships surface in the next
/// `expand_subject_for_listing` call instead of waiting out the
/// 30 s TTL. Without this, fresh group-mediated drive grants
/// don't appear in `/api/drives` for up to 30 s after `add_member`.
engine: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
}
impl SubjectGroupService {
@@ -44,11 +51,29 @@ impl SubjectGroupService {
repo: Arc<SubjectGroupPgRepository>,
pool: Arc<PgPool>,
user_storage: Arc<UserPgRepository>,
engine: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
) -> Self {
Self {
repo,
pool,
user_storage,
engine,
}
}
/// Returns the user IDs whose `user_groups_cache` entries need to
/// drop after a membership change on `member`. For `User` members
/// it's just that user; for nested `Group` members, every
/// transitive user under that child group inherits/loses the
/// parent-group ancestor, so all of them need invalidation.
async fn invalidation_targets(&self, member: GroupMember) -> Result<Vec<Uuid>, DomainError> {
match member {
GroupMember::User(uid) => Ok(vec![uid]),
GroupMember::Group(child_id) => self
.repo
.list_transitive_users(child_id)
.await
.map_err(map_repo_err),
}
}
@@ -331,6 +356,15 @@ impl SubjectGroupService {
map_repo_err(e)
})?;
// Drop stale cached subject expansions for every user who just
// inherited the new group as a transitive ancestor — otherwise
// group-mediated grants (e.g. shared-drive Owner via this
// group) wouldn't surface in the next `expand_subject_for_listing`
// call for up to 30 s.
for uid in self.invalidation_targets(member).await? {
self.engine.invalidate_user_groups_cache(uid).await;
}
tracing::info!(
target: "audit",
event = "group.member_added",
@@ -356,11 +390,80 @@ impl SubjectGroupService {
));
}
// Self-defense (D3a follow-up): a group can never drop to 0
// transitive users once seeded. Without this, an admin could
// empty a group that's the Owner of a shared drive, leaving the
// drive with no effective Owner (orphan-owned). Per Ed's
// conservative-by-default stance: enforce the invariant
// globally — not just for drive-owning groups — so anything
// else that grants groups semantic power (delegated permissions,
// mention targets, ...) is automatically protected.
//
// Pre-check is conservative: it computes the transitive user
// set BEFORE the remove and refuses when the removal would
// collapse it to 0. The edge case "user reachable via a nested
// group" is handled by `list_transitive_users` itself — if the
// user is still reachable via another path after this remove,
// they stay in the set on the post-state, so the check would
// pass on the next remove instead.
let users_before = self
.repo
.list_transitive_users(group_id)
.await
.map_err(map_repo_err)?;
if !users_before.is_empty() {
let would_be_empty = match member {
GroupMember::User(uid) => users_before.len() == 1 && users_before.contains(&uid),
GroupMember::Group(child_id) => {
// For child-group removal: would this drop the
// parent's transitive user set to 0? Look up the
// child's transitive users — if every user in the
// parent's set comes through the child, removing the
// child empties the parent.
let child_users = self
.repo
.list_transitive_users(child_id)
.await
.map_err(map_repo_err)?;
!child_users.is_empty() && users_before.iter().all(|u| child_users.contains(u))
}
};
if would_be_empty {
tracing::info!(
target: "audit",
event = "group.member_removed_rejected",
reason = "would_empty_seeded_group",
group_id = %group_id,
member = ?member,
by = %caller_id,
"👮🏻‍♂️ refused last-user removal from group {group_id} \
(groups must never drop to 0 transitive users once seeded — \
a drive-owning group going empty would orphan the drive)",
);
return Err(DomainError::new(
ErrorKind::InvalidInput,
"SubjectGroup",
"A group cannot have less than 1 user once seeded. \
Add another user before removing this one."
.to_string(),
));
}
}
self.repo
.remove_member(group_id, member)
.await
.map_err(map_repo_err)?;
// Symmetric to add_member: drop the cache for users whose
// expanded subject set just lost the parent group as an
// ancestor. Without this, a removed-from-group user keeps
// appearing as a transitive member in `expand_subject_for_listing`
// for up to 30 s, surfacing grants they no longer have.
for uid in self.invalidation_targets(member).await? {
self.engine.invalidate_user_groups_cache(uid).await;
}
tracing::info!(
target: "audit",
event = "group.member_removed",
+2
View File
@@ -1475,6 +1475,7 @@ impl AppServiceFactory {
crate::application::services::drive_management_service::DriveManagementService::new(
drive_repo.clone(),
authorization.clone(),
subject_group_repo.clone(),
),
),
subject_group_service: Some(Arc::new(
@@ -1486,6 +1487,7 @@ impl AppServiceFactory {
pool.clone(),
),
),
authorization.clone(),
),
)),
email_sender: None, // populated below
@@ -85,6 +85,36 @@ pub trait DriveRepository: Send + Sync + 'static {
quota_bytes: Option<i64>,
) -> Result<DriveWithRootName, DriveRepositoryError>;
/// Atomically create a **shared** drive together with its root folder
/// and the initial Owner-role grant. Mirrors
/// `create_personal_drive_atomic` but with three differences:
/// - `kind='shared'`, `default_for_user=NULL`
/// - root folder name is caller-supplied (validated upstream)
/// - Owner role_grant subject is caller-supplied — either a
/// single `User` (becomes the sole drive Owner) or a `Group`
/// (the group's transitive user members all gain the Owner
/// role via subject expansion). Token subjects are refused at
/// the service edge.
///
/// `granted_by` is recorded on the role_grant row + on the root
/// folder's `created_by` / `updated_by` columns for audit
/// traceability — the OxiCloud admin who provisioned the drive.
///
/// **AuthZ contract**: this method performs no authorization. The
/// service layer MUST verify the caller has the OxiCloud `admin`
/// system role (D3a). If `owner_subject` is `Group`, the service
/// MUST also have verified the group has ≥1 user member —
/// otherwise the drive is created with no effective Owner-user
/// and would breach the "drive must always have ≥1 effective
/// Owner" invariant from day one.
async fn create_shared_drive_atomic(
&self,
name: &str,
owner_subject: crate::domain::services::authorization::Subject,
quota_bytes: Option<i64>,
granted_by: Uuid,
) -> Result<DriveWithRootName, DriveRepositoryError>;
/// Fetch a drive by id together with its display name. `NotFound`
/// when no row matches.
async fn get_by_id(&self, id: Uuid) -> Result<DriveWithRootName, DriveRepositoryError>;
@@ -196,6 +196,113 @@ impl DriveRepository for DrivePgRepository {
Self::row_to_drive_with_name(&row)
}
async fn create_shared_drive_atomic(
&self,
name: &str,
owner_subject: crate::domain::services::authorization::Subject,
quota_bytes: Option<i64>,
granted_by: Uuid,
) -> Result<DriveWithRootName, DriveRepositoryError> {
// Same four-write transaction shape as `create_personal_drive_atomic`
// (see that method for the why-not-CTE explanation). Differences:
// - `kind='shared'`, `default_for_user=NULL`.
// - Root folder name is caller-supplied.
// - Owner grant subject is caller-supplied — either a single
// User (becomes the sole drive Owner) or a Group (transitive
// members inherit Owner via subject expansion).
// - `granted_by` is the OxiCloud admin who provisioned the drive;
// same value goes onto the folder's `created_by`/`updated_by`
// for §14 provenance.
let mut tx = self
.pool
.begin()
.await
.map_err(|e| Self::map_sqlx_err("create_shared_drive_atomic.begin", e))?;
// 1. Drive row (root_folder_id NULL — populated in step 3).
let drive_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO storage.drives
(kind, default_for_user, quota_bytes, policies)
VALUES ('shared', NULL, $1, '{}'::jsonb)
RETURNING id
"#,
)
.bind(quota_bytes)
.fetch_one(&mut *tx)
.await
.map_err(|e| Self::map_sqlx_err("create_shared_drive_atomic.drive", e))?;
// 2. Root folder. The folder's `user_id` carries the admin (legacy
// column still NOT NULL during the dual-write window — D7
// drops it once `drive_id` is the canonical ownership signal).
let folder_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO storage.folders
(name, parent_id, user_id, drive_id, created_by, updated_by)
VALUES ($1, NULL, $2, $3, $2, $2)
RETURNING id
"#,
)
.bind(name)
.bind(granted_by)
.bind(drive_id)
.fetch_one(&mut *tx)
.await
.map_err(|e| Self::map_sqlx_err("create_shared_drive_atomic.folder", e))?;
// 3. Close the circular reference (drive ↔ root folder).
sqlx::query(r#"UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2"#)
.bind(folder_id)
.bind(drive_id)
.execute(&mut *tx)
.await
.map_err(|e| Self::map_sqlx_err("create_shared_drive_atomic.wire", e))?;
// 4. Owner role_grant — subject_type chosen from the caller's input.
// Group subjects expand transitively via `subject_match_set` so
// every member inherits Owner; User subjects are the single
// admin case.
sqlx::query(
r#"
INSERT INTO storage.role_grants
(subject_type, subject_id, resource_type, resource_id,
role, granted_by)
VALUES ($1, $2, 'drive', $3, 'owner', $4)
"#,
)
.bind(owner_subject.type_str())
.bind(owner_subject.id())
.bind(drive_id)
.bind(granted_by)
.execute(&mut *tx)
.await
.map_err(|e| Self::map_sqlx_err("create_shared_drive_atomic.grant", e))?;
// Fetch final state so the caller sees DB-computed defaults.
let row = sqlx::query(
r#"
SELECT d.id, d.kind, d.default_for_user, d.root_folder_id,
d.quota_bytes, d.used_bytes, d.policies,
d.created_at, d.updated_at,
f.name AS root_folder_name
FROM storage.drives d
JOIN storage.folders f ON f.id = d.root_folder_id
WHERE d.id = $1
"#,
)
.bind(drive_id)
.fetch_one(&mut *tx)
.await
.map_err(|e| Self::map_sqlx_err("create_shared_drive_atomic.read", e))?;
tx.commit()
.await
.map_err(|e| Self::map_sqlx_err("create_shared_drive_atomic.commit", e))?;
Self::row_to_drive_with_name(&row)
}
async fn get_by_id(&self, id: Uuid) -> Result<DriveWithRootName, DriveRepositoryError> {
let row = sqlx::query(
r#"
+32 -3
View File
@@ -155,6 +155,13 @@ impl PgAclEngine {
.time_to_live(OWNER_CACHE_TTL)
.build(),
drive_role_cache: Cache::builder()
// `invalidate_entries_if` is the cleanup hook used by
// `invalidate_drive_role_cache_for_drive`. moka returns
// `Err(InvalidationClosuresDisabled)` from that call unless
// this opt-in is set on the builder, so without it the
// bulk invalidation silently no-ops and a freshly-promoted
// member keeps their stale role for the full TTL.
.support_invalidation_closures()
.max_capacity(DRIVE_ROLE_CACHE_CAPACITY)
.time_to_live(DRIVE_ROLE_CACHE_TTL)
.build(),
@@ -214,6 +221,7 @@ impl PgAclEngine {
.time_to_live(Duration::from_secs(1))
.build(),
drive_role_cache: Cache::builder()
.support_invalidation_closures()
.max_capacity(1)
.time_to_live(Duration::from_secs(1))
.build(),
@@ -238,14 +246,35 @@ impl PgAclEngine {
///
/// Uses moka's predicate-based eviction — entries are marked for
/// removal asynchronously by the maintenance task; subsequent `get`
/// calls observe the eviction immediately.
/// calls observe the eviction. Requires
/// `support_invalidation_closures()` on the cache builder (see the
/// `drive_role_cache` initialiser above), otherwise moka returns
/// `InvalidationClosuresDisabled` and the mutation silently leaves
/// stale role rows in cache for the full TTL.
pub async fn invalidate_drive_role_cache_for_drive(&self, drive_id: Uuid) {
// `invalidate_entries_if` rejects predicates returning errors —
// simple Fn(K, V) -> bool. We capture `drive_id` by value (Copy)
// and match against the second tuple component.
let _ = self
//
// The result is `Err` only when the cache was built without
// `support_invalidation_closures()` — a wiring bug, not a runtime
// condition the caller can recover from. We log+continue rather
// than panic because the consequence is a 30 s staleness window
// on cached role entries, not a correctness bug at write time.
if let Err(err) = self
.drive_role_cache
.invalidate_entries_if(move |key, _v| key.1 == drive_id);
.invalidate_entries_if(move |key, _v| key.1 == drive_id)
{
tracing::error!(
target: "oxicloud::authz",
event = "authz.cache_invalidation_failed",
cache = "drive_role_cache",
drive_id = %drive_id,
error = %err,
"drive_role_cache cannot be bulk-invalidated — \
cache builder is missing support_invalidation_closures()",
);
}
}
/// Expand a user subject into the set of subject UUIDs that should match
@@ -98,6 +98,42 @@ pub struct UpdateDriveMemberDto {
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
/// Body for `POST /api/drives` (D3a — create drive).
///
/// `kind` discriminates the drive flavour. D3a wires the `shared` branch
/// end-to-end; the `personal` branch (secondary personal drives, distinct
/// from the lifecycle-created default) is a recognised wire shape but
/// returns 501 today — its authz model (self-service vs admin-only) and
/// quota source (borrowed from per-user pool? separate cap?) are still
/// open product questions. The body shape stays stable so future PRs only
/// need to flip the service's `kind=personal` arm from rejecting to
/// dispatching `create_personal_drive_atomic` with `default_for_user=NULL`.
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateDriveDto {
/// Drive flavour. `"shared"` is implemented; `"personal"` is reserved.
pub kind: DriveKindDto,
/// Drive name (becomes the root folder's name). Trimmed; must be
/// non-empty after trim.
pub name: String,
/// Initial Owner subject. For `kind="shared"`: either a `user` (sole
/// drive Owner) or a `group` (transitive user members all gain Owner
/// via subject expansion). `token` is refused at the service edge.
/// For `kind="personal"` (when implemented): MUST be a `user`.
pub owner: SubjectDto,
/// Optional storage cap in bytes. `None` / omitted → no quota.
/// Quota mutation post-creation is OxiCloud-admin-only (D4).
#[serde(default)]
pub quota_bytes: Option<i64>,
}
/// Wire-shape enum for the drive flavour. Mirrors backend `DriveKind`.
#[derive(Debug, Clone, Copy, Deserialize, ToSchema, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DriveKindDto {
Personal,
Shared,
}
fn parse_subject(kind: SubjectTypeDto, id: Uuid) -> Subject {
match kind {
SubjectTypeDto::User => Subject::User(id),
@@ -106,6 +142,79 @@ fn parse_subject(kind: SubjectTypeDto, id: Uuid) -> Subject {
}
}
/// Create a drive (D3a — shared today; personal kind reserved).
///
/// **AuthZ**: OxiCloud-`admin` role only. The plan (`drive.md §6`) reads
/// "admin OR group owner triggers" — D3a starts with admin-only and later
/// iterations can broaden the gate without changing the wire shape.
///
/// Body:
/// ```json
/// {
/// "kind": "shared",
/// "name": "Engineering",
/// "owner": { "type": "group", "id": "<group-uuid>" },
/// "quota_bytes": 53687091200
/// }
/// ```
///
/// Returns the new `DriveDto`. If `owner.type == "group"`, the group must
/// have ≥1 direct member or the request is refused with 400 — otherwise
/// the drive would be created with no effective Owner-user.
///
/// `kind: "personal"` is recognised on the wire but returns 501 — the
/// authz model (self-service vs admin-only) and quota source for
/// secondary personal drives are still open product questions.
#[utoipa::path(
post,
path = "/api/drives",
request_body = CreateDriveDto,
responses(
(status = 201, description = "Drive created", body = DriveDto),
(status = 400, description = "Empty name, empty owner group, or invalid input"),
(status = 403, description = "Caller is not an OxiCloud admin"),
(status = 501, description = "kind=personal not yet implemented"),
),
security(("bearerAuth" = [])),
tag = "drives"
)]
pub async fn create_drive(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Json(dto): Json<CreateDriveDto>,
) -> impl IntoResponse {
let caller_is_admin = auth_user.role == "admin";
// Personal kind is a wire-shape placeholder — see DTO doc.
if dto.kind == DriveKindDto::Personal {
return (
StatusCode::NOT_IMPLEMENTED,
Json(serde_json::json!({
"error": "Creating secondary personal drives is not yet implemented. \
The authz model and quota source are still open product \
questions — this body shape is reserved for the future PR."
})),
)
.into_response();
}
let owner = parse_subject(dto.owner.kind, dto.owner.id);
match state
.drive_management_service
.create_shared_drive(
auth_user.id,
caller_is_admin,
&dto.name,
owner,
dto.quota_bytes,
)
.await
{
Ok(drive) => (StatusCode::CREATED, Json(DriveDto::from(drive))).into_response(),
Err(e) => AppError::from(e).into_response(),
}
}
#[utoipa::path(
get,
path = "/api/drives/{id}/members",
+5 -2
View File
@@ -421,14 +421,17 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
use crate::interfaces::api::handlers::drive_handler;
let drives_router = Router::new()
.route("/", get(drive_handler::list_drives))
.route(
"/",
get(drive_handler::list_drives).post(drive_handler::create_drive),
)
.route(
"/{id}/members",
get(drive_handler::list_drive_members).post(drive_handler::add_drive_member),
)
.route(
"/{id}/members/{kind}/{sid}",
axum::routing::patch(drive_handler::update_drive_member)
patch(drive_handler::update_drive_member)
.delete(drive_handler::remove_drive_member),
)
.with_state(app_state.clone());