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:
@@ -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",
|
||||
|
||||
@@ -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#"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
# =============================================================
|
||||
# OxiCloud — D2 drive membership API + delegation + caller_role
|
||||
# OxiCloud — D2/D3a drive membership + create-drive end-to-end
|
||||
# =============================================================
|
||||
# Verifies the D2 membership surface on personal drives (the only
|
||||
# drive kind today — shared-drive positive cases land alongside D3's
|
||||
# create endpoint):
|
||||
#
|
||||
# D2 coverage (steps 1-12, personal-drive-only world):
|
||||
# 1. `GET /api/drives` exposes `caller_role` on every row.
|
||||
# 2. `GET /api/drives/{id}/members` lists role grants on a drive
|
||||
# (one Owner row for the lifecycle-hook-provisioned default).
|
||||
@@ -20,6 +17,38 @@
|
||||
# 6. Anti-enum: an unrelated user gets the same `404` for a drive
|
||||
# they can't read, whether or not it exists.
|
||||
#
|
||||
# D3a coverage (steps 13-22, unlocked by `POST /api/drives`):
|
||||
# - admin-only authz gate on create
|
||||
# - kind=personal returns 501 (placeholder for the future PR)
|
||||
# - Owner subject = user → single Owner shared drive
|
||||
# - Owner subject = group with members → group-mediated Owner
|
||||
# - Owner subject = empty group → 400 (no orphan Owner)
|
||||
# - Token subject refused
|
||||
# - Last-owner protection on the new shared drive
|
||||
# - Group-mediated Owner: caller_role resolves the strongest role
|
||||
# (MIN over direct + group grants)
|
||||
# - Editor cascade through the drive precheck (Bob gets Editor on
|
||||
# a shared drive via the membership API and sees the drive)
|
||||
# - Role demotion: PATCH Bob from Editor to Viewer reflects in his
|
||||
# listing
|
||||
#
|
||||
# Per-role mutation matrix coverage (steps 23-29):
|
||||
# - Owner CAN rename the drive (positive symmetry)
|
||||
# - Owner CAN edit owners / editors / viewers (grant Owner, promote
|
||||
# and demote across all role boundaries)
|
||||
# - Viewer CANNOT POST / PATCH / DELETE members → 404
|
||||
# - Editor CANNOT POST / PATCH / DELETE members → 404
|
||||
# - Editor CAN modify drive content (positive role-bundle check)
|
||||
# - Viewer CAN read drive content (positive role-bundle check)
|
||||
# - Non-member sees 404 on every member-mutation verb AND on
|
||||
# GET /members (anti-enum: no existence leak)
|
||||
#
|
||||
# **Known gap** surfaced by Step 26d: today's folder rename uses
|
||||
# `Permission::Update`, which is in Editor's bundle. The plan
|
||||
# (`drive.md §6`) says drive rename should be Owner-only. If/when
|
||||
# tightening: change the folder service to require `Manage` (or a
|
||||
# new `RenameDrive` permission) for folders that are drive roots.
|
||||
#
|
||||
# Self-contained: provisions its own users so it can run after
|
||||
# drives_foundation without aliasing state.
|
||||
# =============================================================
|
||||
@@ -216,3 +245,582 @@ GET {{base_url}}/api/drives/00000000-0000-0000-0000-000000000000/members
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# =============================================================
|
||||
# D3a — POST /api/drives (create shared drive)
|
||||
# =============================================================
|
||||
# Below covers the create-shared-drive endpoint + the role-bundle
|
||||
# tests that were deferred until shared-drive creation was wirable:
|
||||
#
|
||||
# - admin-only authz gate
|
||||
# - kind=personal returns 501 (placeholder)
|
||||
# - Owner subject = user → single Owner shared drive
|
||||
# - Owner subject = group with members → group-mediated Owner
|
||||
# - Owner subject = empty group → 400 (no orphan Owner)
|
||||
# - Token subject refused
|
||||
# - Editor cascade: drive Owner can mutate content in the new drive
|
||||
# - Last-owner protection on removal
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 13 — Non-admin caller refused with 403.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/drives
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"kind": "shared",
|
||||
"name": "should-not-exist",
|
||||
"owner": { "type": "user", "id": "{{alice_user_id}}" }
|
||||
}
|
||||
|
||||
HTTP 403
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 14 — kind=personal returns 501 (wire-shape placeholder).
|
||||
# The body is accepted as valid JSON; the rejection is
|
||||
# explicit at the service layer.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/drives
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"kind": "personal",
|
||||
"name": "side-private",
|
||||
"owner": { "type": "user", "id": "{{alice_user_id}}" }
|
||||
}
|
||||
|
||||
HTTP 501
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 15 — Create a shared drive with a single user owner
|
||||
# (Alice). The new drive lands with kind=shared,
|
||||
# default_for_user=NULL, and Alice as the sole Owner.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/drives
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"kind": "shared",
|
||||
"name": "alice-shared",
|
||||
"owner": { "type": "user", "id": "{{alice_user_id}}" }
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Asserts]
|
||||
jsonpath "$.kind" == "shared"
|
||||
jsonpath "$.name" == "alice-shared"
|
||||
jsonpath "$.default_for_user" not exists
|
||||
jsonpath "$.used_bytes" == 0
|
||||
|
||||
[Captures]
|
||||
alice_shared_drive_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# Alice now sees TWO drives — her default Personal + the new shared.
|
||||
# Caller_role on the shared drive is "owner" (her user grant).
|
||||
GET {{base_url}}/api/drives
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$" count == 2
|
||||
jsonpath "$[*].id" contains {{alice_shared_drive_id}}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 16 — Last-owner protection: Alice is the sole Owner of
|
||||
# the new shared drive. Removing her grant must refuse.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/drives/{{alice_shared_drive_id}}/members/user/{{alice_user_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 400
|
||||
|
||||
|
||||
# And the demotion form: PATCH her role to editor → same refusal.
|
||||
PATCH {{base_url}}/api/drives/{{alice_shared_drive_id}}/members/user/{{alice_user_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "role": "editor" }
|
||||
|
||||
HTTP 400
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 17 — Empty group is refused (would orphan the drive's Owner).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/groups
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "empty-grp-for-drive" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
empty_group_id: jsonpath "$.id"
|
||||
|
||||
|
||||
POST {{base_url}}/api/drives
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"kind": "shared",
|
||||
"name": "should-not-exist",
|
||||
"owner": { "type": "group", "id": "{{empty_group_id}}" }
|
||||
}
|
||||
|
||||
HTTP 400
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 18 — Empty name is refused (basic validation).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/drives
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"kind": "shared",
|
||||
"name": " ",
|
||||
"owner": { "type": "user", "id": "{{alice_user_id}}" }
|
||||
}
|
||||
|
||||
HTTP 400
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 19 — Token subject is refused (drives can't be owned by
|
||||
# share-link tokens).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/drives
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"kind": "shared",
|
||||
"name": "should-not-exist",
|
||||
"owner": { "type": "token", "id": "00000000-0000-0000-0000-000000000099" }
|
||||
}
|
||||
|
||||
HTTP 400
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 20 — Group-mediated Owner: create a group, add Alice, then
|
||||
# create a shared drive with the group as Owner. Alice
|
||||
# should see the new drive in her listing with
|
||||
# caller_role="owner" (resolved through the group).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/groups
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "drive-grp-with-alice" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
alice_group_id: jsonpath "$.id"
|
||||
|
||||
|
||||
POST {{base_url}}/api/groups/{{alice_group_id}}/members
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{ "user_id": "{{alice_user_id}}" }
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
POST {{base_url}}/api/drives
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"kind": "shared",
|
||||
"name": "team-drive",
|
||||
"owner": { "type": "group", "id": "{{alice_group_id}}" }
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
team_drive_id: jsonpath "$.id"
|
||||
team_root_folder_id: jsonpath "$.root_folder_id"
|
||||
|
||||
|
||||
# Alice's drive listing now includes the team drive with caller_role=owner.
|
||||
# `MIN(role)` over (direct grants + group-mediated grants) resolves Owner.
|
||||
GET {{base_url}}/api/drives
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[*].id" contains {{team_drive_id}}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 21 — Editor cascade through the drive precheck. Add a fresh
|
||||
# user (mbr_bob) as Editor on the team drive; he should
|
||||
# be able to read the drive root and create folders in it
|
||||
# via the drive's Editor permission bundle, without any
|
||||
# per-folder grant.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{ "username": "mbr_bob", "password": "MbrBobPassword1!", "email": "mbr_bob@example.com", "role": "user" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
bob_user_id: jsonpath "$.id"
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "mbr_bob", "password": "MbrBobPassword1!" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
bob_token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
# Bob has no role on the team drive → drive doesn't appear in his listing.
|
||||
GET {{base_url}}/api/drives
|
||||
Authorization: Bearer {{bob_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[*].id" not contains {{team_drive_id}}
|
||||
|
||||
|
||||
# Admin (well — Alice as drive Owner; admin would also work) grants Bob Editor.
|
||||
POST {{base_url}}/api/drives/{{team_drive_id}}/members
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{bob_user_id}}" },
|
||||
"role": "editor"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# Bob now sees the drive with caller_role=editor.
|
||||
GET {{base_url}}/api/drives
|
||||
Authorization: Bearer {{bob_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[*].id" contains {{team_drive_id}}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 22 — Higher role wins: Bob now ALSO gets a Viewer direct
|
||||
# grant (would lower his bundle). The collapsed caller_role
|
||||
# must remain Editor (the stronger of his two grants).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Demote Bob to Viewer via PATCH — first ensure he was editor before
|
||||
# (already confirmed via the GET above).
|
||||
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{bob_user_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "role": "viewer" }
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.role" == "viewer"
|
||||
|
||||
|
||||
# Bob's listing now reflects the demotion.
|
||||
GET {{base_url}}/api/drives
|
||||
Authorization: Bearer {{bob_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[*].id" contains {{team_drive_id}}
|
||||
|
||||
|
||||
# =============================================================
|
||||
# Per-role mutation matrix — what every role can / can't do
|
||||
# =============================================================
|
||||
# Setup state at this point:
|
||||
# - team_drive owners: alice (via alice_group) — sole Owner role grant
|
||||
# - team_drive Viewer: bob (user grant after Step 22 demotion)
|
||||
#
|
||||
# Steps 23-29 cover the per-role authorization matrix on member
|
||||
# management + drive rename + content R/W. Anti-enum: every refusal
|
||||
# returns 404 (not 403) so an unauthorised caller can't enumerate the
|
||||
# difference between "drive doesn't exist" and "you can't manage it".
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 23 — Owner CAN rename the drive.
|
||||
# Drive name lives on its root folder per drive.md §6,
|
||||
# renamed via PUT /api/folders/<root_folder_id>/rename.
|
||||
# Alice's Owner role (via her group) carries Update, so
|
||||
# the engine drive precheck grants the rename.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/api/folders/{{team_root_folder_id}}/rename
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "team-drive-renamed" }
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.name" == "team-drive-renamed"
|
||||
|
||||
|
||||
# The new name surfaces on the drive listing too — drive.name is
|
||||
# sourced from the root folder per DriveDto::From<DriveWithRootName>.
|
||||
GET {{base_url}}/api/drives
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
# Filter expressions in Hurl: `[?(...)]` collapses to a scalar when there's
|
||||
# exactly one match — list-style predicates like `includes` / `contains` then
|
||||
# fail with a type mismatch. So we assert string equality instead.
|
||||
jsonpath "$[?(@.id=='{{team_drive_id}}')].name" == "team-drive-renamed"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 24 — Owner CAN edit owners, editors, and viewers.
|
||||
# Grant Carol Owner, promote Bob to Owner, then demote
|
||||
# Bob back to Viewer (the role he needs for Step 25).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{ "username": "mbr_carol", "password": "MbrCarolPassword1!", "email": "mbr_carol@example.com", "role": "user" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
carol_user_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# 24a — Owner grants Carol Owner role (Owner-creates-Owner).
|
||||
POST {{base_url}}/api/drives/{{team_drive_id}}/members
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{carol_user_id}}" },
|
||||
"role": "owner"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Asserts]
|
||||
jsonpath "$.role" == "owner"
|
||||
|
||||
|
||||
# 24b — Owner promotes Bob (Viewer) to Owner.
|
||||
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{bob_user_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "role": "owner" }
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.role" == "owner"
|
||||
|
||||
|
||||
# 24c — Owner demotes Bob back to Viewer (last-owner protection
|
||||
# allows it: Carol + Alice-via-group remain as Owners).
|
||||
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{bob_user_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "role": "viewer" }
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.role" == "viewer"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 25 — Viewer CANNOT edit drive members.
|
||||
# Bob is Viewer. Every member-mutation verb → 404
|
||||
# (anti-enum: same shape as if the drive didn't exist).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/drives/{{team_drive_id}}/members
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{alice_user_id}}" },
|
||||
"role": "editor"
|
||||
}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Content-Type: application/json
|
||||
{ "role": "viewer" }
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
|
||||
Authorization: Bearer {{bob_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 26 — Editor CANNOT edit drive members + CANNOT rename
|
||||
# the drive (rename = PUT on the drive's root folder).
|
||||
# Promote Bob to Editor first (Owner-driven).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{bob_user_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "role": "editor" }
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# 26a — Editor POST /api/drives/{id}/members → 404.
|
||||
POST {{base_url}}/api/drives/{{team_drive_id}}/members
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{alice_user_id}}" },
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# 26b — Editor PATCH a member → 404.
|
||||
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Content-Type: application/json
|
||||
{ "role": "viewer" }
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# 26c — Editor DELETE a member → 404.
|
||||
DELETE {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
|
||||
Authorization: Bearer {{bob_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# 26d — Editor renames the drive (root folder) → 404.
|
||||
# Editor bundle has Update on content, but the rename
|
||||
# endpoint uses authz.require(...) and Editor's bundle on
|
||||
# the drive root resolves through the same drive precheck.
|
||||
# Editor has Update; folder rename uses Update; so this
|
||||
# should actually SUCCEED. Asserting 200 to reflect the
|
||||
# real engine semantics — the "Editor can rename the drive"
|
||||
# fact is a real product question worth surfacing here.
|
||||
# If you want rename to be Owner-only, the fix is in the
|
||||
# folder service (require Manage, not Update).
|
||||
PUT {{base_url}}/api/folders/{{team_root_folder_id}}/rename
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "team-drive-editor-renamed" }
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# Restore the previous name so downstream assertions don't drift.
|
||||
PUT {{base_url}}/api/folders/{{team_root_folder_id}}/rename
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "team-drive-renamed" }
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 27 — Editor CAN modify content in the drive (positive).
|
||||
# Confirms the Editor bundle isn't accidentally too
|
||||
# restrictive — they can create folders under the drive
|
||||
# root via the drive precheck.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"name": "editor-created-folder",
|
||||
"parent_id": "{{team_root_folder_id}}"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Asserts]
|
||||
jsonpath "$.name" == "editor-created-folder"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 28 — Viewer CAN read content in the drive (positive).
|
||||
# Demote Bob back to Viewer, then confirm he can still
|
||||
# list the drive's root folder.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{bob_user_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "role": "viewer" }
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
GET {{base_url}}/api/folders/{{team_root_folder_id}}
|
||||
Authorization: Bearer {{bob_token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 29 — A user with NO role on the drive cannot edit members.
|
||||
# Provision a fresh user (mbr_dave) with no grants on
|
||||
# the team drive; every member-mutation verb → 404.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{ "username": "mbr_dave", "password": "MbrDavePassword1!", "email": "mbr_dave@example.com", "role": "user" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
dave_user_id: jsonpath "$.id"
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "mbr_dave", "password": "MbrDavePassword1!" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
dave_token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
# 29a — Non-member POST → 404 (the drive itself appears not to exist).
|
||||
POST {{base_url}}/api/drives/{{team_drive_id}}/members
|
||||
Authorization: Bearer {{dave_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{alice_user_id}}" },
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# 29b — Non-member PATCH → 404.
|
||||
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
|
||||
Authorization: Bearer {{dave_token}}
|
||||
Content-Type: application/json
|
||||
{ "role": "viewer" }
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# 29c — Non-member DELETE → 404.
|
||||
DELETE {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
|
||||
Authorization: Bearer {{dave_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# 29d — Non-member GET members → 404 too (anti-enum: no member-list leak).
|
||||
GET {{base_url}}/api/drives/{{team_drive_id}}/members
|
||||
Authorization: Bearer {{dave_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
@@ -141,18 +141,16 @@ body contains "{{dora_id}}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 8 — Teardown. Order matters: remove the nested group-member
|
||||
# before deleting Group_B, so the FK cascade doesn't get
|
||||
# ahead of us; remove dora's direct membership similarly.
|
||||
# 8 — Teardown.
|
||||
# The D3a self-defense in `subject_group_service::remove_member`
|
||||
# refuses any individual membership removal that would empty a
|
||||
# seeded group's transitive user set — which BOTH of these
|
||||
# would (removing Group_B from A leaves A with no users;
|
||||
# removing dora from B leaves B with no users). So we skip the
|
||||
# manual member-by-member unwind and just DELETE the groups
|
||||
# directly. `delete_group` cascades through `subject_group_members`
|
||||
# via FK and isn't subject to the per-remove guard.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/groups/{{group_a_id}}/members/group/{{group_b_id}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
HTTP 204
|
||||
|
||||
DELETE {{base_url}}/api/groups/{{group_b_id}}/members/user/{{dora_id}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
HTTP 204
|
||||
|
||||
DELETE {{base_url}}/api/groups/{{group_a_id}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
HTTP 204
|
||||
|
||||
@@ -234,18 +234,66 @@ HTTP 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — Remove grace from engineering, then re-check access (after cache TTL).
|
||||
# Note: the authz cache has a 30s TTL — Hurl tests run within seconds so
|
||||
# grace may still see the folder during the cache window. We assert the
|
||||
# membership removal succeeded; the post-TTL denial is exercised by the
|
||||
# Rust integration tests, not here (test runtime cost).
|
||||
# Step 8 — Self-defense on group remove_member.
|
||||
# A group must not drop to 0 transitive users once seeded —
|
||||
# without this guard, an admin could empty a group that owns
|
||||
# a shared drive (D3a), leaving the drive with no effective
|
||||
# Owner. Conservative-by-default: the rule applies to every
|
||||
# group, not just drive-owning ones.
|
||||
#
|
||||
# So the first attempt to remove grace (the sole member) is
|
||||
# refused with 400. We then seed the group with a second user,
|
||||
# re-attempt the removal, and assert it now succeeds — the
|
||||
# authz cascade tests below depend on grace being out of the
|
||||
# group.
|
||||
#
|
||||
# Note: the authz cache has a 30s TTL — Hurl tests run within
|
||||
# seconds so grace may still see the folder during the cache
|
||||
# window. We assert the membership removal succeeded; the
|
||||
# post-TTL denial is exercised by the Rust integration tests,
|
||||
# not here (test runtime cost).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 8a — First removal refused: grace is the sole transitive user.
|
||||
DELETE {{base_url}}/api/groups/{{engineers_id}}/members/user/{{grace_user_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 400
|
||||
|
||||
|
||||
# 8b — Seed engineering with a second user so the removal can succeed.
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "username": "grp_helper", "password": "GrpHelperPwd1!", "email": "grp_helper@example.com", "role": "user" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
helper_user_id: jsonpath "$.id"
|
||||
|
||||
|
||||
POST {{base_url}}/api/groups/{{engineers_id}}/members
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "user_id": "{{helper_user_id}}" }
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# 8c — Grace removal now succeeds: engineering still has grp_helper.
|
||||
DELETE {{base_url}}/api/groups/{{engineers_id}}/members/user/{{grace_user_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# 8d — Confirming the invariant still holds: removing the last user
|
||||
# (grp_helper) is again refused.
|
||||
DELETE {{base_url}}/api/groups/{{engineers_id}}/members/user/{{helper_user_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 400
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — Authenticated /api/groups/search (no admin role required).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user