feat(mounts): P4 — admin CRUD endpoints + frontend admin tab
Mounts are now configurable at runtime without DB surgery.
Backend (admin-gated, /api/admin/external-mounts):
- GET list all configured mounts
- POST create: validate the provider config up front, create a mount-root
folder under the admin's drive, insert the row, hot-reload the registry
- DELETE remove the mount row + its root folder (host content is left intact),
then hot-reload
- ExternalMountRepositoryPort gains create/delete (PG impl); registry.reload()
runs in-process so changes are live immediately
- audit lines event="external_mount.config" action=create|delete
Frontend:
- admin.ts endpoints: listExternalMounts / createExternalMount / deleteExternalMount
- new "External Mounts" tab in the admin page: add form (name + host path +
read-only) and a list with delete
Integration test for the repo create/delete round-trip (testcontainers).
This commit is contained in:
@@ -168,11 +168,46 @@ pub struct ExternalMountRecord {
|
||||
pub mount_path: String,
|
||||
}
|
||||
|
||||
/// The persistable columns of an `external_mounts` row (admin create).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewExternalMount {
|
||||
/// The mount-root folder UUID this mount attaches to.
|
||||
pub mount_folder_id: Uuid,
|
||||
/// Provider kind.
|
||||
pub kind: String,
|
||||
/// Provider-specific connection config.
|
||||
pub config: serde_json::Value,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// Owner of the mount configuration.
|
||||
pub owner_id: Uuid,
|
||||
/// Whether the mount is read-only.
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
/// Persistence port for external mount configuration.
|
||||
#[async_trait]
|
||||
pub trait ExternalMountRepositoryPort: Send + Sync {
|
||||
/// Load every (non-trashed) mount joined with its folder, for registry build.
|
||||
async fn list_all(&self) -> Result<Vec<ExternalMountRecord>, DomainError>;
|
||||
|
||||
/// Insert a new mount row. Default errors — only the PG repo implements it
|
||||
/// (test doubles need `list_all` only).
|
||||
async fn create(&self, _mount: &NewExternalMount) -> Result<(), DomainError> {
|
||||
Err(DomainError::operation_not_supported(
|
||||
"ExternalMount",
|
||||
"create is not supported by this repository",
|
||||
))
|
||||
}
|
||||
|
||||
/// Delete a mount row by its mount-root folder id. Returns `true` when a
|
||||
/// row was removed. Default errors (see `create`).
|
||||
async fn delete(&self, _mount_folder_id: Uuid) -> Result<bool, DomainError> {
|
||||
Err(DomainError::operation_not_supported(
|
||||
"ExternalMount",
|
||||
"delete is not supported by this repository",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds [`ExternalMountProvider`]s from a `kind` + `config` pair.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! PostgreSQL persistence for external mount configuration.
|
||||
//!
|
||||
//! P1 only needs [`ExternalMountRepositoryPort::list_all`] to (re)build the
|
||||
//! in-memory registry; admin CRUD lands with P4.
|
||||
//! `list_all` (re)builds the in-memory registry; `create`/`delete` back the
|
||||
//! admin CRUD endpoints.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -10,7 +10,7 @@ use sqlx::{PgPool, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::ports::external_mount_ports::{
|
||||
ExternalMountRecord, ExternalMountRepositoryPort,
|
||||
ExternalMountRecord, ExternalMountRepositoryPort, NewExternalMount,
|
||||
};
|
||||
use crate::domain::errors::DomainError;
|
||||
|
||||
@@ -91,6 +91,37 @@ impl ExternalMountRepositoryPort for ExternalMountPgRepository {
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn create(&self, mount: &NewExternalMount) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO storage.external_mounts
|
||||
(mount_folder_id, kind, config, name, owner_id, read_only)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
)
|
||||
.bind(mount.mount_folder_id)
|
||||
.bind(&mount.kind)
|
||||
.bind(&mount.config)
|
||||
.bind(&mount.name)
|
||||
.bind(mount.owner_id)
|
||||
.bind(mount.read_only)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::database_error(format!("failed to create external mount: {e}"))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, mount_folder_id: Uuid) -> Result<bool, DomainError> {
|
||||
let res = sqlx::query("DELETE FROM storage.external_mounts WHERE mount_folder_id = $1")
|
||||
.bind(mount_folder_id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::database_error(format!("failed to delete external mount: {e}"))
|
||||
})?;
|
||||
Ok(res.rows_affected() > 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Gated on `test` too: the module uses the `testcontainers` dev-dependency,
|
||||
@@ -151,4 +182,33 @@ mod integration_tests {
|
||||
let repo = ExternalMountPgRepository::new(pool.clone());
|
||||
assert!(repo.list_all().await.expect("list_all").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_and_delete_round_trip() {
|
||||
use crate::application::ports::external_mount_ports::NewExternalMount;
|
||||
let (_c, pool) = fresh_db().await;
|
||||
let p = provision_folder(&pool, "owner", "Media").await;
|
||||
let repo = ExternalMountPgRepository::new(pool.clone());
|
||||
|
||||
repo.create(&NewExternalMount {
|
||||
mount_folder_id: p.mount_folder_id,
|
||||
kind: "local_fs".to_string(),
|
||||
config: serde_json::json!({ "path": "/srv/x" }),
|
||||
name: "Media".to_string(),
|
||||
owner_id: p.owner_id,
|
||||
read_only: true,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
let mounts = repo.list_all().await.expect("list");
|
||||
assert_eq!(mounts.len(), 1);
|
||||
assert!(mounts[0].read_only);
|
||||
assert_eq!(mounts[0].mount_folder_id, p.mount_folder_id);
|
||||
|
||||
assert!(repo.delete(p.mount_folder_id).await.expect("delete"));
|
||||
assert!(repo.list_all().await.expect("list").is_empty());
|
||||
// Deleting a non-existent mount returns false.
|
||||
assert!(!repo.delete(p.mount_folder_id).await.expect("delete again"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Admin CRUD for external file mounts (`/api/admin/external-mounts`).
|
||||
//!
|
||||
//! Creating a mount: validate the backend config, create a mount-root folder
|
||||
//! under the admin's drive, insert the `external_mounts` row, then hot-reload
|
||||
//! the in-memory registry. Deleting: remove the row + the folder and reload.
|
||||
//! Every endpoint is admin-gated.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::ports::external_mount_ports::{
|
||||
ExternalMountRecord, ExternalMountRepositoryPort, MountProviderFactory, NewExternalMount,
|
||||
};
|
||||
use crate::common::di::AppState;
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::infrastructure::repositories::pg::ExternalMountPgRepository;
|
||||
use crate::infrastructure::services::mount_provider_factory::DefaultMountProviderFactory;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::admin::require_admin;
|
||||
|
||||
/// JSON view of a configured mount.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ExternalMountResponse {
|
||||
pub mount_folder_id: String,
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub owner_id: String,
|
||||
pub read_only: bool,
|
||||
pub drive_id: String,
|
||||
pub mount_path: String,
|
||||
pub config: serde_json::Value,
|
||||
}
|
||||
|
||||
impl From<ExternalMountRecord> for ExternalMountResponse {
|
||||
fn from(r: ExternalMountRecord) -> Self {
|
||||
Self {
|
||||
mount_folder_id: r.mount_folder_id.to_string(),
|
||||
name: r.name,
|
||||
kind: r.kind,
|
||||
owner_id: r.owner_id.to_string(),
|
||||
read_only: r.read_only,
|
||||
drive_id: r.drive_id.to_string(),
|
||||
mount_path: r.mount_path,
|
||||
config: r.config,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Request body for creating a mount.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateExternalMountRequest {
|
||||
/// Display name (also the mount-root folder name).
|
||||
pub name: String,
|
||||
/// Absolute host path for the `local_fs` provider.
|
||||
pub host_path: String,
|
||||
/// Provider kind. Defaults to `local_fs`.
|
||||
#[serde(default = "default_kind")]
|
||||
pub kind: String,
|
||||
/// When true, the mount refuses all mutations.
|
||||
#[serde(default)]
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
fn default_kind() -> String {
|
||||
"local_fs".to_string()
|
||||
}
|
||||
|
||||
fn pool(state: &AppState) -> Result<Arc<sqlx::PgPool>, AppError> {
|
||||
state
|
||||
.db_pool
|
||||
.clone()
|
||||
.ok_or_else(|| AppError::internal_error("Database not available"))
|
||||
}
|
||||
|
||||
/// `GET /api/admin/external-mounts` — list all configured mounts.
|
||||
pub async fn list_external_mounts(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
require_admin(&state, &headers).await?;
|
||||
let repo = ExternalMountPgRepository::new(pool(&state)?);
|
||||
let mounts = repo
|
||||
.list_all()
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("list external mounts: {e}")))?;
|
||||
let out: Vec<ExternalMountResponse> = mounts.into_iter().map(Into::into).collect();
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
/// `POST /api/admin/external-mounts` — create a mount in the admin's drive.
|
||||
pub async fn create_external_mount(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<CreateExternalMountRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (admin_id, _role) = require_admin(&state, &headers).await?;
|
||||
|
||||
if req.name.trim().is_empty() {
|
||||
return Err(AppError::bad_request("Mount name must not be empty"));
|
||||
}
|
||||
|
||||
// Build the provider config and validate it up front (path exists, etc.).
|
||||
let config = serde_json::json!({ "path": req.host_path, "read_only": req.read_only });
|
||||
let factory = DefaultMountProviderFactory::new();
|
||||
factory
|
||||
.build(&req.kind, &config)
|
||||
.await
|
||||
.map_err(|e| AppError::bad_request(format!("invalid mount configuration: {e}")))?;
|
||||
|
||||
// Create the mount-root folder under the admin's default drive root.
|
||||
let drive = state
|
||||
.drive_repo
|
||||
.find_default_for_user(admin_id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("find default drive: {e}")))?;
|
||||
let root_folder_id = drive.drive.root_folder_id.to_string();
|
||||
|
||||
let folder = state
|
||||
.repositories
|
||||
.folder_repository
|
||||
.create_folder(req.name.clone(), Some(root_folder_id), admin_id)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
let mount_folder_id =
|
||||
Uuid::parse_str(folder.id()).map_err(|_| AppError::internal_error("bad folder id"))?;
|
||||
|
||||
let repo = ExternalMountPgRepository::new(pool(&state)?);
|
||||
repo.create(&NewExternalMount {
|
||||
mount_folder_id,
|
||||
kind: req.kind.clone(),
|
||||
config,
|
||||
name: req.name.clone(),
|
||||
owner_id: admin_id,
|
||||
read_only: req.read_only,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("create mount row: {e}")))?;
|
||||
|
||||
// Hot-reload so the new mount is live immediately.
|
||||
state.mount_router.registry().reload(&repo, &factory).await;
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "external_mount.config",
|
||||
action = "create",
|
||||
mount_id = %mount_folder_id,
|
||||
caller_id = %admin_id,
|
||||
kind = %req.kind,
|
||||
reason = "external_mount_admin",
|
||||
"👮🏻♂️ external mount created",
|
||||
);
|
||||
|
||||
// Return the freshly created mount.
|
||||
let created = repo
|
||||
.list_all()
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("reload mounts: {e}")))?
|
||||
.into_iter()
|
||||
.find(|m| m.mount_folder_id == mount_folder_id)
|
||||
.map(ExternalMountResponse::from)
|
||||
.ok_or_else(|| AppError::internal_error("created mount not found"))?;
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
}
|
||||
|
||||
/// `DELETE /api/admin/external-mounts/{id}` — remove a mount (and its root
|
||||
/// folder). The host filesystem content is untouched.
|
||||
pub async fn delete_external_mount(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (admin_id, _role) = require_admin(&state, &headers).await?;
|
||||
|
||||
let repo = ExternalMountPgRepository::new(pool(&state)?);
|
||||
let removed = repo
|
||||
.delete(id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("delete mount row: {e}")))?;
|
||||
if !removed {
|
||||
return Err(AppError::not_found("Mount not found"));
|
||||
}
|
||||
|
||||
// Remove the mount-root folder row (host content is left intact).
|
||||
state
|
||||
.repositories
|
||||
.folder_repository
|
||||
.delete_folder(&id.to_string())
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let factory = DefaultMountProviderFactory::new();
|
||||
state.mount_router.registry().reload(&repo, &factory).await;
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "external_mount.config",
|
||||
action = "delete",
|
||||
mount_id = %id,
|
||||
caller_id = %admin_id,
|
||||
reason = "external_mount_admin",
|
||||
"👮🏻♂️ external mount deleted",
|
||||
);
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -33,7 +33,17 @@ use uuid::Uuid;
|
||||
|
||||
/// Admin API routes — all require admin role.
|
||||
pub fn admin_routes() -> Router<Arc<AppState>> {
|
||||
use super::admin_external_mounts as ext_mounts;
|
||||
Router::new()
|
||||
// External file mounts
|
||||
.route(
|
||||
"/external-mounts",
|
||||
get(ext_mounts::list_external_mounts).post(ext_mounts::create_external_mount),
|
||||
)
|
||||
.route(
|
||||
"/external-mounts/{id}",
|
||||
delete(ext_mounts::delete_external_mount),
|
||||
)
|
||||
// OIDC settings
|
||||
.route("/settings/oidc", get(get_oidc_settings))
|
||||
.route("/settings/oidc", put(save_oidc_settings))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod admin_external_mounts;
|
||||
pub mod admin_handler;
|
||||
pub mod app_password_handler;
|
||||
pub mod auth_handler;
|
||||
|
||||
Reference in New Issue
Block a user