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
@@ -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());