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:
Bradley Nelson
2026-06-25 01:07:03 -06:00
parent d8229650ef
commit 07320a3925
7 changed files with 510 additions and 4 deletions
@@ -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
View File
@@ -1,3 +1,4 @@
pub mod admin_external_mounts;
pub mod admin_handler;
pub mod app_password_handler;
pub mod auth_handler;