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:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user