mirror of
https://github.com/sol1/rustguac.git
synced 2026-10-25 21:19:45 +00:00
ccd2badafe
A folder can lend a username, password, private key and domain per session
type to entries that have no password or private key of their own, so an
entry created without secrets (a NetBox webhook, a bulk import) connects the
moment it exists. The defaults live at `<folder>/.defaults` beside
`.config`, are reserved like it (never listed, not a valid entry name) and
are removed with the folder. `GET/PUT/DELETE .../defaults` manage them
(secrets write-only, audited as update_folder_defaults /
delete_folder_defaults) and the folder dialog gains a Default credentials
section. Listings report such entries as `has_credentials: true` with
`inherited_credentials: true`. An entry's own credentials always win and an
entry set to prompt still prompts.
A subfolder without its own `.defaults` takes its nearest ancestor's, per
type, but the walk follows the access rule: it only continues past a folder
whose `.config` has `inherit_from_parent: true`. That flag is how a folder
is isolated for access, so a key set on a parent must not reach a subfolder
shared with someone else through `allowed_users`. The isolated folder's own
`.defaults` still apply to its entries. A folder with no `.config`, or one
written before the flag existed, counts as not inheriting, exactly as
`resolve_folder_access` treats it. The walk is `resolve_effective_defaults`
over a small `DefaultsSource` trait so it is tested without a Vault.
Both connect paths resolve defaults through one helper,
`lend_folder_defaults`: the Connections endpoint
(`POST .../entries/:entry/connect`) and `/api/connect` quick links, so an
entry that works from the page works from a link. `rustguac vault-migrate`
copies each folder's `.defaults` alongside its `.config`, so a DR split keeps
them (the dry run lists them as `[defaults]`).
Docs cover the storage layout, the inheritance rule and its boundary, the
migrate behaviour, and the warning that a web entry using a default
password has that password typed into a page the user can read.
Part 2/3 of #253.
(cherry picked from commit cc01469b07)
7670 lines
259 KiB
Rust
7670 lines
259 KiB
Rust
//! REST API routes.
|
|
|
|
use crate::auth::{self, client_ip, role_level, AuthIdentity, TrustedProxies};
|
|
use crate::db::{self, Db};
|
|
use crate::session::{CreateSessionRequest, SessionManager, SessionType};
|
|
use crate::vault::{AddressBookEntry, DefaultsSource, FolderConfig, VaultClient, VaultError};
|
|
use axum::{
|
|
body::Body,
|
|
extract::{ConnectInfo, Path, Query, State},
|
|
http::StatusCode,
|
|
response::{IntoResponse, Redirect, Response},
|
|
Extension, Json,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::json;
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
use tokio_util::io::ReaderStream;
|
|
use uuid::Uuid;
|
|
|
|
pub type AppState = Arc<SessionManager>;
|
|
|
|
/// POST /api/sessions — Create a new session. Requires operator+.
|
|
pub async fn create_session(
|
|
State(manager): State<AppState>,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: axum::http::HeaderMap,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
trusted: Option<Extension<TrustedProxies>>,
|
|
Json(mut req): Json<CreateSessionRequest>,
|
|
) -> impl IntoResponse {
|
|
let identity = identity.map(|Extension(id)| id);
|
|
// Ad-hoc targets from anyone below admin get every network fence.
|
|
req.fence_all_targets = !identity.as_ref().is_some_and(|id| id.has_role("admin"));
|
|
let caller_name = identity
|
|
.as_ref()
|
|
.map(|id| id.display_name().to_string())
|
|
.unwrap_or_else(|| "unknown".into());
|
|
|
|
// Require poweruser+ role for ad-hoc session creation
|
|
if let Some(ref id) = identity {
|
|
if !id.has_role("poweruser") {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "insufficient permissions — poweruser role required for ad-hoc sessions"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
// `owner` creates the session on someone else's behalf: they, not the
|
|
// caller, are its creator and the only identity that may connect to it
|
|
// first. Admin-only, since it hands that user a logged-in session.
|
|
let admin_name = match req.owner.as_deref() {
|
|
None => caller_name.clone(),
|
|
Some(owner) => {
|
|
if !identity.as_ref().is_some_and(|id| id.has_role("admin")) {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required to create a session for another owner"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
match validate_session_owner(owner) {
|
|
Ok(o) => o,
|
|
Err(msg) => {
|
|
return (StatusCode::BAD_REQUEST, Json(json!({ "error": msg })))
|
|
.into_response();
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
let proxies = trusted.map(|Extension(t)| t.0).unwrap_or_default();
|
|
let client_ip = client_ip(&headers, addr.ip(), &proxies);
|
|
|
|
let target = match req.session_type {
|
|
crate::session::SessionType::Ssh => {
|
|
format!(
|
|
"{}:{}",
|
|
req.hostname.as_deref().unwrap_or("?"),
|
|
req.port.unwrap_or(22)
|
|
)
|
|
}
|
|
crate::session::SessionType::Rdp => {
|
|
format!(
|
|
"{}:{}",
|
|
req.hostname.as_deref().unwrap_or("?"),
|
|
req.port.unwrap_or(3389)
|
|
)
|
|
}
|
|
crate::session::SessionType::Vnc => {
|
|
format!(
|
|
"{}:{}",
|
|
req.hostname.as_deref().unwrap_or("?"),
|
|
req.port.unwrap_or(5900)
|
|
)
|
|
}
|
|
crate::session::SessionType::Spice => {
|
|
format!(
|
|
"{}:{}",
|
|
req.hostname.as_deref().unwrap_or("?"),
|
|
req.port.unwrap_or(5900)
|
|
)
|
|
}
|
|
crate::session::SessionType::Proxmox => {
|
|
format!(
|
|
"{}/{}",
|
|
req.proxmox_node.as_deref().unwrap_or("?"),
|
|
req.proxmox_vmid
|
|
.map(|v| v.to_string())
|
|
.unwrap_or_else(|| "?".into())
|
|
)
|
|
}
|
|
crate::session::SessionType::Web => req.url.as_deref().unwrap_or("?").to_string(),
|
|
crate::session::SessionType::Vdi => {
|
|
req.container_image.as_deref().unwrap_or("?").to_string()
|
|
}
|
|
};
|
|
|
|
tracing::info!(
|
|
admin = %admin_name,
|
|
caller = %caller_name,
|
|
client_ip = %client_ip,
|
|
session_type = ?req.session_type,
|
|
target = %target,
|
|
"Session creation requested"
|
|
);
|
|
|
|
match manager.create_session(req, admin_name.clone()).await {
|
|
Ok(info) => {
|
|
tracing::info!(
|
|
admin = %admin_name,
|
|
client_ip = %client_ip,
|
|
session_id = %info.session_id,
|
|
session_type = ?info.session_type,
|
|
target = %target,
|
|
"Session created successfully"
|
|
);
|
|
(StatusCode::CREATED, Json(json!(info))).into_response()
|
|
}
|
|
Err(e) => {
|
|
let msg = e.to_string();
|
|
tracing::error!(
|
|
admin = %admin_name,
|
|
client_ip = %client_ip,
|
|
target = %target,
|
|
error = %msg,
|
|
"Session creation failed"
|
|
);
|
|
let status = match &e {
|
|
crate::session::SessionError::ValidationError(_) => StatusCode::BAD_REQUEST,
|
|
_ => StatusCode::BAD_GATEWAY,
|
|
};
|
|
(status, Json(json!({ "error": msg }))).into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Normalise and check the `owner` of a session created on someone's
|
|
/// behalf. It is compared against the connecting user's email, so it must
|
|
/// look like an identity rather than arbitrary text.
|
|
fn validate_session_owner(owner: &str) -> Result<String, &'static str> {
|
|
let owner = owner.trim();
|
|
if owner.is_empty() {
|
|
return Err("owner must not be empty");
|
|
}
|
|
if owner.len() > 320 {
|
|
return Err("owner is too long");
|
|
}
|
|
if owner.chars().any(|c| c.is_control() || c.is_whitespace()) {
|
|
return Err("owner must not contain whitespace or control characters");
|
|
}
|
|
Ok(owner.to_string())
|
|
}
|
|
|
|
/// Strip share_url from session info unless the caller is the session creator or an admin.
|
|
fn redact_share_url(
|
|
mut info: crate::session::SessionInfo,
|
|
identity: &Option<Extension<AuthIdentity>>,
|
|
) -> crate::session::SessionInfo {
|
|
let is_owner_or_admin = match identity {
|
|
Some(Extension(id)) => id.has_role("admin") || id.display_name() == info.created_by,
|
|
None => false,
|
|
};
|
|
if !is_owner_or_admin {
|
|
info.share_url = None;
|
|
info.share_url_readonly = None;
|
|
}
|
|
info
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
pub struct ListSessionsQuery {
|
|
/// When `true`, admins receive all active sessions instead of only
|
|
/// their own. Ignored for non-admins. Used by the Sessions
|
|
/// management page; the Address Book keeps the owner-only default.
|
|
#[serde(default)]
|
|
pub all: bool,
|
|
}
|
|
|
|
/// GET /api/sessions — List active sessions.
|
|
/// Default behaviour (GitHub #102): callers see only sessions they
|
|
/// created. The Sessions management page passes `?all=true` to let
|
|
/// admins see everyone.
|
|
pub async fn list_sessions(
|
|
State(manager): State<AppState>,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
axum::extract::Query(q): axum::extract::Query<ListSessionsQuery>,
|
|
) -> impl IntoResponse {
|
|
let is_admin = identity
|
|
.as_ref()
|
|
.map(|Extension(id)| id.has_role("admin"))
|
|
.unwrap_or(false);
|
|
let owner = identity
|
|
.as_ref()
|
|
.map(|Extension(id)| id.display_name().to_string());
|
|
let show_all = q.all && is_admin;
|
|
|
|
let sessions: Vec<_> = manager
|
|
.list_sessions()
|
|
.await
|
|
.into_iter()
|
|
.filter(|s| show_all || owner.as_deref().map(|o| s.created_by == o).unwrap_or(false))
|
|
.map(|s| redact_share_url(s, &identity))
|
|
.collect();
|
|
Json(json!(sessions))
|
|
}
|
|
|
|
/// GET /api/sessions/:id — Get session info.
|
|
/// Non-admins can only inspect their own sessions (GitHub #102).
|
|
pub async fn get_session(
|
|
State(manager): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
) -> impl IntoResponse {
|
|
match manager.get_session(id).await {
|
|
Some(info) => {
|
|
let is_admin = identity
|
|
.as_ref()
|
|
.map(|Extension(id)| id.has_role("admin"))
|
|
.unwrap_or(false);
|
|
let is_owner = identity
|
|
.as_ref()
|
|
.map(|Extension(id)| info.created_by == id.display_name())
|
|
.unwrap_or(false);
|
|
if !is_admin && !is_owner {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({ "error": "session not found" })),
|
|
)
|
|
.into_response();
|
|
}
|
|
let info = redact_share_url(info, &identity);
|
|
(StatusCode::OK, Json(json!(info))).into_response()
|
|
}
|
|
None => (
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({ "error": "session not found" })),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// DELETE /api/sessions/:id — Terminate a session.
|
|
/// Admins can delete any session. Operators can only delete their own sessions.
|
|
pub async fn delete_session(
|
|
State(manager): State<AppState>,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: axum::http::HeaderMap,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
trusted: Option<Extension<TrustedProxies>>,
|
|
Path(id): Path<Uuid>,
|
|
) -> impl IntoResponse {
|
|
let proxies = trusted.map(|Extension(t)| t.0).unwrap_or_default();
|
|
let ip = client_ip(&headers, addr.ip(), &proxies);
|
|
|
|
let id_inner = match identity {
|
|
Some(Extension(ref id_inner)) => id_inner,
|
|
None => {
|
|
return (
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(json!({"error": "authentication required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
if !id_inner.has_role("operator") {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "insufficient permissions — operator role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
// Operators can only delete their own sessions; admins can delete any
|
|
if !id_inner.has_role("admin") {
|
|
if let Some(creator) = manager.get_session_creator(id).await {
|
|
if creator != id_inner.display_name() {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "you can only delete your own sessions"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
}
|
|
|
|
if manager.delete_session(id).await {
|
|
tracing::info!(
|
|
session_id = %id,
|
|
identity = %id_inner.display_name(),
|
|
client_ip = %ip,
|
|
"Session deleted"
|
|
);
|
|
StatusCode::NO_CONTENT.into_response()
|
|
} else {
|
|
(
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({ "error": "session not found" })),
|
|
)
|
|
.into_response()
|
|
}
|
|
}
|
|
|
|
/// PUT /api/sessions/:id/thumbnail — Upload a session thumbnail (JPEG).
|
|
/// Called by the client periodically to update the session preview.
|
|
/// Only the session owner (or an admin) can upload.
|
|
pub async fn put_session_thumbnail(
|
|
State(manager): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
body: axum::body::Bytes,
|
|
) -> impl IntoResponse {
|
|
// Validate: session must exist
|
|
let Some(info) = manager.get_session(id).await else {
|
|
return StatusCode::NOT_FOUND.into_response();
|
|
};
|
|
// Authorise: owner or admin only (privacy — GitHub #102)
|
|
let is_admin = identity
|
|
.as_ref()
|
|
.map(|Extension(id)| id.has_role("admin"))
|
|
.unwrap_or(false);
|
|
let is_owner = identity
|
|
.as_ref()
|
|
.map(|Extension(id)| info.created_by == id.display_name())
|
|
.unwrap_or(false);
|
|
if !is_admin && !is_owner {
|
|
return StatusCode::NOT_FOUND.into_response();
|
|
}
|
|
// Reject oversized thumbnails (100KB max)
|
|
if body.len() > 100_000 {
|
|
return StatusCode::PAYLOAD_TOO_LARGE.into_response();
|
|
}
|
|
// Validate JPEG magic bytes
|
|
if !is_jpeg_magic(&body) {
|
|
return StatusCode::BAD_REQUEST.into_response();
|
|
}
|
|
|
|
let dir = manager.thumbnails_dir();
|
|
if let Err(e) = std::fs::create_dir_all(&dir) {
|
|
tracing::warn!("Failed to create thumbnails dir: {}", e);
|
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
|
}
|
|
|
|
let path = manager.thumbnail_path(id);
|
|
match tokio::fs::write(&path, &body).await {
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(e) => {
|
|
tracing::warn!(session_id = %id, "Failed to write thumbnail: {}", e);
|
|
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// GET /api/sessions/:id/thumbnail — Serve a session thumbnail (JPEG).
|
|
/// Only the session owner (or an admin) can read. Returns 404 for
|
|
/// non-owners so we don't leak session existence.
|
|
pub async fn get_session_thumbnail(
|
|
State(manager): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
) -> impl IntoResponse {
|
|
let Some(info) = manager.get_session(id).await else {
|
|
return StatusCode::NOT_FOUND.into_response();
|
|
};
|
|
let is_admin = identity
|
|
.as_ref()
|
|
.map(|Extension(id)| id.has_role("admin"))
|
|
.unwrap_or(false);
|
|
let is_owner = identity
|
|
.as_ref()
|
|
.map(|Extension(id)| info.created_by == id.display_name())
|
|
.unwrap_or(false);
|
|
if !is_admin && !is_owner {
|
|
return StatusCode::NOT_FOUND.into_response();
|
|
}
|
|
|
|
let path = manager.thumbnail_path(id);
|
|
match tokio::fs::read(&path).await {
|
|
Ok(data) => (
|
|
StatusCode::OK,
|
|
[
|
|
(axum::http::header::CONTENT_TYPE, "image/jpeg"),
|
|
(axum::http::header::CACHE_CONTROL, "no-cache"),
|
|
],
|
|
data,
|
|
)
|
|
.into_response(),
|
|
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
|
}
|
|
}
|
|
|
|
/// Optional body for `POST /api/sessions/:id/shadow`.
|
|
#[derive(Deserialize, Default)]
|
|
pub struct ShadowRequest {
|
|
/// Join as a view-only viewer. Default false (keyboard and mouse
|
|
/// control), which is what shadow has always granted.
|
|
#[serde(default)]
|
|
pub read_only: bool,
|
|
}
|
|
|
|
/// POST /api/sessions/:id/shadow — Mint a short-lived viewer token
|
|
/// that lets the caller join another user's active session without that
|
|
/// user's cooperation. Admin-only. The raw token is returned once and a
|
|
/// `token_audit_log` entry is written. Tokens expire after 10 minutes.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn shadow_session(
|
|
State(manager): State<AppState>,
|
|
Extension(database): Extension<Db>,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: axum::http::HeaderMap,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
trusted: Option<Extension<TrustedProxies>>,
|
|
Path(id): Path<Uuid>,
|
|
body: Option<Json<ShadowRequest>>,
|
|
) -> impl IntoResponse {
|
|
let read_only = body.map(|Json(b)| b.read_only).unwrap_or(false);
|
|
let id_inner = match identity {
|
|
Some(Extension(ref id)) if id.has_role("admin") => id.clone(),
|
|
_ => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
let info = match manager.get_session(id).await {
|
|
Some(i) => i,
|
|
None => {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({"error": "session not found"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
let admin_email = id_inner.display_name().to_string();
|
|
let (raw, expires_at) = match manager.mint_shadow_token(id, &admin_email, read_only).await {
|
|
Ok(t) => t,
|
|
Err(crate::session::SessionError::NotActive) => {
|
|
return (
|
|
StatusCode::CONFLICT,
|
|
Json(json!({"error": "session hasn't started yet; its owner must connect before it can be shadowed"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
Err(_) => {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({"error": "session not found"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
// Audit: record that an admin shadowed a session.
|
|
let proxies = trusted.map(|Extension(t)| t.0).unwrap_or_default();
|
|
let ip = client_ip(&headers, addr.ip(), &proxies).to_string();
|
|
let details = format!(
|
|
"session_id={}, owner={}, read_only={}, expires_at={}",
|
|
id,
|
|
info.created_by,
|
|
read_only,
|
|
expires_at.to_rfc3339()
|
|
);
|
|
let db_clone = database.clone();
|
|
let admin_for_audit = admin_email.clone();
|
|
let _ = tokio::task::spawn_blocking(move || {
|
|
if let Err(e) = db::log_token_event(
|
|
&db_clone,
|
|
None,
|
|
None,
|
|
&admin_for_audit,
|
|
"shadow_session",
|
|
Some(&ip),
|
|
Some(&details),
|
|
) {
|
|
tracing::warn!(error = %e, "failed to write shadow audit log");
|
|
}
|
|
})
|
|
.await;
|
|
|
|
tracing::info!(
|
|
admin = %admin_email,
|
|
session_id = %id,
|
|
owner = %info.created_by,
|
|
"Admin minted shadow token"
|
|
);
|
|
|
|
let url = format!("/client/{}?token={}", id, raw);
|
|
Json(json!({
|
|
"url": url,
|
|
"expires_at": expires_at.to_rfc3339(),
|
|
"ttl_seconds": 600,
|
|
"read_only": read_only,
|
|
}))
|
|
.into_response()
|
|
}
|
|
|
|
/// GET /api/vdi/containers — List running VDI containers for the current user.
|
|
/// Returns containers with metadata for the "Active Desktops" UI.
|
|
pub async fn list_vdi_containers(
|
|
State(manager): State<AppState>,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
) -> impl IntoResponse {
|
|
let id = match identity {
|
|
Some(Extension(ref id)) if id.has_role("operator") => id,
|
|
_ => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "operator role required"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
let Some(vdi) = manager.vdi_driver() else {
|
|
return Json(json!([])).into_response();
|
|
};
|
|
|
|
let mut containers = match vdi.list_managed_containers_detail().await {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
tracing::warn!("Failed to list VDI containers: {}", e);
|
|
return Json(json!([])).into_response();
|
|
}
|
|
};
|
|
|
|
// Filter to current user's containers
|
|
let current_user = id
|
|
.display_name()
|
|
.split('@')
|
|
.next()
|
|
.unwrap_or("")
|
|
.to_lowercase()
|
|
.chars()
|
|
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
|
|
.collect::<String>();
|
|
|
|
containers.retain(|c| c.username == current_user);
|
|
|
|
// Populate thumbnail URLs and active session status
|
|
for c in &mut containers {
|
|
let vdi_thumb = manager.vdi_thumbnail_path(&c.container_name);
|
|
if vdi_thumb.exists() {
|
|
c.thumbnail_url = Some(format!(
|
|
"/api/vdi/containers/{}/thumbnail",
|
|
c.container_name
|
|
));
|
|
}
|
|
c.has_active_session = manager.has_active_vdi_session(&c.container_id).await;
|
|
}
|
|
|
|
Json(json!(containers)).into_response()
|
|
}
|
|
|
|
/// GET /api/vdi/containers/:name/thumbnail — Serve a VDI container's thumbnail.
|
|
/// Owner-or-admin only: a caller may only read the thumbnail of a container
|
|
/// derived from their own username (admins may read any). Returns 404 for
|
|
/// non-owners so container existence is not leaked.
|
|
pub async fn get_vdi_container_thumbnail(
|
|
State(manager): State<AppState>,
|
|
Path(name): Path<String>,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
) -> impl IntoResponse {
|
|
// Prevent path traversal: container names are alphanumeric + hyphens only
|
|
if !name
|
|
.chars()
|
|
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
|
{
|
|
return StatusCode::BAD_REQUEST.into_response();
|
|
}
|
|
|
|
// Ownership: container names are `rustguac-vdi-{username}` or
|
|
// `rustguac-vdi-{username}-{entry}`, where {username} is derived from the
|
|
// caller's identity exactly as in list_vdi_containers. A caller may only
|
|
// read their own container's thumbnail; admins may read any.
|
|
let Some(Extension(id)) = identity else {
|
|
return StatusCode::NOT_FOUND.into_response();
|
|
};
|
|
let current_user = id
|
|
.display_name()
|
|
.split('@')
|
|
.next()
|
|
.unwrap_or("")
|
|
.to_lowercase()
|
|
.chars()
|
|
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
|
|
.collect::<String>();
|
|
let owns = !current_user.is_empty()
|
|
&& (name == format!("rustguac-vdi-{}", current_user)
|
|
|| name.starts_with(&format!("rustguac-vdi-{}-", current_user)));
|
|
if !id.has_role("admin") && !owns {
|
|
return StatusCode::NOT_FOUND.into_response();
|
|
}
|
|
|
|
let path = manager.vdi_thumbnail_path(&name);
|
|
match tokio::fs::read(&path).await {
|
|
Ok(data) => (
|
|
StatusCode::OK,
|
|
[
|
|
(axum::http::header::CONTENT_TYPE, "image/jpeg"),
|
|
(axum::http::header::CACHE_CONTROL, "no-cache"),
|
|
],
|
|
data,
|
|
)
|
|
.into_response(),
|
|
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
|
}
|
|
}
|
|
|
|
/// GET /api/login-scripts — List available login scripts. Requires operator+.
|
|
pub async fn list_login_scripts(
|
|
State(manager): State<AppState>,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
) -> impl IntoResponse {
|
|
let id_inner = match identity {
|
|
Some(Extension(ref id_inner)) => id_inner,
|
|
None => {
|
|
return (
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(json!({"error": "authentication required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
if !id_inner.has_role("operator") {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "insufficient permissions — operator role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let scripts_dir = std::path::Path::new(&manager.config().login_scripts_dir);
|
|
let mut scripts: Vec<String> = Vec::new();
|
|
if let Ok(entries) = std::fs::read_dir(scripts_dir) {
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.is_file() {
|
|
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
|
// Only list script files, skip hidden/config files
|
|
if !name.starts_with('.')
|
|
&& (name.ends_with(".js") || name.ends_with(".sh") || name.ends_with(".py"))
|
|
&& !name.starts_with("package")
|
|
{
|
|
scripts.push(name.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
scripts.sort();
|
|
Json(json!({ "scripts": scripts })).into_response()
|
|
}
|
|
|
|
/// GET /api/health — Health check.
|
|
pub async fn health() -> impl IntoResponse {
|
|
Json(json!({ "status": "ok" }))
|
|
}
|
|
|
|
/// POST /api/ws-ticket — Create a single-use WebSocket ticket.
|
|
/// Returns a short-lived ticket that can be used in place of an API key
|
|
/// in the WebSocket URL query parameter, preventing key exposure in logs.
|
|
pub async fn create_ws_ticket(
|
|
Extension(ticket_store): Extension<auth::WsTicketStore>,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
) -> impl IntoResponse {
|
|
let identity = match identity {
|
|
Some(Extension(id)) => id,
|
|
None => {
|
|
return (
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(json!({"error": "unauthorized"})),
|
|
)
|
|
}
|
|
};
|
|
let ticket = ticket_store.create(identity);
|
|
(StatusCode::OK, Json(json!({"ticket": ticket})))
|
|
}
|
|
|
|
/// GET /api/system/status — System info for admin panel. Admin only.
|
|
pub async fn system_status(
|
|
State(manager): State<AppState>,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
Extension(vault_configured): Extension<VaultConfigured>,
|
|
) -> impl IntoResponse {
|
|
if !identity
|
|
.as_ref()
|
|
.map(|Extension(id)| id.has_role("admin"))
|
|
.unwrap_or(false)
|
|
{
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let version = env!("CARGO_PKG_VERSION");
|
|
|
|
// Recording disk usage
|
|
let rec_path = manager.recording_path().to_path_buf();
|
|
let (rec_disk_pct, rec_count, rec_size_mb) = tokio::task::spawn_blocking(move || {
|
|
let pct = crate::recording::disk_usage_percent(&rec_path).unwrap_or(0.0);
|
|
let mut count = 0u32;
|
|
let mut total_bytes = 0u64;
|
|
if let Ok(entries) = std::fs::read_dir(&rec_path) {
|
|
for entry in entries.flatten() {
|
|
if entry.path().extension().and_then(|e| e.to_str()) == Some("guac") {
|
|
count += 1;
|
|
total_bytes += entry.metadata().map(|m| m.len()).unwrap_or(0);
|
|
}
|
|
}
|
|
}
|
|
(pct, count, total_bytes as f64 / 1024.0 / 1024.0)
|
|
})
|
|
.await
|
|
.unwrap_or((0.0, 0, 0.0));
|
|
|
|
// Active sessions
|
|
let sessions = manager.list_sessions().await;
|
|
let active_sessions = sessions
|
|
.iter()
|
|
.filter(|s| s.status == crate::session::SessionStatus::Active)
|
|
.count();
|
|
let pending_sessions = sessions
|
|
.iter()
|
|
.filter(|s| s.status == crate::session::SessionStatus::Pending)
|
|
.count();
|
|
|
|
// Vault status
|
|
let vault_ok = if vault_configured.0 {
|
|
manager.config().vault.is_some()
|
|
} else {
|
|
false
|
|
};
|
|
|
|
// User count
|
|
let user_count = {
|
|
let conn = database.lock().unwrap();
|
|
conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get::<_, i64>(0))
|
|
.unwrap_or(0)
|
|
};
|
|
|
|
// Session history total
|
|
let history_total = {
|
|
let conn = database.lock().unwrap();
|
|
conn.query_row("SELECT COUNT(*) FROM session_history", [], |row| {
|
|
row.get::<_, i64>(0)
|
|
})
|
|
.unwrap_or(0)
|
|
};
|
|
|
|
// OIDC configured
|
|
let oidc_configured = manager.config().oidc.is_some();
|
|
|
|
// Drive configured
|
|
let drive_configured = manager.config().drive.is_some();
|
|
|
|
// TLS
|
|
let tls_configured = manager.config().tls.is_some();
|
|
|
|
Json(json!({
|
|
"version": version,
|
|
"sessions": {
|
|
"active": active_sessions,
|
|
"pending": pending_sessions,
|
|
"total_current": sessions.len(),
|
|
},
|
|
"recordings": {
|
|
"count": rec_count,
|
|
"size_mb": (rec_size_mb * 10.0).round() / 10.0,
|
|
"disk_usage_pct": (rec_disk_pct * 10.0).round() / 10.0,
|
|
},
|
|
"vault": {
|
|
"configured": vault_configured.0,
|
|
"connected": vault_ok,
|
|
},
|
|
"users": {
|
|
"count": user_count,
|
|
},
|
|
"history": {
|
|
"total_sessions": history_total,
|
|
},
|
|
"features": {
|
|
"oidc": oidc_configured,
|
|
"drive": drive_configured,
|
|
"tls": tls_configured,
|
|
},
|
|
}))
|
|
.into_response()
|
|
}
|
|
|
|
/// GET /api/auth/status — Returns whether OIDC is enabled. No auth required.
|
|
pub async fn auth_status(
|
|
Extension(oidc_enabled): Extension<OidcEnabled>,
|
|
Extension(oidc): Extension<OidcHandle>,
|
|
Extension(site_title): Extension<SiteTitle>,
|
|
Extension(theme): Extension<ThemeData>,
|
|
Extension(drive_configured): Extension<DriveConfigured>,
|
|
) -> impl IntoResponse {
|
|
// Live provider availability: true once metadata has been discovered and
|
|
// cached. If OIDC is enabled but not yet ready (provider was down at
|
|
// startup and nobody has logged in since), kick off a background discovery
|
|
// so the cache warms and a subsequent poll flips availability — no user
|
|
// click required for recovery.
|
|
let oidc_available = match oidc.0 {
|
|
Some(state) => {
|
|
let ready = state.is_ready().await;
|
|
if !ready {
|
|
state.warm_in_background();
|
|
}
|
|
ready
|
|
}
|
|
None => false,
|
|
};
|
|
|
|
let mut resp = json!({
|
|
"oidc_enabled": oidc_enabled.0,
|
|
"oidc_available": oidc_available,
|
|
"site_title": site_title.0,
|
|
"drive_configured": drive_configured.0,
|
|
});
|
|
resp["theme"] = json!({
|
|
"admin_preset": theme.admin_preset,
|
|
"admin_colors": theme.admin_colors,
|
|
"presets": theme.presets,
|
|
});
|
|
if let Some(ref url) = theme.logo_url {
|
|
resp["theme"]["logo_url"] = json!(url);
|
|
}
|
|
Json(resp)
|
|
}
|
|
|
|
/// Site title from config, shared via Extension.
|
|
#[derive(Clone)]
|
|
pub struct SiteTitle(pub String);
|
|
|
|
/// Resolved theme data shared via Extension.
|
|
#[derive(Clone)]
|
|
pub struct ThemeData {
|
|
/// Admin-configured preset name (e.g. "dark").
|
|
pub admin_preset: String,
|
|
/// Fully-resolved admin theme colors (preset + overrides).
|
|
pub admin_colors: crate::config::ThemeColors,
|
|
/// Optional custom logo URL.
|
|
pub logo_url: Option<String>,
|
|
/// All built-in preset palettes for client-side switching.
|
|
pub presets: std::collections::HashMap<String, crate::config::ThemeColors>,
|
|
}
|
|
|
|
/// Marker for whether OIDC is configured.
|
|
#[derive(Clone)]
|
|
pub struct OidcEnabled(pub bool);
|
|
|
|
/// Handle to the live OIDC state (`None` when OIDC is not configured). Lets the
|
|
/// status endpoint report current provider *availability* (metadata discovered)
|
|
/// distinct from whether OIDC is merely configured.
|
|
#[derive(Clone)]
|
|
pub struct OidcHandle(pub Option<crate::oidc::OidcState>);
|
|
|
|
/// Marker for whether Vault is configured (has [vault] in config).
|
|
/// Distinct from VaultState which tracks whether it's currently connected.
|
|
#[derive(Clone)]
|
|
pub struct VaultConfigured(pub bool);
|
|
|
|
/// Marker for whether [drive] is configured.
|
|
#[derive(Clone)]
|
|
pub struct DriveConfigured(pub bool);
|
|
|
|
/// Default scope ("local" | "shared") for a new per-user credential variable
|
|
/// (from `[user_credentials_default_scope]`). Only meaningful when more than
|
|
/// one Vault backend is configured.
|
|
#[derive(Clone)]
|
|
pub struct CredentialDefaultScope(pub String);
|
|
|
|
/// GET /api/recordings — List all recording files. Requires poweruser+ role.
|
|
pub async fn list_recordings(
|
|
State(manager): State<AppState>,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
) -> impl IntoResponse {
|
|
match &identity {
|
|
Some(Extension(id)) if id.has_role("poweruser") => {}
|
|
_ => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "requires poweruser or admin role"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
let recording_path = manager.recording_path().to_path_buf();
|
|
|
|
match tokio::task::spawn_blocking(move || {
|
|
let mut recordings = Vec::new();
|
|
let entries = match std::fs::read_dir(&recording_path) {
|
|
Ok(e) => e,
|
|
Err(_) => return recordings,
|
|
};
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.extension().and_then(|e| e.to_str()) != Some("guac") {
|
|
continue;
|
|
}
|
|
let name = match path.file_name().and_then(|n| n.to_str()) {
|
|
Some(n) => n.to_string(),
|
|
None => continue,
|
|
};
|
|
let meta = match std::fs::metadata(&path) {
|
|
Ok(m) => m,
|
|
Err(_) => continue,
|
|
};
|
|
let modified = meta
|
|
.modified()
|
|
.ok()
|
|
.map(|t| {
|
|
let dt: chrono::DateTime<chrono::Utc> = t.into();
|
|
dt.to_rfc3339()
|
|
})
|
|
.unwrap_or_default();
|
|
let mut rec = json!({
|
|
"name": name,
|
|
"size_bytes": meta.len(),
|
|
"modified": modified,
|
|
});
|
|
// Read sidecar .meta file for extra context
|
|
if let Some(sidecar) = crate::recording::read_meta(&path) {
|
|
if let Some(ref e) = sidecar.address_book_entry {
|
|
rec["address_book_entry"] = json!(e);
|
|
}
|
|
rec["created_at"] = json!(sidecar.created_at);
|
|
if let Some(ref u) = sidecar.user {
|
|
rec["user"] = json!(u);
|
|
}
|
|
if let Some(ref f) = sidecar.folder {
|
|
rec["folder"] = json!(f);
|
|
}
|
|
if let Some(ref d) = sidecar.entry_display_name {
|
|
rec["entry_display_name"] = json!(d);
|
|
}
|
|
if let Some(ref t) = sidecar.session_type {
|
|
rec["session_type"] = json!(t);
|
|
}
|
|
}
|
|
recordings.push(rec);
|
|
}
|
|
recordings.sort_by(|a, b| {
|
|
let ma = a["modified"].as_str().unwrap_or("");
|
|
let mb = b["modified"].as_str().unwrap_or("");
|
|
mb.cmp(ma)
|
|
});
|
|
recordings
|
|
})
|
|
.await
|
|
{
|
|
Ok(recordings) => Json(json!(recordings)).into_response(),
|
|
Err(_) => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to list recordings"})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// GET /api/typescripts - List SSH typescript recordings (#159). Requires
|
|
/// poweruser+ role.
|
|
///
|
|
/// **List only by design.** Unlike `.guac` recordings, the typescript
|
|
/// content is never served or downloadable through the web UI: a
|
|
/// typescript captures the full terminal output (which can include
|
|
/// passwords typed at prompts or secrets printed to screen). A poweruser
|
|
/// can see *that* a session was recorded (connection, time, size) for
|
|
/// accountability, but retrieving the actual log requires direct access
|
|
/// to the rustguac host or storage. There is deliberately no serve or
|
|
/// delete endpoint, so there is also no name parameter and no
|
|
/// path-traversal surface.
|
|
pub async fn list_typescripts(
|
|
State(manager): State<AppState>,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
) -> impl IntoResponse {
|
|
match &identity {
|
|
Some(Extension(id)) if id.has_role("poweruser") => {}
|
|
_ => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "requires poweruser or admin role"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
let ts_path = match manager
|
|
.config()
|
|
.recording
|
|
.as_ref()
|
|
.and_then(|r| r.typescript_path.clone())
|
|
{
|
|
Some(p) => p,
|
|
// Typescript recording not configured: empty list, not an error.
|
|
None => return Json(json!({"path": null, "items": []})).into_response(),
|
|
};
|
|
// Host path shown in the UI so operators know where the files live.
|
|
let ts_path_str = ts_path.display().to_string();
|
|
|
|
match tokio::task::spawn_blocking(move || {
|
|
let mut items = Vec::new();
|
|
let entries = match std::fs::read_dir(&ts_path) {
|
|
Ok(e) => e,
|
|
Err(_) => return items,
|
|
};
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if !path.is_file() {
|
|
continue;
|
|
}
|
|
let name = match path.file_name().and_then(|n| n.to_str()) {
|
|
Some(n) => n.to_string(),
|
|
None => continue,
|
|
};
|
|
// Each session writes "NAME" + "NAME.timing"; list only the
|
|
// data file so one row == one session.
|
|
if name.ends_with(".timing") {
|
|
continue;
|
|
}
|
|
let meta = match std::fs::metadata(&path) {
|
|
Ok(m) => m,
|
|
Err(_) => continue,
|
|
};
|
|
let modified = meta
|
|
.modified()
|
|
.ok()
|
|
.map(|t| {
|
|
let dt: chrono::DateTime<chrono::Utc> = t.into();
|
|
dt.to_rfc3339()
|
|
})
|
|
.unwrap_or_default();
|
|
items.push(json!({
|
|
"name": name,
|
|
"size_bytes": meta.len(),
|
|
"modified": modified,
|
|
}));
|
|
}
|
|
items.sort_by(|a, b| {
|
|
let ma = a["modified"].as_str().unwrap_or("");
|
|
let mb = b["modified"].as_str().unwrap_or("");
|
|
mb.cmp(ma)
|
|
});
|
|
items
|
|
})
|
|
.await
|
|
{
|
|
Ok(items) => Json(json!({"path": ts_path_str, "items": items})).into_response(),
|
|
Err(_) => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to list typescripts"})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
// ── Report endpoints ──
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ReportQuery {
|
|
pub user: Option<String>,
|
|
pub entry: Option<String>,
|
|
#[serde(rename = "type")]
|
|
pub session_type: Option<String>,
|
|
pub from: Option<String>,
|
|
pub to: Option<String>,
|
|
/// The caller's `Date.getTimezoneOffset()`: minutes to add to local time to
|
|
/// reach UTC. Makes a zoneless `from`/`to` mean a *local* day, matching the
|
|
/// local times the Reports page displays. Absent (the scripted case) means
|
|
/// UTC, so existing callers are unaffected.
|
|
pub tz_offset: Option<i32>,
|
|
pub limit: Option<u32>,
|
|
pub offset: Option<u32>,
|
|
}
|
|
|
|
/// Normalize a report date filter into the shape `session_history` stores
|
|
/// ("YYYY-MM-DD HH:MM:SS", UTC), so the string comparison in SQL means what the
|
|
/// caller intended. Returns `Err` with the offending value if it can't be read.
|
|
///
|
|
/// A bare "YYYY-MM-DD" is a whole day: its first second for the lower bound,
|
|
/// its last for the upper, so `to=2026-09-04` includes the 4th rather than
|
|
/// excluding it (the DB values sort after a bare date, so they used to fall
|
|
/// outside). Zoneless values are shifted by `tz_offset_mins`; a value carrying
|
|
/// its own zone is converted from that and ignores the offset.
|
|
fn normalize_report_bound(
|
|
value: &str,
|
|
tz_offset_mins: i32,
|
|
end_of_day: bool,
|
|
) -> Result<String, String> {
|
|
const STORED: &str = "%Y-%m-%d %H:%M:%S";
|
|
let v = value.trim();
|
|
if v.is_empty() {
|
|
return Err(value.to_string());
|
|
}
|
|
|
|
// Zoned: RFC 3339 / ISO 8601 with Z or an explicit offset.
|
|
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(v) {
|
|
return Ok(dt.with_timezone(&chrono::Utc).format(STORED).to_string());
|
|
}
|
|
|
|
let local: chrono::NaiveDateTime =
|
|
if let Ok(d) = chrono::NaiveDate::parse_from_str(v, "%Y-%m-%d") {
|
|
let time = if end_of_day {
|
|
chrono::NaiveTime::from_hms_opt(23, 59, 59)
|
|
} else {
|
|
chrono::NaiveTime::from_hms_opt(0, 0, 0)
|
|
};
|
|
d.and_time(time.expect("literal time is valid"))
|
|
} else if let Some(dt) = ["%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M"]
|
|
.iter()
|
|
.find_map(|f| chrono::NaiveDateTime::parse_from_str(v, f).ok())
|
|
{
|
|
dt
|
|
} else {
|
|
return Err(value.to_string());
|
|
};
|
|
|
|
// A local wall clock plus its offset-to-UTC is the UTC instant.
|
|
let utc = local + chrono::Duration::minutes(tz_offset_mins as i64);
|
|
Ok(utc.format(STORED).to_string())
|
|
}
|
|
|
|
/// Read `from`/`to` off a report query as stored-format UTC bounds, or name the
|
|
/// parameter that could not be parsed.
|
|
fn report_bounds(q: &ReportQuery) -> Result<(Option<String>, Option<String>), String> {
|
|
// Beyond any real zone (UTC-12..UTC+14), so a junk value can't shift a
|
|
// filter into a different year.
|
|
let tz = q.tz_offset.unwrap_or(0).clamp(-840, 840);
|
|
let from = match q.from.as_deref() {
|
|
Some(v) => Some(normalize_report_bound(v, tz, false).map_err(|v| format!("from={}", v))?),
|
|
None => None,
|
|
};
|
|
let to = match q.to.as_deref() {
|
|
Some(v) => Some(normalize_report_bound(v, tz, true).map_err(|v| format!("to={}", v))?),
|
|
None => None,
|
|
};
|
|
Ok((from, to))
|
|
}
|
|
|
|
/// GET /api/reports/sessions — Paginated session history with filters. Poweruser+ only.
|
|
pub async fn report_sessions(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
Query(q): Query<ReportQuery>,
|
|
) -> impl IntoResponse {
|
|
if !identity
|
|
.as_ref()
|
|
.map(|Extension(id)| id.has_role("poweruser"))
|
|
.unwrap_or(false)
|
|
{
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "poweruser role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
let limit = q.limit.unwrap_or(100).min(1000);
|
|
let offset = q.offset.unwrap_or(0);
|
|
let (from, to) = match report_bounds(&q) {
|
|
Ok(b) => b,
|
|
Err(bad) => {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({"error": format!("unparseable date filter: {}", bad)})),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
match db::query_session_history(
|
|
&database,
|
|
q.user.as_deref(),
|
|
q.entry.as_deref(),
|
|
q.session_type.as_deref(),
|
|
from.as_deref(),
|
|
to.as_deref(),
|
|
limit,
|
|
offset,
|
|
) {
|
|
Ok((rows, total)) => {
|
|
Json(json!({"sessions": rows, "total": total, "limit": limit, "offset": offset}))
|
|
.into_response()
|
|
}
|
|
Err(e) => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": e.to_string()})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// GET /api/reports/sessions/csv — Export session history as CSV. Poweruser+ only.
|
|
pub async fn report_sessions_csv(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
Query(q): Query<ReportQuery>,
|
|
) -> impl IntoResponse {
|
|
if !identity
|
|
.as_ref()
|
|
.map(|Extension(id)| id.has_role("poweruser"))
|
|
.unwrap_or(false)
|
|
{
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "poweruser role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
let (from, to) = match report_bounds(&q) {
|
|
Ok(b) => b,
|
|
Err(bad) => {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({"error": format!("unparseable date filter: {}", bad)})),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
match db::query_session_history(
|
|
&database,
|
|
q.user.as_deref(),
|
|
q.entry.as_deref(),
|
|
q.session_type.as_deref(),
|
|
from.as_deref(),
|
|
to.as_deref(),
|
|
100_000,
|
|
0,
|
|
) {
|
|
Ok((rows, _total)) => {
|
|
let mut csv = String::from("Session ID,Type,Hostname,Username,User,Entry,Folder,Started (UTC),Ended (UTC),Duration (secs),Status,Recording\n");
|
|
for row in &rows {
|
|
let fields = [
|
|
row["session_id"].as_str().unwrap_or(""),
|
|
row["session_type"].as_str().unwrap_or(""),
|
|
row["hostname"].as_str().unwrap_or(""),
|
|
row["username"].as_str().unwrap_or(""),
|
|
row["created_by"].as_str().unwrap_or(""),
|
|
row["entry_display_name"]
|
|
.as_str()
|
|
.or_else(|| row["address_book_entry"].as_str())
|
|
.unwrap_or(""),
|
|
row["address_book_folder"].as_str().unwrap_or(""),
|
|
];
|
|
for (i, f) in fields.iter().enumerate() {
|
|
if i > 0 {
|
|
csv.push(',');
|
|
}
|
|
csv_escape_into(&mut csv, f);
|
|
}
|
|
// Timestamps, marked as UTC. The DB stores them zoneless, which
|
|
// a spreadsheet reads as local time and silently shifts.
|
|
for key in ["started_at", "ended_at"] {
|
|
csv.push(',');
|
|
csv_escape_into(&mut csv, &mark_utc(row[key].as_str().unwrap_or("")));
|
|
}
|
|
// duration_secs (numeric)
|
|
csv.push(',');
|
|
if let Some(d) = row["duration_secs"].as_i64() {
|
|
csv.push_str(&d.to_string());
|
|
}
|
|
// status
|
|
csv.push(',');
|
|
csv_escape_into(&mut csv, row["status"].as_str().unwrap_or(""));
|
|
// recording
|
|
csv.push(',');
|
|
csv_escape_into(&mut csv, row["recording_file"].as_str().unwrap_or(""));
|
|
csv.push('\n');
|
|
}
|
|
Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header("Content-Type", "text/csv; charset=utf-8")
|
|
.header(
|
|
"Content-Disposition",
|
|
"attachment; filename=\"session-history.csv\"",
|
|
)
|
|
.body(Body::from(csv))
|
|
.unwrap()
|
|
.into_response()
|
|
}
|
|
Err(e) => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": e.to_string()})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// Escape a field for CSV output: wrap in double quotes if it contains commas, quotes, or newlines.
|
|
/// Tag a zoneless SQLite timestamp ("YYYY-MM-DD HH:MM:SS", always UTC via
|
|
/// `datetime('now')`) as RFC 3339 UTC. Values that already carry a zone, and
|
|
/// anything that doesn't match the expected shape, pass through unchanged.
|
|
fn mark_utc(ts: &str) -> String {
|
|
let bytes = ts.as_bytes();
|
|
if bytes.len() == 19 && bytes[10] == b' ' {
|
|
format!("{}T{}Z", &ts[..10], &ts[11..])
|
|
} else {
|
|
ts.to_string()
|
|
}
|
|
}
|
|
|
|
fn csv_escape_into(buf: &mut String, field: &str) {
|
|
if field.contains(',') || field.contains('"') || field.contains('\n') {
|
|
buf.push('"');
|
|
for ch in field.chars() {
|
|
if ch == '"' {
|
|
buf.push('"');
|
|
}
|
|
buf.push(ch);
|
|
}
|
|
buf.push('"');
|
|
} else {
|
|
buf.push_str(field);
|
|
}
|
|
}
|
|
|
|
/// GET /api/reports/top-connections — Most-used connections. Poweruser+ only.
|
|
pub async fn report_top_connections(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
Query(q): Query<ReportQuery>,
|
|
) -> impl IntoResponse {
|
|
if !identity
|
|
.as_ref()
|
|
.map(|Extension(id)| id.has_role("poweruser"))
|
|
.unwrap_or(false)
|
|
{
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "poweruser role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
let limit = q.limit.unwrap_or(20).min(100);
|
|
match db::top_connections(&database, limit) {
|
|
Ok(rows) => Json(json!(rows)).into_response(),
|
|
Err(e) => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": e.to_string()})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// GET /api/reports/top-users — Most active users. Poweruser+ only.
|
|
pub async fn report_top_users(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
Query(q): Query<ReportQuery>,
|
|
) -> impl IntoResponse {
|
|
if !identity
|
|
.as_ref()
|
|
.map(|Extension(id)| id.has_role("poweruser"))
|
|
.unwrap_or(false)
|
|
{
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "poweruser role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
let limit = q.limit.unwrap_or(20).min(100);
|
|
match db::top_users(&database, limit) {
|
|
Ok(rows) => Json(json!(rows)).into_response(),
|
|
Err(e) => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": e.to_string()})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// GET /api/reports/summary — Dashboard statistics. Poweruser+ only.
|
|
pub async fn report_summary(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
) -> impl IntoResponse {
|
|
if !identity
|
|
.as_ref()
|
|
.map(|Extension(id)| id.has_role("poweruser"))
|
|
.unwrap_or(false)
|
|
{
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "poweruser role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
match db::session_summary(&database) {
|
|
Ok(summary) => Json(summary).into_response(),
|
|
Err(e) => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": e.to_string()})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// GET /api/recordings/{name} — Serve a .guac recording file. Requires poweruser+ role.
|
|
pub async fn serve_recording(
|
|
State(manager): State<AppState>,
|
|
Path(name): Path<String>,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
) -> impl IntoResponse {
|
|
match &identity {
|
|
Some(Extension(id)) if id.has_role("poweruser") => {}
|
|
_ => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "requires poweruser or admin role"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
if !is_safe_recording_name(&name, manager.recording_path()) {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({"error": "invalid recording name"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let path = manager.recording_path().join(&name);
|
|
|
|
let file = match tokio::fs::File::open(&path).await {
|
|
Ok(f) => f,
|
|
Err(_) => {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({"error": "recording not found"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
let stream = ReaderStream::new(file);
|
|
let body = Body::from_stream(stream);
|
|
|
|
axum::response::Response::builder()
|
|
.header("content-type", "application/octet-stream")
|
|
.header(
|
|
"content-disposition",
|
|
format!("inline; filename=\"{}\"", name),
|
|
)
|
|
.body(body)
|
|
.unwrap()
|
|
.into_response()
|
|
}
|
|
|
|
/// DELETE /api/recordings/{name} — Delete a recording file. Requires admin.
|
|
pub async fn delete_recording(
|
|
State(manager): State<AppState>,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Path(name): Path<String>,
|
|
) -> impl IntoResponse {
|
|
if let Some(Extension(ref id)) = identity {
|
|
if !id.has_role("admin") {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "insufficient permissions — admin role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
if !is_safe_recording_name(&name, manager.recording_path()) {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({"error": "invalid recording name"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let path = manager.recording_path().join(&name);
|
|
|
|
match tokio::fs::remove_file(&path).await {
|
|
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(_) => (
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({"error": "recording not found"})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// Validate a recording filename: must end in .guac, no path separators,
|
|
/// and the resolved path must stay within the recording directory.
|
|
fn is_safe_recording_name(name: &str, recording_dir: &std::path::Path) -> bool {
|
|
if name.is_empty()
|
|
|| name == ".guac"
|
|
|| !name.ends_with(".guac")
|
|
|| name.contains('/')
|
|
|| name.contains('\\')
|
|
|| name.contains("..")
|
|
|| name.contains('\0')
|
|
{
|
|
return false;
|
|
}
|
|
// Belt-and-suspenders: verify canonical path is within recording dir
|
|
let full = recording_dir.join(name);
|
|
match (full.canonicalize(), recording_dir.canonicalize()) {
|
|
(Ok(resolved), Ok(base)) => resolved.starts_with(&base),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// JPEG magic-byte check. Uploaded thumbnails must begin with `FF D8 FF`
|
|
/// (SOI + APPn marker). Rejects truncated bodies so the caller can return
|
|
/// a clean 400 without touching disk.
|
|
fn is_jpeg_magic(body: &[u8]) -> bool {
|
|
body.len() >= 3 && body[0] == 0xFF && body[1] == 0xD8 && body[2] == 0xFF
|
|
}
|
|
|
|
// ── User management endpoints (admin only) ──
|
|
|
|
/// GET /api/users — List all OIDC users. Admin only.
|
|
pub async fn list_users(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
) -> impl IntoResponse {
|
|
if let Some(Extension(ref id)) = identity {
|
|
if !id.has_role("admin") {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
let db_clone = database.clone();
|
|
match tokio::task::spawn_blocking(move || db::list_users(&db_clone)).await {
|
|
Ok(Ok(users)) => Json(json!(users)).into_response(),
|
|
_ => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to list users"})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct SetRoleRequest {
|
|
pub role: String,
|
|
}
|
|
|
|
/// PUT /api/users/{email}/role — Set user role. Admin only.
|
|
pub async fn set_user_role(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
Path(email): Path<String>,
|
|
Json(req): Json<SetRoleRequest>,
|
|
) -> impl IntoResponse {
|
|
if let Some(Extension(ref id)) = identity {
|
|
if !id.has_role("admin") {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
if !["admin", "poweruser", "operator", "viewer"].contains(&req.role.as_str()) {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({"error": "role must be admin, poweruser, operator, or viewer"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let db_clone = database.clone();
|
|
let role = req.role.clone();
|
|
match tokio::task::spawn_blocking(move || db::set_user_role(&db_clone, &email, &role)).await {
|
|
Ok(Ok(true)) => Json(json!({"ok": true})).into_response(),
|
|
Ok(Ok(false)) => (
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({"error": "user not found"})),
|
|
)
|
|
.into_response(),
|
|
_ => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to set role"})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// DELETE /api/users/{email} — Delete user. Admin only.
|
|
pub async fn delete_user(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
Path(email): Path<String>,
|
|
) -> impl IntoResponse {
|
|
if let Some(Extension(ref id)) = identity {
|
|
if !id.has_role("admin") {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
let db_clone = database.clone();
|
|
match tokio::task::spawn_blocking(move || db::delete_user(&db_clone, &email)).await {
|
|
Ok(Ok(true)) => StatusCode::NO_CONTENT.into_response(),
|
|
Ok(Ok(false)) => (
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({"error": "user not found"})),
|
|
)
|
|
.into_response(),
|
|
_ => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to delete user"})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// DELETE /api/users/{email}/sessions — Force-logout a user by deleting all their sessions. Admin only.
|
|
pub async fn delete_user_sessions(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
Path(email): Path<String>,
|
|
) -> impl IntoResponse {
|
|
if !identity
|
|
.as_ref()
|
|
.map(|Extension(id)| id.has_role("admin"))
|
|
.unwrap_or(false)
|
|
{
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let db_clone = database.clone();
|
|
let email_clone = email.clone();
|
|
let user =
|
|
match tokio::task::spawn_blocking(move || db::get_user_by_email(&db_clone, &email_clone))
|
|
.await
|
|
{
|
|
Ok(Ok(user)) => user,
|
|
_ => {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({"error": "user not found"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
let db_clone = database.clone();
|
|
let user_id = user.id;
|
|
match tokio::task::spawn_blocking(move || db::delete_user_sessions(&db_clone, user_id)).await {
|
|
Ok(Ok(count)) => {
|
|
tracing::info!(email = %email, sessions_revoked = count, "Admin force-logout user");
|
|
Json(json!({"ok": true, "sessions_revoked": count})).into_response()
|
|
}
|
|
_ => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to delete sessions"})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// GET /api/me — Return current user identity and role.
|
|
pub async fn me(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(vault): Extension<VaultState>,
|
|
Extension(vault_configured): Extension<VaultConfigured>,
|
|
) -> impl IntoResponse {
|
|
match identity {
|
|
Some(Extension(id)) => {
|
|
let vault_available = vault.any_connected().await;
|
|
Json(json!({
|
|
"name": id.display_name(),
|
|
"role": id.role(),
|
|
"groups": id.groups(),
|
|
"vault_enabled": vault_available,
|
|
"vault_configured": vault_configured.0,
|
|
}))
|
|
.into_response()
|
|
}
|
|
None => (
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(json!({"error": "not authenticated"})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct BannerQuery {
|
|
pub token: String,
|
|
}
|
|
|
|
/// GET /api/sessions/:id/banner?token=... — Get session banner (unauthenticated, requires share token).
|
|
pub async fn get_session_banner(
|
|
State(manager): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
Query(query): Query<BannerQuery>,
|
|
) -> impl IntoResponse {
|
|
if !manager
|
|
.validate_share_token(id, &query.token)
|
|
.await
|
|
.is_valid()
|
|
{
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "invalid share token"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
match manager.get_session(id).await {
|
|
Some(info) => Json(json!({ "banner": info.banner })).into_response(),
|
|
None => (
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({"error": "session not found"})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
// ── User disable/enable endpoints (admin only) ──
|
|
|
|
/// POST /api/users/{email}/disable — Disable a user. Admin only.
|
|
pub async fn disable_user(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
Path(email): Path<String>,
|
|
) -> impl IntoResponse {
|
|
if !identity
|
|
.as_ref()
|
|
.map(|Extension(id)| id.has_role("admin"))
|
|
.unwrap_or(false)
|
|
{
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let db_clone = database.clone();
|
|
match tokio::task::spawn_blocking(move || db::disable_user(&db_clone, &email)).await {
|
|
Ok(Ok(true)) => Json(json!({"ok": true})).into_response(),
|
|
Ok(Ok(false)) => (
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({"error": "user not found"})),
|
|
)
|
|
.into_response(),
|
|
_ => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to disable user"})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// POST /api/users/{email}/enable — Enable a user. Admin only.
|
|
pub async fn enable_user(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
Path(email): Path<String>,
|
|
) -> impl IntoResponse {
|
|
if !identity
|
|
.as_ref()
|
|
.map(|Extension(id)| id.has_role("admin"))
|
|
.unwrap_or(false)
|
|
{
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let db_clone = database.clone();
|
|
match tokio::task::spawn_blocking(move || db::enable_user(&db_clone, &email)).await {
|
|
Ok(Ok(true)) => Json(json!({"ok": true})).into_response(),
|
|
Ok(Ok(false)) => (
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({"error": "user not found"})),
|
|
)
|
|
.into_response(),
|
|
_ => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to enable user"})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
// ── Group-to-role mapping endpoints (admin only) ──
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct CreateGroupMappingRequest {
|
|
pub group: String,
|
|
pub role: String,
|
|
}
|
|
|
|
/// GET /api/admin/group-mappings — List all group-to-role mappings. Admin only.
|
|
pub async fn list_group_mappings(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
) -> impl IntoResponse {
|
|
if !identity
|
|
.as_ref()
|
|
.map(|Extension(id)| id.has_role("admin"))
|
|
.unwrap_or(false)
|
|
{
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let db_clone = database.clone();
|
|
match tokio::task::spawn_blocking(move || db::list_group_mappings(&db_clone)).await {
|
|
Ok(Ok(mappings)) => Json(json!(mappings)).into_response(),
|
|
_ => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to list mappings"})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// GET /api/auth/known-groups — List known OIDC groups (union of role
|
|
/// mappings + groups ever seen in a user's login claims). Admin only;
|
|
/// used by the folder modal to populate the allowed_groups picker.
|
|
pub async fn list_known_groups(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
) -> impl IntoResponse {
|
|
if !identity
|
|
.as_ref()
|
|
.map(|Extension(id)| id.has_role("admin"))
|
|
.unwrap_or(false)
|
|
{
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let db_clone = database.clone();
|
|
match tokio::task::spawn_blocking(move || db::list_known_groups(&db_clone)).await {
|
|
Ok(Ok(groups)) => Json(json!({ "groups": groups })).into_response(),
|
|
_ => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to list groups"})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// POST /api/admin/group-mappings — Create a group-to-role mapping. Admin only.
|
|
pub async fn create_group_mapping(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
Json(req): Json<CreateGroupMappingRequest>,
|
|
) -> impl IntoResponse {
|
|
if !identity
|
|
.as_ref()
|
|
.map(|Extension(id)| id.has_role("admin"))
|
|
.unwrap_or(false)
|
|
{
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
if !["admin", "poweruser", "operator", "viewer"].contains(&req.role.as_str()) {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({"error": "role must be admin, poweruser, operator, or viewer"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let db_clone = database.clone();
|
|
match tokio::task::spawn_blocking(move || {
|
|
db::create_group_mapping(&db_clone, &req.group, &req.role)
|
|
})
|
|
.await
|
|
{
|
|
Ok(Ok(mapping)) => (StatusCode::CREATED, Json(json!(mapping))).into_response(),
|
|
Ok(Err(e)) => {
|
|
let msg = e.to_string();
|
|
if msg.contains("UNIQUE") {
|
|
(
|
|
StatusCode::CONFLICT,
|
|
Json(json!({"error": "mapping for this group already exists"})),
|
|
)
|
|
.into_response()
|
|
} else {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": msg})),
|
|
)
|
|
.into_response()
|
|
}
|
|
}
|
|
_ => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to create mapping"})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct UpdateGroupMappingRequest {
|
|
pub group: String,
|
|
pub role: String,
|
|
}
|
|
|
|
/// PUT /api/admin/group-mappings/{id} — Update a group-to-role mapping. Admin only.
|
|
pub async fn update_group_mapping(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
Path(id): Path<i64>,
|
|
Json(req): Json<UpdateGroupMappingRequest>,
|
|
) -> impl IntoResponse {
|
|
if !identity
|
|
.as_ref()
|
|
.map(|Extension(id)| id.has_role("admin"))
|
|
.unwrap_or(false)
|
|
{
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
if !["admin", "poweruser", "operator", "viewer"].contains(&req.role.as_str()) {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({"error": "role must be admin, poweruser, operator, or viewer"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let db_clone = database.clone();
|
|
match tokio::task::spawn_blocking(move || {
|
|
db::update_group_mapping(&db_clone, id, &req.group, &req.role)
|
|
})
|
|
.await
|
|
{
|
|
Ok(Ok(true)) => Json(json!({"ok": true})).into_response(),
|
|
Ok(Ok(false)) => (
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({"error": "mapping not found"})),
|
|
)
|
|
.into_response(),
|
|
Ok(Err(e)) => {
|
|
let msg = e.to_string();
|
|
if msg.contains("UNIQUE") {
|
|
(
|
|
StatusCode::CONFLICT,
|
|
Json(json!({"error": "mapping for this group already exists"})),
|
|
)
|
|
.into_response()
|
|
} else {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": msg})),
|
|
)
|
|
.into_response()
|
|
}
|
|
}
|
|
_ => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to update mapping"})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// DELETE /api/admin/group-mappings/{id} — Delete a group-to-role mapping. Admin only.
|
|
pub async fn delete_group_mapping(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
Path(id): Path<i64>,
|
|
) -> impl IntoResponse {
|
|
if !identity
|
|
.as_ref()
|
|
.map(|Extension(id)| id.has_role("admin"))
|
|
.unwrap_or(false)
|
|
{
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let db_clone = database.clone();
|
|
match tokio::task::spawn_blocking(move || db::delete_group_mapping(&db_clone, id)).await {
|
|
Ok(Ok(true)) => StatusCode::NO_CONTENT.into_response(),
|
|
Ok(Ok(false)) => (
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({"error": "mapping not found"})),
|
|
)
|
|
.into_response(),
|
|
_ => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to delete mapping"})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
// ── Address Book endpoints (Vault-backed) ──
|
|
|
|
/// One Vault backend connection cell — `None` until connected / while down.
|
|
pub type VaultCell = Arc<tokio::sync::RwLock<Option<Arc<VaultClient>>>>;
|
|
|
|
/// The set of Vault backends rustguac talks to.
|
|
///
|
|
/// In the single-Vault default, `shared` and `local` both alias `default`, so
|
|
/// behaviour is identical to a single `VaultClient`. A multi-Vault split (see
|
|
/// `[vault_shared]` / `[vault_local]`) routes the `shared` and `instance`
|
|
/// address-book scopes to dedicated backends, each with its own connection and
|
|
/// retry lifecycle, so one backend being down cannot take the others with it.
|
|
pub struct VaultBackends {
|
|
/// `[vault]`: fallback for any scope without a dedicated backend, and home
|
|
/// of unscoped secrets (the LUKS key; user credential variables for now).
|
|
pub default: VaultCell,
|
|
/// Serves the `shared` scope. Aliases `default` unless `[vault_shared]` is
|
|
/// configured.
|
|
pub shared: VaultCell,
|
|
/// Serves the `instance` (local) scope. Aliases `default` unless
|
|
/// `[vault_local]` is configured.
|
|
pub local: VaultCell,
|
|
}
|
|
|
|
impl VaultBackends {
|
|
/// The backend cell serving a given address-book scope (`"shared"` or
|
|
/// `"instance"`). Anything else falls back to the shared backend.
|
|
pub fn cell_for_scope(&self, scope: &str) -> &VaultCell {
|
|
match scope {
|
|
"instance" => &self.local,
|
|
_ => &self.shared,
|
|
}
|
|
}
|
|
|
|
/// Resolve the connected client for `scope`, or `Unavailable` if that
|
|
/// backend is down / not yet connected.
|
|
async fn scoped(&self, scope: &str) -> Result<Arc<VaultClient>, VaultError> {
|
|
self.cell_for_scope(scope)
|
|
.read()
|
|
.await
|
|
.clone()
|
|
.ok_or(VaultError::Unavailable)
|
|
}
|
|
|
|
/// True if at least one configured backend is currently connected.
|
|
pub async fn any_connected(&self) -> bool {
|
|
self.default.read().await.is_some()
|
|
|| self.shared.read().await.is_some()
|
|
|| self.local.read().await.is_some()
|
|
}
|
|
|
|
// ── Scope-routed address-book operations (dispatch to the scope's backend) ──
|
|
|
|
pub async fn list_subfolders(
|
|
&self,
|
|
scope: &str,
|
|
parent: &str,
|
|
) -> Result<Vec<crate::vault::FolderInfo>, VaultError> {
|
|
self.scoped(scope)
|
|
.await?
|
|
.list_subfolders(scope, parent)
|
|
.await
|
|
}
|
|
|
|
pub async fn list_entries(&self, scope: &str, folder: &str) -> Result<Vec<String>, VaultError> {
|
|
self.scoped(scope).await?.list_entries(scope, folder).await
|
|
}
|
|
|
|
pub async fn get_entry(
|
|
&self,
|
|
scope: &str,
|
|
folder: &str,
|
|
entry: &str,
|
|
) -> Result<AddressBookEntry, VaultError> {
|
|
self.scoped(scope)
|
|
.await?
|
|
.get_entry(scope, folder, entry)
|
|
.await
|
|
}
|
|
|
|
pub async fn put_entry(
|
|
&self,
|
|
scope: &str,
|
|
folder: &str,
|
|
entry: &str,
|
|
data: &AddressBookEntry,
|
|
) -> Result<(), VaultError> {
|
|
self.scoped(scope)
|
|
.await?
|
|
.put_entry(scope, folder, entry, data)
|
|
.await
|
|
}
|
|
|
|
pub async fn put_entry_if_absent(
|
|
&self,
|
|
scope: &str,
|
|
folder: &str,
|
|
entry: &str,
|
|
data: &AddressBookEntry,
|
|
) -> Result<bool, VaultError> {
|
|
self.scoped(scope)
|
|
.await?
|
|
.put_entry_if_absent(scope, folder, entry, data)
|
|
.await
|
|
}
|
|
|
|
pub async fn delete_entry(
|
|
&self,
|
|
scope: &str,
|
|
folder: &str,
|
|
entry: &str,
|
|
) -> Result<(), VaultError> {
|
|
self.scoped(scope)
|
|
.await?
|
|
.delete_entry(scope, folder, entry)
|
|
.await
|
|
}
|
|
|
|
pub async fn get_folder_config(
|
|
&self,
|
|
scope: &str,
|
|
folder: &str,
|
|
) -> Result<FolderConfig, VaultError> {
|
|
self.scoped(scope)
|
|
.await?
|
|
.get_folder_config(scope, folder)
|
|
.await
|
|
}
|
|
|
|
pub async fn put_folder_config(
|
|
&self,
|
|
scope: &str,
|
|
folder: &str,
|
|
config: &FolderConfig,
|
|
) -> Result<(), VaultError> {
|
|
self.scoped(scope)
|
|
.await?
|
|
.put_folder_config(scope, folder, config)
|
|
.await
|
|
}
|
|
|
|
pub async fn get_folder_defaults(
|
|
&self,
|
|
scope: &str,
|
|
folder: &str,
|
|
) -> Result<crate::vault::FolderDefaults, VaultError> {
|
|
self.scoped(scope)
|
|
.await?
|
|
.get_folder_defaults(scope, folder)
|
|
.await
|
|
}
|
|
|
|
pub async fn put_folder_defaults(
|
|
&self,
|
|
scope: &str,
|
|
folder: &str,
|
|
defaults: &crate::vault::FolderDefaults,
|
|
) -> Result<(), VaultError> {
|
|
self.scoped(scope)
|
|
.await?
|
|
.put_folder_defaults(scope, folder, defaults)
|
|
.await
|
|
}
|
|
|
|
pub async fn delete_folder_defaults(
|
|
&self,
|
|
scope: &str,
|
|
folder: &str,
|
|
) -> Result<(), VaultError> {
|
|
self.scoped(scope)
|
|
.await?
|
|
.delete_folder_defaults(scope, folder)
|
|
.await
|
|
}
|
|
|
|
pub async fn effective_folder_defaults(
|
|
&self,
|
|
scope: &str,
|
|
folder: &str,
|
|
) -> Result<Option<crate::vault::FolderDefaults>, VaultError> {
|
|
self.scoped(scope)
|
|
.await?
|
|
.effective_folder_defaults(scope, folder)
|
|
.await
|
|
}
|
|
|
|
pub async fn delete_folder(
|
|
&self,
|
|
scope: &str,
|
|
folder: &str,
|
|
) -> Result<(usize, usize), VaultError> {
|
|
self.scoped(scope).await?.delete_folder(scope, folder).await
|
|
}
|
|
|
|
pub async fn resolve_folder_access(
|
|
&self,
|
|
scope: &str,
|
|
folder: &str,
|
|
who: crate::vault::FolderSubject<'_>,
|
|
) -> Result<bool, VaultError> {
|
|
self.scoped(scope)
|
|
.await?
|
|
.resolve_folder_access(scope, folder, who)
|
|
.await
|
|
}
|
|
|
|
// ── Per-user credential variables ──
|
|
//
|
|
// Credentials live in the "local" backend (cell_for_scope("instance")) or
|
|
// the "shared" backend (cell_for_scope("shared")) — location is the scope.
|
|
// In single-Vault those cells alias the default, so there is one physical
|
|
// store and the shared/local distinction collapses (see `creds_split`),
|
|
// preserving the quick-start behaviour exactly.
|
|
|
|
/// Whether the shared and local credential backends are physically
|
|
/// distinct. False in single-Vault (one store); the per-credential
|
|
/// shared/local choice is only meaningful when this is true.
|
|
pub fn creds_split(&self) -> bool {
|
|
!Arc::ptr_eq(&self.shared, &self.local)
|
|
}
|
|
|
|
async fn cred_client(&self, shared: bool) -> Result<Arc<VaultClient>, VaultError> {
|
|
let cell = if shared { &self.shared } else { &self.local };
|
|
cell.read().await.clone().ok_or(VaultError::Unavailable)
|
|
}
|
|
|
|
pub async fn get_user_credentials_scoped(
|
|
&self,
|
|
email: &str,
|
|
shared: bool,
|
|
) -> Result<std::collections::HashMap<String, String>, VaultError> {
|
|
self.cred_client(shared)
|
|
.await?
|
|
.get_user_credentials(email)
|
|
.await
|
|
}
|
|
|
|
pub async fn put_user_credentials_scoped(
|
|
&self,
|
|
email: &str,
|
|
shared: bool,
|
|
creds: &std::collections::HashMap<String, String>,
|
|
) -> Result<(), VaultError> {
|
|
self.cred_client(shared)
|
|
.await?
|
|
.put_user_credentials(email, creds)
|
|
.await
|
|
}
|
|
|
|
/// Merged view (local wins on a name collision) for connect-time
|
|
/// resolution. Tolerant of a down backend — it contributes nothing, so a
|
|
/// credential on the reachable backend still resolves. Single read in
|
|
/// single-Vault.
|
|
pub async fn get_user_credentials(
|
|
&self,
|
|
email: &str,
|
|
) -> Result<std::collections::HashMap<String, String>, VaultError> {
|
|
let local = self
|
|
.get_user_credentials_scoped(email, false)
|
|
.await
|
|
.unwrap_or_else(|e| {
|
|
tracing::warn!(error = %e, "reading local credentials failed");
|
|
std::collections::HashMap::new()
|
|
});
|
|
if !self.creds_split() {
|
|
return Ok(local);
|
|
}
|
|
let mut merged = self
|
|
.get_user_credentials_scoped(email, true)
|
|
.await
|
|
.unwrap_or_else(|e| {
|
|
tracing::warn!(error = %e, "reading shared credentials failed");
|
|
std::collections::HashMap::new()
|
|
});
|
|
merged.extend(local); // local wins
|
|
Ok(merged)
|
|
}
|
|
|
|
// ── Fan-out across scopes ──
|
|
|
|
/// List top-level folders across every scope, routing each to its backend.
|
|
/// Returns `(folders, unavailable_scopes)`: a scope whose dedicated backend
|
|
/// is down (or errors) is reported in `unavailable_scopes` rather than
|
|
/// failing the whole call, so the UI can grey it while other scopes stay
|
|
/// live. Only when NO backend is connected at all is `Unavailable` returned
|
|
/// (full outage → 503). In a single-Vault deployment every scope aliases
|
|
/// the one backend, so `unavailable_scopes` is empty whenever it is up.
|
|
pub async fn list_all_folders(
|
|
&self,
|
|
) -> Result<(Vec<crate::vault::FolderInfo>, Vec<String>), VaultError> {
|
|
let mut folders = Vec::new();
|
|
let mut unavailable = Vec::new();
|
|
let mut any = false;
|
|
for scope in ["shared", "instance"] {
|
|
match self.cell_for_scope(scope).read().await.clone() {
|
|
Some(client) => {
|
|
any = true;
|
|
match client.list_folders_in_scope(scope).await {
|
|
Ok(fs) => folders.extend(fs),
|
|
Err(VaultError::NotFound) => {}
|
|
Err(e) => {
|
|
tracing::warn!(scope, error = %e, "listing folders for scope failed");
|
|
unavailable.push(scope.to_string());
|
|
}
|
|
}
|
|
}
|
|
None => unavailable.push(scope.to_string()),
|
|
}
|
|
}
|
|
if !any {
|
|
return Err(VaultError::Unavailable);
|
|
}
|
|
Ok((folders, unavailable))
|
|
}
|
|
}
|
|
|
|
/// The defaults walk over the multi-backend router: each read goes to the
|
|
/// backend that serves the scope, like every other folder read here.
|
|
impl DefaultsSource for VaultBackends {
|
|
async fn folder_defaults(
|
|
&self,
|
|
scope: &str,
|
|
folder: &str,
|
|
) -> Result<Option<crate::vault::FolderDefaults>, VaultError> {
|
|
self.scoped(scope)
|
|
.await?
|
|
.folder_defaults(scope, folder)
|
|
.await
|
|
}
|
|
|
|
async fn inherits_from_parent(&self, scope: &str, folder: &str) -> Result<bool, VaultError> {
|
|
self.scoped(scope)
|
|
.await?
|
|
.inherits_from_parent(scope, folder)
|
|
.await
|
|
}
|
|
}
|
|
|
|
pub type VaultState = Arc<VaultBackends>;
|
|
|
|
// ── Docs endpoint ──
|
|
|
|
include!(concat!(env!("OUT_DIR"), "/docs-rendered.rs"));
|
|
|
|
/// GET /api/docs — Return rendered documentation sections. No auth required.
|
|
pub async fn get_docs() -> impl IntoResponse {
|
|
let sections: Vec<serde_json::Value> = DOCS
|
|
.iter()
|
|
.map(|(slug, title, html)| json!({ "slug": slug, "title": title, "html": html }))
|
|
.collect();
|
|
Json(json!(sections))
|
|
}
|
|
|
|
/// Helper: check if the identity has group access to a folder, honouring
|
|
/// `inherit_from_parent` on the folder's config (a subfolder may inherit
|
|
/// access from any ancestor whose `allowed_groups` matches the caller's
|
|
/// OIDC groups). Admin role bypasses all checks.
|
|
// The Err variant is an axum `Response` on purpose (callers `?` it straight
|
|
// into a handler result), so its size is inherent, not a mistake.
|
|
#[allow(clippy::result_large_err)]
|
|
async fn check_folder_access(
|
|
vault: &VaultBackends,
|
|
scope: &str,
|
|
folder: &str,
|
|
identity: &AuthIdentity,
|
|
) -> Result<(), Response> {
|
|
if identity.has_role("admin") {
|
|
return Ok(());
|
|
}
|
|
|
|
let who = identity.folder_subject();
|
|
match vault.resolve_folder_access(scope, folder, who).await {
|
|
Ok(true) => Ok(()),
|
|
Ok(false) => Err((
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "no access to this folder"})),
|
|
)
|
|
.into_response()),
|
|
Err(VaultError::NotFound) => Err((
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({"error": "folder not found"})),
|
|
)
|
|
.into_response()),
|
|
Err(e) => Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": e.to_string()})),
|
|
)
|
|
.into_response()),
|
|
}
|
|
}
|
|
|
|
/// Returns true if the user can access this folder directly, or can access
|
|
/// any descendant of it. Used to decide folder visibility: a folder the user
|
|
/// cannot enter is still shown if it is on the path to something they can
|
|
/// reach, so deeper grants are never orphaned out of the tree.
|
|
///
|
|
/// Recursion is boxed (async). The walk short-circuits on the first
|
|
/// accessible folder and is bounded by the actual subtree, which is small in
|
|
/// practice; folders the user CAN access return immediately without
|
|
/// descending.
|
|
fn folder_or_descendant_accessible<'a>(
|
|
vault: &'a VaultBackends,
|
|
scope: &'a str,
|
|
path: &'a str,
|
|
who: crate::vault::FolderSubject<'a>,
|
|
) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send + 'a>> {
|
|
Box::pin(async move {
|
|
if vault
|
|
.resolve_folder_access(scope, path, who)
|
|
.await
|
|
.unwrap_or(false)
|
|
{
|
|
return true;
|
|
}
|
|
// Not directly accessible — keep it only if some descendant is.
|
|
if let Ok(subs) = vault.list_subfolders(scope, path).await {
|
|
for sub in subs {
|
|
let child = sub.path.unwrap_or(sub.name);
|
|
if folder_or_descendant_accessible(vault, scope, &child, who).await {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
false
|
|
})
|
|
}
|
|
|
|
/// GET /api/addressbook/folders — List folders visible to the current user.
|
|
pub async fn ab_list_folders(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(vault): Extension<VaultState>,
|
|
) -> impl IntoResponse {
|
|
let id = match identity {
|
|
Some(Extension(ref id)) if id.has_role("operator") => id,
|
|
_ => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "operator role required"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
let folders = match vault.list_all_folders().await {
|
|
Ok((f, _)) => f,
|
|
Err(e) => {
|
|
return (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"error": e.to_string()})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
// Filter by access (admins see all). A top-level folder is shown if the
|
|
// user can open it OR it is on the path to a subfolder they can: access
|
|
// granted only to `users/jsmith` must still be reachable from the tree.
|
|
// The folder's own entries stay refused by the per-folder check.
|
|
let who = id.folder_subject();
|
|
let mut visible = Vec::new();
|
|
for folder in folders {
|
|
if id.has_role("admin")
|
|
|| folder_or_descendant_accessible(&vault, &folder.scope, &folder.name, who).await
|
|
{
|
|
visible.push(folder);
|
|
}
|
|
}
|
|
|
|
Json(json!(visible)).into_response()
|
|
}
|
|
|
|
/// GET /api/addressbook/folders/:scope/:folder/subfolders — List subfolders at a path.
|
|
pub async fn ab_list_subfolders(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(vault): Extension<VaultState>,
|
|
Path((scope, folder)): Path<(String, String)>,
|
|
) -> impl IntoResponse {
|
|
let id = match identity {
|
|
Some(Extension(ref id)) if id.has_role("operator") => id,
|
|
_ => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "operator role required"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
// Listing children only needs the folder to be on the user's path, not
|
|
// openable: that is how a grant on a deeper folder is reached. The
|
|
// children are filtered the same way below, so nothing off the path is
|
|
// named, and the folder's own entries are still refused elsewhere.
|
|
if !id.has_role("admin")
|
|
&& !folder_or_descendant_accessible(&vault, &scope, &folder, id.folder_subject()).await
|
|
{
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "no access to this folder"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
match vault.list_subfolders(&scope, &folder).await {
|
|
Ok(subfolders) => {
|
|
// Hide subfolders the user cannot reach. A folder is shown if the
|
|
// user can access it directly or can access any descendant of it
|
|
// (so deeper grants aren't orphaned out of the tree). Admins see
|
|
// everything. Fixes #147: previously every subfolder of an
|
|
// accessible parent was returned, so users saw folders they could
|
|
// only click into and get "no access".
|
|
if id.has_role("admin") {
|
|
return Json(json!(subfolders)).into_response();
|
|
}
|
|
let who = id.folder_subject();
|
|
let mut visible = Vec::with_capacity(subfolders.len());
|
|
for sf in subfolders {
|
|
let path = sf.path.clone().unwrap_or_else(|| sf.name.clone());
|
|
if folder_or_descendant_accessible(&vault, &scope, &path, who).await {
|
|
visible.push(sf);
|
|
}
|
|
}
|
|
Json(json!(visible)).into_response()
|
|
}
|
|
Err(crate::vault::VaultError::NotFound) => Json(json!([])).into_response(),
|
|
Err(e) => (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"error": e.to_string()})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
// ── Folder default credentials ──
|
|
|
|
/// Which default credentials a folder lends, without the secrets.
|
|
fn folder_defaults_summary(d: &crate::vault::FolderDefaults) -> serde_json::Value {
|
|
let mut out = serde_json::Map::new();
|
|
for (session_type, c) in d {
|
|
out.insert(
|
|
session_type.clone(),
|
|
json!({
|
|
"username": c.username,
|
|
"domain": c.domain,
|
|
"has_password": c.password.as_deref().is_some_and(|p| !p.is_empty()),
|
|
"has_private_key": c.private_key.as_deref().is_some_and(|k| !k.is_empty()),
|
|
}),
|
|
);
|
|
}
|
|
serde_json::Value::Object(out)
|
|
}
|
|
|
|
/// GET /api/addressbook/folders/:scope/:folder/defaults — admin only. The
|
|
/// folder's own `.defaults` (not what it inherits), secrets stripped.
|
|
pub async fn ab_get_folder_defaults(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(vault): Extension<VaultState>,
|
|
Path((scope, folder)): Path<(String, String)>,
|
|
) -> impl IntoResponse {
|
|
if !matches!(identity, Some(Extension(ref id)) if id.has_role("admin")) {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
match vault.get_folder_defaults(&scope, &folder).await {
|
|
Ok(d) => Json(json!({ "defaults": folder_defaults_summary(&d) })).into_response(),
|
|
Err(VaultError::NotFound) => Json(json!({ "defaults": {} })).into_response(),
|
|
Err(e) => (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"error": e.to_string()})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// One session type's part of a `PUT .../defaults` body. An omitted field
|
|
/// keeps what is stored, an empty string clears it; the same rule as entry
|
|
/// PUTs use for their secrets.
|
|
#[derive(Deserialize, Default)]
|
|
pub struct DefaultCredentialsPatch {
|
|
#[serde(default)]
|
|
pub username: Option<String>,
|
|
#[serde(default)]
|
|
pub password: Option<String>,
|
|
#[serde(default)]
|
|
pub private_key: Option<String>,
|
|
#[serde(default)]
|
|
pub domain: Option<String>,
|
|
}
|
|
|
|
/// PUT /api/addressbook/folders/:scope/:folder/defaults — admin only.
|
|
///
|
|
/// Body: `{ "<session type>": { username?, password?, private_key?, domain? } | null, ... }`.
|
|
/// Types not mentioned keep what is stored; `null` removes a type. Within a
|
|
/// type an omitted secret keeps the stored one and `""` clears it. A type
|
|
/// left with nothing in it is dropped; a folder left with no types loses its
|
|
/// `.defaults` key.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn ab_put_folder_defaults(
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: axum::http::HeaderMap,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
trusted: Option<Extension<TrustedProxies>>,
|
|
Extension(database): Extension<Db>,
|
|
Extension(vault): Extension<VaultState>,
|
|
Path((scope, folder)): Path<(String, String)>,
|
|
Json(patch): Json<std::collections::BTreeMap<String, Option<DefaultCredentialsPatch>>>,
|
|
) -> impl IntoResponse {
|
|
let admin_email = match identity.as_ref() {
|
|
Some(Extension(id)) if id.has_role("admin") => id.display_name().to_string(),
|
|
_ => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
for session_type in patch.keys() {
|
|
let ok = !session_type.is_empty()
|
|
&& session_type.len() <= 32
|
|
&& session_type
|
|
.chars()
|
|
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-');
|
|
if !ok {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({"error": format!("invalid session type key {:?}", session_type)})),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
let mut current = match vault.get_folder_defaults(&scope, &folder).await {
|
|
Ok(d) => d,
|
|
Err(VaultError::NotFound) => crate::vault::FolderDefaults::new(),
|
|
Err(e) => {
|
|
return (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"error": e.to_string()})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
fn non_empty(v: String) -> Option<String> {
|
|
if v.is_empty() {
|
|
None
|
|
} else {
|
|
Some(v)
|
|
}
|
|
}
|
|
|
|
for (session_type, part) in patch {
|
|
match part {
|
|
None => {
|
|
current.remove(&session_type);
|
|
}
|
|
Some(p) => {
|
|
let c = current.entry(session_type).or_default();
|
|
if let Some(u) = p.username {
|
|
c.username = non_empty(u);
|
|
}
|
|
if let Some(d) = p.domain {
|
|
c.domain = non_empty(d);
|
|
}
|
|
if let Some(pw) = p.password {
|
|
c.password = non_empty(pw);
|
|
}
|
|
if let Some(k) = p.private_key {
|
|
c.private_key = non_empty(k);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
current.retain(|_, c| c.username.is_some() || c.domain.is_some() || c.has_secret());
|
|
|
|
match vault.put_folder_defaults(&scope, &folder, ¤t).await {
|
|
Ok(()) => {
|
|
let ip = audit_client_ip(&headers, &addr, trusted.as_ref());
|
|
let details = json!({ "types": current.keys().collect::<Vec<_>>() }).to_string();
|
|
log_ab_event(
|
|
&database,
|
|
&admin_email,
|
|
"update_folder_defaults",
|
|
&scope,
|
|
&folder,
|
|
None,
|
|
&ip,
|
|
Some(&details),
|
|
)
|
|
.await;
|
|
Json(json!({ "ok": true, "defaults": folder_defaults_summary(¤t) }))
|
|
.into_response()
|
|
}
|
|
Err(e) => (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"error": e.to_string()})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// DELETE /api/addressbook/folders/:scope/:folder/defaults — admin only.
|
|
pub async fn ab_delete_folder_defaults(
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: axum::http::HeaderMap,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
trusted: Option<Extension<TrustedProxies>>,
|
|
Extension(database): Extension<Db>,
|
|
Extension(vault): Extension<VaultState>,
|
|
Path((scope, folder)): Path<(String, String)>,
|
|
) -> impl IntoResponse {
|
|
let admin_email = match identity.as_ref() {
|
|
Some(Extension(id)) if id.has_role("admin") => id.display_name().to_string(),
|
|
_ => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
match vault.delete_folder_defaults(&scope, &folder).await {
|
|
Ok(()) => {
|
|
let ip = audit_client_ip(&headers, &addr, trusted.as_ref());
|
|
log_ab_event(
|
|
&database,
|
|
&admin_email,
|
|
"delete_folder_defaults",
|
|
&scope,
|
|
&folder,
|
|
None,
|
|
&ip,
|
|
None,
|
|
)
|
|
.await;
|
|
StatusCode::NO_CONTENT.into_response()
|
|
}
|
|
Err(e) => (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"error": e.to_string()})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// Lend an entry without credentials of its own the folder defaults for its
|
|
/// session type: the nearest folder on the path wins per type, and the walk
|
|
/// stops at a folder with `inherit_from_parent: false` (see
|
|
/// `crate::vault::resolve_effective_defaults`). This is the one place the
|
|
/// Connections connect endpoint and `/api/connect` quick links resolve
|
|
/// defaults, so an entry behaves the same from either. An entry with its own
|
|
/// secret, or set to prompt, is returned untouched; a Vault error leaves it
|
|
/// as it was, and the caller then prompts as it did before defaults existed.
|
|
async fn lend_folder_defaults<S: DefaultsSource>(
|
|
src: &S,
|
|
scope: &str,
|
|
folder: &str,
|
|
entry: &str,
|
|
mut ab_entry: AddressBookEntry,
|
|
) -> AddressBookEntry {
|
|
if !crate::vault::entry_takes_folder_defaults(&ab_entry) {
|
|
return ab_entry;
|
|
}
|
|
match crate::vault::resolve_effective_defaults(src, scope, folder).await {
|
|
Ok(Some(d)) => {
|
|
if crate::vault::apply_folder_defaults(&mut ab_entry, &d) {
|
|
tracing::debug!(
|
|
scope,
|
|
folder,
|
|
entry,
|
|
"entry using folder default credentials"
|
|
);
|
|
}
|
|
}
|
|
Ok(None) => {}
|
|
Err(err) => tracing::warn!("Failed to read folder defaults from Vault: {}", err),
|
|
}
|
|
ab_entry
|
|
}
|
|
|
|
/// GET /api/addressbook — Batch endpoint returning all visible folders with entries.
|
|
/// Replaces the N+1 pattern of listing folders then entries per folder.
|
|
pub async fn ab_list_all(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(vault): Extension<VaultState>,
|
|
) -> impl IntoResponse {
|
|
let id = match identity {
|
|
Some(Extension(ref id)) if id.has_role("operator") => id,
|
|
_ => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "operator role required"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
let (folders, unavailable_scopes) = match vault.list_all_folders().await {
|
|
Ok(fu) => fu,
|
|
Err(e) => {
|
|
return (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"error": e.to_string()})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
let mut result = Vec::new();
|
|
let who = id.folder_subject();
|
|
for folder in folders {
|
|
// Access check (admins see all); inherits via resolver walk-up. A
|
|
// folder the user cannot open is still listed, without its entries,
|
|
// when it leads to a subfolder they can: that is how the tree reaches
|
|
// a grant made only on `users/jsmith`.
|
|
if !id.has_role("admin")
|
|
&& !vault
|
|
.resolve_folder_access(&folder.scope, &folder.name, who)
|
|
.await
|
|
.unwrap_or(false)
|
|
{
|
|
if folder_or_descendant_accessible(&vault, &folder.scope, &folder.name, who).await {
|
|
result.push(json!({
|
|
"name": folder.name,
|
|
"scope": folder.scope,
|
|
"path": folder.path,
|
|
"has_children": folder.has_children,
|
|
"description": folder.description,
|
|
"path_only": true,
|
|
"entries": [],
|
|
}));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Fetch folder config for the response
|
|
let config = vault
|
|
.get_folder_config(&folder.scope, &folder.name)
|
|
.await
|
|
.ok();
|
|
|
|
// Fetch entries (stripped of credentials)
|
|
let entry_names = vault
|
|
.list_entries(&folder.scope, &folder.name)
|
|
.await
|
|
.unwrap_or_default();
|
|
let defaults = vault
|
|
.effective_folder_defaults(&folder.scope, &folder.name)
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
let mut entries = Vec::new();
|
|
for name in &entry_names {
|
|
if let Ok(entry) = vault.get_entry(&folder.scope, &folder.name, name).await {
|
|
let mut info = crate::vault::EntryInfo::from((name.as_str(), &entry));
|
|
info.apply_inherited(&entry, defaults.as_ref());
|
|
entries.push(info);
|
|
}
|
|
}
|
|
|
|
result.push(json!({
|
|
"name": folder.name,
|
|
"scope": folder.scope,
|
|
"path": folder.path,
|
|
"has_children": folder.has_children,
|
|
"description": folder.description,
|
|
"allowed_groups": config.as_ref().map(|c| &c.allowed_groups),
|
|
// allowed_users is deliberately not listed: operators see this,
|
|
// and a user who can open a folder should not learn who else can.
|
|
// Admins read it from GET .../config.
|
|
"entries": entries,
|
|
}));
|
|
}
|
|
|
|
// `unavailable_scopes` lists scopes whose dedicated backend is down (empty
|
|
// in a healthy single-Vault deploy). The UI greys those scopes rather than
|
|
// silently hiding them.
|
|
Json(json!({"folders": result, "unavailable_scopes": unavailable_scopes})).into_response()
|
|
}
|
|
|
|
/// GET /api/addressbook/search-index — Flat list of every entry the user has access to,
|
|
/// across all folders and subfolders. Powers the Connections page quick-search.
|
|
///
|
|
/// Walks the full tree (top-level via list_folders, then list_subfolders per node) and
|
|
/// emits one row per entry as `{scope, folder_path, entry: EntryInfo}`. Subfolder
|
|
/// traversal is unconditional because a child may grant access independently of a
|
|
/// denied parent (resolve_folder_access semantics); ACL is enforced per folder before
|
|
/// its entries are emitted.
|
|
pub async fn ab_search_index(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(vault): Extension<VaultState>,
|
|
) -> impl IntoResponse {
|
|
let id = match identity {
|
|
Some(Extension(ref id)) if id.has_role("operator") => id,
|
|
_ => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "operator role required"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
let who = id.folder_subject();
|
|
let is_admin = id.has_role("admin");
|
|
|
|
let top = match vault.list_all_folders().await {
|
|
Ok((f, _)) => f,
|
|
Err(e) => {
|
|
return (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"error": e.to_string()})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
let mut queue: Vec<(String, String)> = top
|
|
.into_iter()
|
|
.map(|f| (f.scope, f.path.unwrap_or(f.name)))
|
|
.collect();
|
|
let mut emitted = Vec::new();
|
|
|
|
while let Some((scope, path)) = queue.pop() {
|
|
if let Ok(subs) = vault.list_subfolders(&scope, &path).await {
|
|
for s in subs {
|
|
let child_path = s.path.unwrap_or_else(|| s.name.clone());
|
|
queue.push((scope.clone(), child_path));
|
|
}
|
|
}
|
|
|
|
let allowed = is_admin
|
|
|| vault
|
|
.resolve_folder_access(&scope, &path, who)
|
|
.await
|
|
.unwrap_or(false);
|
|
if !allowed {
|
|
continue;
|
|
}
|
|
|
|
let names = match vault.list_entries(&scope, &path).await {
|
|
Ok(n) => n,
|
|
Err(_) => continue,
|
|
};
|
|
let defaults = vault
|
|
.effective_folder_defaults(&scope, &path)
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
for name in &names {
|
|
if let Ok(entry) = vault.get_entry(&scope, &path, name).await {
|
|
let mut info = crate::vault::EntryInfo::from((name.as_str(), &entry));
|
|
info.apply_inherited(&entry, defaults.as_ref());
|
|
emitted.push(json!({
|
|
"scope": scope,
|
|
"folder_path": path,
|
|
"entry": info,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
Json(json!({"entries": emitted})).into_response()
|
|
}
|
|
|
|
/// GET /api/addressbook/folders/:scope/:folder/entries — List entries in a folder.
|
|
pub async fn ab_list_entries(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(vault): Extension<VaultState>,
|
|
Path((scope, folder)): Path<(String, String)>,
|
|
) -> impl IntoResponse {
|
|
let id = match identity {
|
|
Some(Extension(ref id)) if id.has_role("operator") => id,
|
|
_ => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "operator role required"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
if let Err(resp) = check_folder_access(&vault, &scope, &folder, id).await {
|
|
return resp;
|
|
}
|
|
|
|
let entry_names = match vault.list_entries(&scope, &folder).await {
|
|
Ok(e) => e,
|
|
Err(VaultError::NotFound) => Vec::new(),
|
|
Err(e) => {
|
|
return (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"error": e.to_string()})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
// Fetch each entry and strip credentials
|
|
let defaults = vault
|
|
.effective_folder_defaults(&scope, &folder)
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
let mut entries = Vec::new();
|
|
for name in &entry_names {
|
|
if let Ok(entry) = vault.get_entry(&scope, &folder, name).await {
|
|
let mut info = crate::vault::EntryInfo::from((name.as_str(), &entry));
|
|
info.apply_inherited(&entry, defaults.as_ref());
|
|
entries.push(info);
|
|
}
|
|
}
|
|
|
|
Json(json!(entries)).into_response()
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ConnectRequest {
|
|
#[serde(default)]
|
|
pub width: Option<u32>,
|
|
#[serde(default)]
|
|
pub height: Option<u32>,
|
|
#[serde(default)]
|
|
pub dpi: Option<u32>,
|
|
#[serde(default)]
|
|
pub banner: Option<String>,
|
|
/// Override or supply credentials at connect time (never stored).
|
|
#[serde(default)]
|
|
pub username: Option<String>,
|
|
#[serde(default)]
|
|
pub password: Option<String>,
|
|
#[serde(default)]
|
|
pub domain: Option<String>,
|
|
}
|
|
|
|
/// POST /api/ssh/probe-host-key — Probe an SSH server to retrieve its host key.
|
|
pub async fn ssh_probe_host_key(
|
|
State(manager): State<AppState>,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
axum::Json(body): axum::Json<ProbeHostKeyRequest>,
|
|
) -> impl IntoResponse {
|
|
// Require admin or poweruser
|
|
match &identity {
|
|
Some(Extension(id)) if id.has_role("poweruser") => {}
|
|
_ => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
axum::Json(serde_json::json!({"error": "requires poweruser or admin role"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
let port = body.port.unwrap_or(22);
|
|
// The probe is a raw TCP + SSH key exchange to a caller-chosen host. For
|
|
// a poweruser it is fenced like an SSH session and dialled at the checked
|
|
// address; unchecked it is a port scanner and banner grabber for anything
|
|
// rustguac can reach (guacd, Vault, metadata services). Admins probe
|
|
// freely, since they configure jump hosts outside the allowlists.
|
|
let is_admin = matches!(&identity, Some(Extension(id)) if id.has_role("admin"));
|
|
let dial = if is_admin {
|
|
body.hostname.clone()
|
|
} else {
|
|
match crate::session::allowed_address(
|
|
&body.hostname,
|
|
port,
|
|
&manager.config().ssh_allowed_networks,
|
|
)
|
|
.await
|
|
{
|
|
Ok(ip) => crate::session::dial_host(ip),
|
|
Err(e) => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
axum::Json(serde_json::json!({"error": e.to_string()})),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
};
|
|
match crate::tunnel::probe_host_key(&dial, port).await {
|
|
Ok(host_key) => {
|
|
let fingerprint = crate::tunnel::fingerprint_openssh_key(&host_key)
|
|
.unwrap_or_else(|_| "unknown".into());
|
|
let algorithm = host_key
|
|
.split_whitespace()
|
|
.next()
|
|
.unwrap_or("unknown")
|
|
.to_string();
|
|
(
|
|
StatusCode::OK,
|
|
axum::Json(serde_json::json!({
|
|
"host_key": host_key,
|
|
"fingerprint": fingerprint,
|
|
"algorithm": algorithm,
|
|
})),
|
|
)
|
|
.into_response()
|
|
}
|
|
Err(e) => (
|
|
StatusCode::BAD_GATEWAY,
|
|
axum::Json(serde_json::json!({"error": e.to_string()})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ProbeHostKeyRequest {
|
|
pub hostname: String,
|
|
pub port: Option<u16>,
|
|
}
|
|
|
|
/// POST /api/addressbook/folders/:scope/:folder/entries/:entry/connect — Create session from entry.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn ab_connect_entry(
|
|
State(manager): State<AppState>,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: axum::http::HeaderMap,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
trusted: Option<Extension<TrustedProxies>>,
|
|
Extension(vault): Extension<VaultState>,
|
|
Path((scope, folder, entry)): Path<(String, String, String)>,
|
|
Json(req): Json<ConnectRequest>,
|
|
) -> impl IntoResponse {
|
|
let id = match identity {
|
|
Some(Extension(ref id)) if id.has_role("operator") => id.clone(),
|
|
_ => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "operator role required"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
if let Err(resp) = check_folder_access(&vault, &scope, &folder, &id).await {
|
|
return resp;
|
|
}
|
|
|
|
// Read full entry (with credentials) from Vault
|
|
let ab_entry = match vault.get_entry(&scope, &folder, &entry).await {
|
|
Ok(e) => e,
|
|
Err(VaultError::NotFound) => {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({"error": "entry not found"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
Err(e) => {
|
|
return (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"error": e.to_string()})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
let ab_entry = lend_folder_defaults(vault.as_ref(), &scope, &folder, &entry, ab_entry).await;
|
|
|
|
// Resolve credential variable references ($domain_user, $domain_password, etc.)
|
|
let ab_entry = if !crate::vault::entry_credential_variables(&ab_entry).is_empty() {
|
|
let user_email = match &id {
|
|
AuthIdentity::User { email, .. } => Some(email.clone()),
|
|
_ => None,
|
|
};
|
|
if let Some(email) = user_email {
|
|
match vault.get_user_credentials(&email).await {
|
|
Ok(user_creds) => {
|
|
match crate::vault::resolve_credential_variables(&ab_entry, &user_creds) {
|
|
Ok(resolved) => resolved,
|
|
Err(missing) => {
|
|
return (
|
|
StatusCode::PRECONDITION_FAILED,
|
|
Json(json!({
|
|
"error": "missing credential variables",
|
|
"missing_variables": missing,
|
|
})),
|
|
)
|
|
.into_response()
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("Failed to read user credentials from Vault: {}", e);
|
|
ab_entry // Fall through with unresolved variables
|
|
}
|
|
}
|
|
} else {
|
|
ab_entry // API key users — no variable resolution
|
|
}
|
|
} else {
|
|
ab_entry
|
|
};
|
|
|
|
// Map address book entry type to SessionType
|
|
let session_type = match ab_entry.session_type.as_str() {
|
|
"ssh" => SessionType::Ssh,
|
|
"rdp" => SessionType::Rdp,
|
|
"vnc" => SessionType::Vnc,
|
|
"spice" => SessionType::Spice,
|
|
"proxmox" => SessionType::Proxmox,
|
|
"web" => SessionType::Web,
|
|
"vdi" => SessionType::Vdi,
|
|
other => {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({"error": format!("unknown session type: {}", other)})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
// Build CreateSessionRequest from the Vault entry + connect request display params.
|
|
// ConnectRequest credentials override address book values (for prompted credentials).
|
|
let ab_entry_key = format!("{}/{}/{}", scope, folder, entry);
|
|
let create_req = CreateSessionRequest {
|
|
session_type,
|
|
hostname: ab_entry.hostname,
|
|
port: ab_entry.port,
|
|
username: req.username.or(ab_entry.username),
|
|
password: req.password.or(ab_entry.password),
|
|
private_key: ab_entry.private_key,
|
|
generate_keypair: None,
|
|
url: ab_entry.url,
|
|
domain: req.domain.or(ab_entry.domain),
|
|
security: ab_entry.security,
|
|
server_layout: ab_entry.server_layout,
|
|
ignore_cert: ab_entry.ignore_cert,
|
|
auth_pkg: ab_entry.auth_pkg,
|
|
kdc_url: ab_entry.kdc_url,
|
|
kerberos_cache: None,
|
|
color_depth: ab_entry.color_depth,
|
|
jump_hosts: ab_entry.jump_hosts,
|
|
jump_host: None,
|
|
jump_port: None,
|
|
jump_username: None,
|
|
jump_password: None,
|
|
jump_private_key: None,
|
|
width: req.width,
|
|
height: req.height,
|
|
dpi: req.dpi,
|
|
banner: req.banner.or(ab_entry.banner),
|
|
enable_drive: ab_entry.enable_drive,
|
|
remote_app: ab_entry.remote_app,
|
|
remote_app_dir: ab_entry.remote_app_dir,
|
|
remote_app_args: ab_entry.remote_app_args,
|
|
enable_recording: ab_entry.enable_recording,
|
|
record_typescript: ab_entry.record_typescript,
|
|
address_book_entry: Some(ab_entry_key),
|
|
address_book_folder: Some(folder.to_string()),
|
|
entry_display_name: ab_entry.display_name.clone(),
|
|
max_recordings: ab_entry.max_recordings,
|
|
login_script: ab_entry.login_script,
|
|
autofill: ab_entry.autofill,
|
|
allowed_domains: ab_entry.allowed_domains,
|
|
disable_copy: ab_entry.disable_copy,
|
|
disable_paste: ab_entry.disable_paste,
|
|
enable_gfx: ab_entry.enable_gfx,
|
|
enable_desktop_composition: ab_entry.enable_desktop_composition,
|
|
enable_wallpaper: ab_entry.enable_wallpaper,
|
|
enable_theming: ab_entry.enable_theming,
|
|
enable_full_window_drag: ab_entry.enable_full_window_drag,
|
|
force_lossless: ab_entry.force_lossless,
|
|
enable_h264: ab_entry.enable_h264,
|
|
container_image: ab_entry.container_image,
|
|
container_cpu_limit: ab_entry.container_cpu_limit,
|
|
container_memory_limit: ab_entry.container_memory_limit,
|
|
container_env: ab_entry.container_env,
|
|
container_idle_timeout_mins: ab_entry.container_idle_timeout_mins,
|
|
container_username: ab_entry.container_username,
|
|
container_password: ab_entry.container_password,
|
|
allow_sharing: ab_entry.allow_sharing,
|
|
fullscreen_on_connect: ab_entry.fullscreen_on_connect,
|
|
autohide_side_tabs: ab_entry.autohide_side_tabs,
|
|
spice_tls: ab_entry.spice_tls,
|
|
spice_tls_port: ab_entry.spice_tls_port,
|
|
spice_ca_cert: ab_entry.spice_ca_cert,
|
|
spice_cert_subject: ab_entry.spice_cert_subject,
|
|
spice_proxy: ab_entry.spice_proxy,
|
|
proxmox_url: ab_entry.proxmox_url,
|
|
proxmox_node: ab_entry.proxmox_node,
|
|
proxmox_vmid: ab_entry.proxmox_vmid,
|
|
proxmox_token_id: ab_entry.proxmox_token_id,
|
|
proxmox_token_secret: ab_entry.proxmox_token_secret,
|
|
proxmox_verify_tls: ab_entry.proxmox_verify_tls,
|
|
max_monitors: ab_entry.max_monitors,
|
|
owner: None,
|
|
fence_all_targets: false,
|
|
ssh_font_size: ab_entry.ssh_font_size,
|
|
wol_send_packet: ab_entry.wol_send_packet,
|
|
wol_mac_addr: ab_entry.wol_mac_addr,
|
|
wol_broadcast_addr: ab_entry.wol_broadcast_addr,
|
|
wol_udp_port: ab_entry.wol_udp_port,
|
|
wol_wait_time: ab_entry.wol_wait_time,
|
|
};
|
|
|
|
let proxies = trusted.map(|Extension(t)| t.0).unwrap_or_default();
|
|
let client_ip_addr = client_ip(&headers, addr.ip(), &proxies);
|
|
let admin_name = id.display_name().to_string();
|
|
|
|
tracing::info!(
|
|
user = %admin_name,
|
|
client_ip = %client_ip_addr,
|
|
folder = %folder,
|
|
entry = %entry,
|
|
scope = %scope,
|
|
"Address book connect requested"
|
|
);
|
|
|
|
match manager.create_session(create_req, admin_name.clone()).await {
|
|
Ok(info) => {
|
|
tracing::info!(
|
|
user = %admin_name,
|
|
session_id = %info.session_id,
|
|
"Address book session created"
|
|
);
|
|
(StatusCode::CREATED, Json(json!(info))).into_response()
|
|
}
|
|
Err(e) => {
|
|
let msg = e.to_string();
|
|
tracing::error!(user = %admin_name, error = %msg, "Address book session creation failed");
|
|
let status = match &e {
|
|
crate::session::SessionError::ValidationError(_) => StatusCode::BAD_REQUEST,
|
|
_ => StatusCode::BAD_GATEWAY,
|
|
};
|
|
(status, Json(json!({"error": msg}))).into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Admin-only address book management ──
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct CreateFolderRequest {
|
|
pub name: String,
|
|
pub allowed_groups: Vec<String>,
|
|
/// Login emails granted access in addition to `allowed_groups`.
|
|
#[serde(default)]
|
|
pub allowed_users: Vec<String>,
|
|
#[serde(default)]
|
|
pub description: String,
|
|
/// "shared" or "instance" (default: "shared")
|
|
#[serde(default = "default_scope")]
|
|
pub scope: String,
|
|
#[serde(default)]
|
|
pub inherit_from_parent: bool,
|
|
}
|
|
|
|
fn default_scope() -> String {
|
|
"shared".into()
|
|
}
|
|
|
|
/// Most users one folder may list in `allowed_users`.
|
|
const MAX_ALLOWED_USERS: usize = 500;
|
|
|
|
/// Tidy a folder's `allowed_users`: trim, drop blanks and case-insensitive
|
|
/// repeats, and refuse anything that cannot be a login identity. Usually an
|
|
/// email, but not required to contain `@`: rustguac falls back to the OIDC
|
|
/// subject when a provider sends no email claim.
|
|
fn normalize_allowed_users(users: Vec<String>) -> Result<Vec<String>, String> {
|
|
let mut out: Vec<String> = Vec::new();
|
|
for raw in users {
|
|
let u = raw.trim();
|
|
if u.is_empty() {
|
|
continue;
|
|
}
|
|
if u.len() > 320 || u.chars().any(|c| c.is_control() || c.is_whitespace()) {
|
|
return Err(format!("not a valid user identity: {u:?}"));
|
|
}
|
|
if !out.iter().any(|o| o.eq_ignore_ascii_case(u)) {
|
|
out.push(u.to_string());
|
|
}
|
|
}
|
|
if out.len() > MAX_ALLOWED_USERS {
|
|
return Err(format!("at most {MAX_ALLOWED_USERS} users per folder"));
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Resolve the caller's client IP (honouring trusted proxies) into a string
|
|
/// for audit logging. Returns the empty string if the identity is anonymous.
|
|
fn audit_client_ip(
|
|
headers: &axum::http::HeaderMap,
|
|
addr: &SocketAddr,
|
|
trusted: Option<&Extension<TrustedProxies>>,
|
|
) -> String {
|
|
let proxies = trusted.map(|Extension(t)| t.0.as_slice()).unwrap_or(&[]);
|
|
client_ip(headers, addr.ip(), proxies).to_string()
|
|
}
|
|
|
|
/// Persist a connections audit row asynchronously via spawn_blocking.
|
|
/// Errors are swallowed: an audit write must never cause a user request to
|
|
/// fail. Details must be headline-only (counts, field-name lists, booleans);
|
|
/// never entry field values (see feedback_audit_log_scope.md).
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn log_ab_event(
|
|
database: &Db,
|
|
email: &str,
|
|
action: &str,
|
|
scope: &str,
|
|
folder_path: &str,
|
|
entry_name: Option<&str>,
|
|
ip: &str,
|
|
details: Option<&str>,
|
|
) {
|
|
let database = database.clone();
|
|
let email = email.to_string();
|
|
let action = action.to_string();
|
|
let scope = scope.to_string();
|
|
let folder_path = folder_path.to_string();
|
|
let entry_name = entry_name.map(str::to_string);
|
|
let ip = ip.to_string();
|
|
let details = details.map(str::to_string);
|
|
let _ = tokio::task::spawn_blocking(move || {
|
|
let _ = db::log_addressbook_event(
|
|
&database,
|
|
&email,
|
|
&action,
|
|
&scope,
|
|
&folder_path,
|
|
entry_name.as_deref(),
|
|
Some(&ip),
|
|
details.as_deref(),
|
|
);
|
|
})
|
|
.await;
|
|
}
|
|
|
|
/// POST /api/addressbook/folders — Create a new folder. Admin only.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn ab_create_folder(
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: axum::http::HeaderMap,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
trusted: Option<Extension<TrustedProxies>>,
|
|
Extension(database): Extension<Db>,
|
|
Extension(vault): Extension<VaultState>,
|
|
Json(req): Json<CreateFolderRequest>,
|
|
) -> impl IntoResponse {
|
|
let admin_email = match identity.as_ref() {
|
|
Some(Extension(id)) if id.has_role("admin") => id.display_name().to_string(),
|
|
_ => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
let allowed_users = match normalize_allowed_users(req.allowed_users) {
|
|
Ok(u) => u,
|
|
Err(msg) => return ab_error(StatusCode::BAD_REQUEST, msg),
|
|
};
|
|
let allowed_count = req.allowed_groups.len();
|
|
let users_count = allowed_users.len();
|
|
let inherit = req.inherit_from_parent;
|
|
let config = FolderConfig {
|
|
allowed_groups: req.allowed_groups,
|
|
allowed_users,
|
|
description: req.description,
|
|
inherit_from_parent: req.inherit_from_parent,
|
|
};
|
|
|
|
match vault
|
|
.put_folder_config(&req.scope, &req.name, &config)
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
let ip = audit_client_ip(&headers, &addr, trusted.as_ref());
|
|
let details = json!({
|
|
"allowed_groups_count": allowed_count,
|
|
"allowed_users_count": users_count,
|
|
"inherit_from_parent": inherit,
|
|
})
|
|
.to_string();
|
|
log_ab_event(
|
|
&database,
|
|
&admin_email,
|
|
"create_folder",
|
|
&req.scope,
|
|
&req.name,
|
|
None,
|
|
&ip,
|
|
Some(&details),
|
|
)
|
|
.await;
|
|
(StatusCode::CREATED, Json(json!({"ok": true}))).into_response()
|
|
}
|
|
Err(e) => (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"error": e.to_string()})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct UpdateFolderRequest {
|
|
pub allowed_groups: Vec<String>,
|
|
/// Login emails granted access. Omitted means "leave as is", so a client
|
|
/// that predates this field cannot wipe the list by saving the folder.
|
|
#[serde(default)]
|
|
pub allowed_users: Option<Vec<String>>,
|
|
#[serde(default)]
|
|
pub description: String,
|
|
#[serde(default)]
|
|
pub inherit_from_parent: bool,
|
|
}
|
|
|
|
/// PUT /api/addressbook/folders/:scope/:folder — Update folder config. Admin only.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn ab_update_folder(
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: axum::http::HeaderMap,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
trusted: Option<Extension<TrustedProxies>>,
|
|
Extension(database): Extension<Db>,
|
|
Extension(vault): Extension<VaultState>,
|
|
Path((scope, folder)): Path<(String, String)>,
|
|
Json(req): Json<UpdateFolderRequest>,
|
|
) -> impl IntoResponse {
|
|
let admin_email = match identity.as_ref() {
|
|
Some(Extension(id)) if id.has_role("admin") => id.display_name().to_string(),
|
|
_ => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
let allowed_users = match req.allowed_users {
|
|
Some(users) => match normalize_allowed_users(users) {
|
|
Ok(u) => u,
|
|
Err(msg) => return ab_error(StatusCode::BAD_REQUEST, msg),
|
|
},
|
|
None => match vault.get_folder_config(&scope, &folder).await {
|
|
Ok(existing) => existing.allowed_users,
|
|
Err(VaultError::NotFound) => Vec::new(),
|
|
Err(e) => return ab_error(StatusCode::BAD_GATEWAY, e.to_string()),
|
|
},
|
|
};
|
|
let allowed_count = req.allowed_groups.len();
|
|
let users_count = allowed_users.len();
|
|
let inherit = req.inherit_from_parent;
|
|
let config = FolderConfig {
|
|
allowed_groups: req.allowed_groups,
|
|
allowed_users,
|
|
description: req.description,
|
|
inherit_from_parent: req.inherit_from_parent,
|
|
};
|
|
|
|
match vault.put_folder_config(&scope, &folder, &config).await {
|
|
Ok(()) => {
|
|
let ip = audit_client_ip(&headers, &addr, trusted.as_ref());
|
|
let details = json!({
|
|
"allowed_groups_count": allowed_count,
|
|
"allowed_users_count": users_count,
|
|
"inherit_from_parent": inherit,
|
|
})
|
|
.to_string();
|
|
log_ab_event(
|
|
&database,
|
|
&admin_email,
|
|
"update_folder",
|
|
&scope,
|
|
&folder,
|
|
None,
|
|
&ip,
|
|
Some(&details),
|
|
)
|
|
.await;
|
|
Json(json!({"ok": true})).into_response()
|
|
}
|
|
Err(e) => (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"error": e.to_string()})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// GET /api/addressbook/folders/:scope/:folder/config — Read the stored
|
|
/// FolderConfig (allowed_groups, description). Admin only — used by the
|
|
/// folder modal to prefill the allowed-groups picker on edit.
|
|
pub async fn ab_get_folder_config(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(vault): Extension<VaultState>,
|
|
Path((scope, folder)): Path<(String, String)>,
|
|
) -> impl IntoResponse {
|
|
if !identity
|
|
.as_ref()
|
|
.map(|Extension(id)| id.has_role("admin"))
|
|
.unwrap_or(false)
|
|
{
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
match vault.get_folder_config(&scope, &folder).await {
|
|
Ok(cfg) => Json(json!({
|
|
"allowed_groups": cfg.allowed_groups,
|
|
"allowed_users": cfg.allowed_users,
|
|
"description": cfg.description,
|
|
"inherit_from_parent": cfg.inherit_from_parent,
|
|
}))
|
|
.into_response(),
|
|
Err(crate::vault::VaultError::NotFound) => Json(json!({
|
|
"allowed_groups": Vec::<String>::new(),
|
|
"allowed_users": Vec::<String>::new(),
|
|
"description": "",
|
|
"inherit_from_parent": false,
|
|
}))
|
|
.into_response(),
|
|
Err(e) => (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"error": e.to_string()})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// DELETE /api/addressbook/folders/:scope/:folder — Delete folder and all entries. Admin only.
|
|
pub async fn ab_delete_folder(
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: axum::http::HeaderMap,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
trusted: Option<Extension<TrustedProxies>>,
|
|
Extension(database): Extension<Db>,
|
|
Extension(vault): Extension<VaultState>,
|
|
Path((scope, folder)): Path<(String, String)>,
|
|
) -> impl IntoResponse {
|
|
let admin_email = match identity.as_ref() {
|
|
Some(Extension(id)) if id.has_role("admin") => id.display_name().to_string(),
|
|
_ => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
match vault.delete_folder(&scope, &folder).await {
|
|
Ok((subfolders, entries)) => {
|
|
let ip = audit_client_ip(&headers, &addr, trusted.as_ref());
|
|
let details = json!({
|
|
"subfolders_deleted": subfolders,
|
|
"entries_deleted": entries,
|
|
})
|
|
.to_string();
|
|
log_ab_event(
|
|
&database,
|
|
&admin_email,
|
|
"delete_folder",
|
|
&scope,
|
|
&folder,
|
|
None,
|
|
&ip,
|
|
Some(&details),
|
|
)
|
|
.await;
|
|
Json(json!({
|
|
"ok": true,
|
|
"subfolders_deleted": subfolders,
|
|
"entries_deleted": entries,
|
|
}))
|
|
.into_response()
|
|
}
|
|
Err(e) => (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"error": e.to_string()})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct CreateEntryRequest {
|
|
pub name: String,
|
|
#[serde(flatten)]
|
|
pub entry: AddressBookEntry,
|
|
}
|
|
|
|
/// POST /api/addressbook/folders/:scope/:folder/entries — Create/update an entry. Admin only.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn ab_create_entry(
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: axum::http::HeaderMap,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
trusted: Option<Extension<TrustedProxies>>,
|
|
Extension(database): Extension<Db>,
|
|
Extension(vault): Extension<VaultState>,
|
|
Path((scope, folder)): Path<(String, String)>,
|
|
Json(req): Json<CreateEntryRequest>,
|
|
) -> impl IntoResponse {
|
|
let admin_email = match identity.as_ref() {
|
|
Some(Extension(id)) if id.has_role("admin") => id.display_name().to_string(),
|
|
_ => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
let session_type = req.entry.session_type.clone();
|
|
match vault
|
|
.put_entry(&scope, &folder, &req.name, &req.entry)
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
let ip = audit_client_ip(&headers, &addr, trusted.as_ref());
|
|
let details = json!({ "type": session_type }).to_string();
|
|
log_ab_event(
|
|
&database,
|
|
&admin_email,
|
|
"create_entry",
|
|
&scope,
|
|
&folder,
|
|
Some(&req.name),
|
|
&ip,
|
|
Some(&details),
|
|
)
|
|
.await;
|
|
(StatusCode::CREATED, Json(json!({"ok": true}))).into_response()
|
|
}
|
|
Err(e) => (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"error": e.to_string()})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// Query string of `PUT .../entries/:entry`.
|
|
#[derive(Deserialize, Default)]
|
|
pub struct EntryPutQuery {
|
|
/// `?move=true`: before the update, find the entry whose `external_id`
|
|
/// matches the body's anywhere in the scope and move it here, secrets
|
|
/// included, so a synced device that changes folder or name is moved
|
|
/// rather than duplicated. Needs `external_id` in the body; the name is
|
|
/// never used to match across folders.
|
|
#[serde(rename = "move", default)]
|
|
pub move_: bool,
|
|
}
|
|
|
|
/// The entry storage the update and move paths work on. A trait, like
|
|
/// `FolderSource`, so the locate / move / merge sequence of
|
|
/// `PUT ...?move=true` is unit-testable without a live Vault.
|
|
#[allow(async_fn_in_trait)]
|
|
trait EntryStore {
|
|
/// Every folder path in `scope`, top level and below.
|
|
async fn folder_paths(&self, scope: &str) -> Result<Vec<String>, VaultError>;
|
|
/// Entry names in one folder, subfolders excluded.
|
|
async fn entry_names(&self, scope: &str, folder: &str) -> Result<Vec<String>, VaultError>;
|
|
/// The full entry, secrets included.
|
|
async fn get_entry(
|
|
&self,
|
|
scope: &str,
|
|
folder: &str,
|
|
entry: &str,
|
|
) -> Result<AddressBookEntry, VaultError>;
|
|
async fn put_entry(
|
|
&self,
|
|
scope: &str,
|
|
folder: &str,
|
|
entry: &str,
|
|
data: &AddressBookEntry,
|
|
) -> Result<(), VaultError>;
|
|
/// Write only if nothing is stored there; `Ok(false)` when something is.
|
|
async fn put_entry_if_absent(
|
|
&self,
|
|
scope: &str,
|
|
folder: &str,
|
|
entry: &str,
|
|
data: &AddressBookEntry,
|
|
) -> Result<bool, VaultError>;
|
|
async fn delete_entry(&self, scope: &str, folder: &str, entry: &str) -> Result<(), VaultError>;
|
|
}
|
|
|
|
impl EntryStore for VaultBackends {
|
|
async fn folder_paths(&self, scope: &str) -> Result<Vec<String>, VaultError> {
|
|
let client = self.scoped(scope).await?;
|
|
let mut queue: Vec<String> = client
|
|
.list_folders_in_scope(scope)
|
|
.await?
|
|
.into_iter()
|
|
.map(|f| f.path.unwrap_or(f.name))
|
|
.collect();
|
|
let mut out = Vec::new();
|
|
while let Some(path) = queue.pop() {
|
|
match client.list_subfolders(scope, &path).await {
|
|
Ok(subs) => {
|
|
for s in subs {
|
|
queue.push(s.path.unwrap_or_else(|| format!("{path}/{}", s.name)));
|
|
}
|
|
}
|
|
// Emptied since it was listed.
|
|
Err(VaultError::NotFound) => {}
|
|
Err(e) => return Err(e),
|
|
}
|
|
out.push(path);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
async fn entry_names(&self, scope: &str, folder: &str) -> Result<Vec<String>, VaultError> {
|
|
// The KV listing also returns subfolders, as `name/`.
|
|
match VaultBackends::list_entries(self, scope, folder).await {
|
|
Ok(names) => Ok(names.into_iter().filter(|n| !n.ends_with('/')).collect()),
|
|
Err(VaultError::NotFound) => Ok(Vec::new()),
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
async fn get_entry(
|
|
&self,
|
|
scope: &str,
|
|
folder: &str,
|
|
entry: &str,
|
|
) -> Result<AddressBookEntry, VaultError> {
|
|
VaultBackends::get_entry(self, scope, folder, entry).await
|
|
}
|
|
|
|
async fn put_entry(
|
|
&self,
|
|
scope: &str,
|
|
folder: &str,
|
|
entry: &str,
|
|
data: &AddressBookEntry,
|
|
) -> Result<(), VaultError> {
|
|
VaultBackends::put_entry(self, scope, folder, entry, data).await
|
|
}
|
|
|
|
async fn put_entry_if_absent(
|
|
&self,
|
|
scope: &str,
|
|
folder: &str,
|
|
entry: &str,
|
|
data: &AddressBookEntry,
|
|
) -> Result<bool, VaultError> {
|
|
VaultBackends::put_entry_if_absent(self, scope, folder, entry, data).await
|
|
}
|
|
|
|
async fn delete_entry(&self, scope: &str, folder: &str, entry: &str) -> Result<(), VaultError> {
|
|
VaultBackends::delete_entry(self, scope, folder, entry).await
|
|
}
|
|
}
|
|
|
|
/// An update merged onto the stored entry: anything the request carries
|
|
/// wins, and every secret it omits is kept, because neither the edit form
|
|
/// (which never receives secrets) nor a sync source such as the NetBox
|
|
/// webhook (which has none) can send them back. Jump host credentials are
|
|
/// kept per hop by index; `external_id` is kept the same way so an edit
|
|
/// from the form does not cut a synced entry loose from its source.
|
|
fn merge_entry_update(data: AddressBookEntry, existing: AddressBookEntry) -> AddressBookEntry {
|
|
let merged_jump_hosts = if let Some(ref new_hops) = data.jump_hosts {
|
|
let old_hops = existing.jump_hosts.as_deref().unwrap_or(&[]);
|
|
let merged: Vec<_> = new_hops
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, hop)| {
|
|
let old = old_hops.get(i);
|
|
crate::tunnel::JumpHost {
|
|
hostname: hop.hostname.clone(),
|
|
port: hop.port,
|
|
username: hop.username.clone(),
|
|
password: hop
|
|
.password
|
|
.clone()
|
|
.or_else(|| old.and_then(|o| o.password.clone())),
|
|
private_key: hop
|
|
.private_key
|
|
.clone()
|
|
.or_else(|| old.and_then(|o| o.private_key.clone())),
|
|
host_key: hop
|
|
.host_key
|
|
.clone()
|
|
.or_else(|| old.and_then(|o| o.host_key.clone())),
|
|
}
|
|
})
|
|
.collect();
|
|
Some(merged)
|
|
} else {
|
|
data.jump_hosts.clone()
|
|
};
|
|
|
|
AddressBookEntry {
|
|
password: data.password.or(existing.password),
|
|
private_key: data.private_key.or(existing.private_key),
|
|
container_password: data.container_password.or(existing.container_password),
|
|
proxmox_token_secret: data.proxmox_token_secret.or(existing.proxmox_token_secret),
|
|
external_id: data.external_id.or(existing.external_id),
|
|
jump_hosts: merged_jump_hosts,
|
|
// Clear legacy flat fields — they've been migrated
|
|
jump_password: None,
|
|
jump_private_key: None,
|
|
..data
|
|
}
|
|
}
|
|
|
|
/// Where `PUT ...?move=true` found the entry before bringing it to the
|
|
/// requested folder and name.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
struct MovedFrom {
|
|
folder: String,
|
|
name: String,
|
|
}
|
|
|
|
/// Why an upsert did not complete.
|
|
#[derive(Debug)]
|
|
enum UpsertError {
|
|
/// Nothing was changed.
|
|
Rejected(StatusCode, String),
|
|
/// The entry was copied to the requested folder but the original could
|
|
/// not be removed, so it now exists in both; the update was not applied.
|
|
MoveIncomplete { from: MovedFrom, error: VaultError },
|
|
/// The write of the update failed, after the move if there was one.
|
|
UpdateFailed {
|
|
moved_from: Option<MovedFrom>,
|
|
error: VaultError,
|
|
},
|
|
}
|
|
|
|
/// `?move=true`: find the entry stored under `external_id` in `scope` and
|
|
/// bring it to `folder`/`entry`, with everything stored in it, before the
|
|
/// update is applied. Returns where it came from, or `None` when nothing
|
|
/// had to move (it is already there, or there is no such entry yet).
|
|
///
|
|
/// Matching is on `external_id` only. Entries elsewhere in the scope that
|
|
/// merely share the name are not the same device (two tenants can each
|
|
/// have a `fw01`) and are never touched.
|
|
async fn relocate_by_external_id<S: EntryStore>(
|
|
store: &S,
|
|
scope: &str,
|
|
folder: &str,
|
|
entry: &str,
|
|
external_id: &str,
|
|
) -> Result<Option<MovedFrom>, UpsertError> {
|
|
let reject = |e: VaultError| {
|
|
let (status, msg) = ab_vault_error(e);
|
|
UpsertError::Rejected(status, msg)
|
|
};
|
|
|
|
// Already here: the steady state of a sync, and the only case that
|
|
// needs no scan.
|
|
let adopting = match store.get_entry(scope, folder, entry).await {
|
|
Ok(existing) => match existing.external_id.as_deref() {
|
|
Some(other) if other != external_id => {
|
|
return Err(UpsertError::Rejected(
|
|
StatusCode::CONFLICT,
|
|
format!("{folder}/{entry} belongs to another device (external_id {other})"),
|
|
))
|
|
}
|
|
Some(_) => return Ok(None),
|
|
// An entry made by hand before syncing began: it is updated in
|
|
// place and adopts the id, but only once the scan below shows no
|
|
// other entry already carries it, or the id would end up on two.
|
|
None => true,
|
|
},
|
|
Err(VaultError::NotFound) => false,
|
|
Err(e) => return Err(reject(e)),
|
|
};
|
|
|
|
let mut found = Vec::new();
|
|
for other_folder in store.folder_paths(scope).await.map_err(reject)? {
|
|
for name in store
|
|
.entry_names(scope, &other_folder)
|
|
.await
|
|
.map_err(reject)?
|
|
{
|
|
if other_folder == folder && name == entry {
|
|
continue;
|
|
}
|
|
let stored = match store.get_entry(scope, &other_folder, &name).await {
|
|
Ok(e) => e,
|
|
Err(VaultError::NotFound) => continue, // deleted since it was listed
|
|
Err(e) => return Err(reject(e)),
|
|
};
|
|
if stored.external_id.as_deref() == Some(external_id) {
|
|
found.push(MovedFrom {
|
|
folder: other_folder.clone(),
|
|
name,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if adopting && !found.is_empty() {
|
|
let where_ = found
|
|
.iter()
|
|
.map(|f| format!("{}/{}", f.folder, f.name))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
return Err(UpsertError::Rejected(
|
|
StatusCode::CONFLICT,
|
|
format!(
|
|
"{folder}/{entry} is an entry without an external_id, and external_id {external_id} \
|
|
is already stored on {where_}; nothing was changed"
|
|
),
|
|
));
|
|
}
|
|
|
|
match found.len() {
|
|
0 => Ok(None),
|
|
1 => {
|
|
let from = found.remove(0);
|
|
match move_stored_entry(store, scope, &from.folder, &from.name, scope, folder, entry)
|
|
.await
|
|
{
|
|
Ok(_) => Ok(Some(from)),
|
|
Err(MoveError::Copy(status, msg)) => Err(UpsertError::Rejected(status, msg)),
|
|
Err(MoveError::Incomplete(error)) => {
|
|
Err(UpsertError::MoveIncomplete { from, error })
|
|
}
|
|
}
|
|
}
|
|
n => {
|
|
let where_ = found
|
|
.iter()
|
|
.map(|f| format!("{}/{}", f.folder, f.name))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
Err(UpsertError::Rejected(
|
|
StatusCode::CONFLICT,
|
|
format!("external_id {external_id} is stored on {n} entries ({where_}); nothing was changed"),
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What a completed upsert wrote, and where the entry came from if
|
|
/// `?move=true` moved it.
|
|
#[derive(Debug)]
|
|
struct UpsertOutcome {
|
|
moved_from: Option<MovedFrom>,
|
|
entry: AddressBookEntry,
|
|
}
|
|
|
|
/// `PUT .../entries/:entry` without the HTTP: locate (and move) by
|
|
/// `external_id` when asked, then read-modify-write so the request's fields
|
|
/// land on top of everything stored, secrets included.
|
|
async fn upsert_entry<S: EntryStore>(
|
|
store: &S,
|
|
scope: &str,
|
|
folder: &str,
|
|
entry: &str,
|
|
data: AddressBookEntry,
|
|
move_: bool,
|
|
) -> Result<UpsertOutcome, UpsertError> {
|
|
let moved_from = if move_ {
|
|
let Some(external_id) = data.external_id.as_deref().filter(|id| !id.is_empty()) else {
|
|
return Err(UpsertError::Rejected(
|
|
StatusCode::BAD_REQUEST,
|
|
"move=true needs external_id in the body: the entry is matched on it, not on its name"
|
|
.into(),
|
|
));
|
|
};
|
|
relocate_by_external_id(store, scope, folder, entry, external_id).await?
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let merged = match store.get_entry(scope, folder, entry).await {
|
|
Ok(existing) => merge_entry_update(data, existing),
|
|
Err(VaultError::NotFound) => data,
|
|
// Writing the request as-is here would replace a stored entry with
|
|
// one that has no secrets, so a failed read is an error, not a create.
|
|
Err(error) => return Err(UpsertError::UpdateFailed { moved_from, error }),
|
|
};
|
|
|
|
match store.put_entry(scope, folder, entry, &merged).await {
|
|
Ok(()) => Ok(UpsertOutcome {
|
|
moved_from,
|
|
entry: merged,
|
|
}),
|
|
Err(error) => Err(UpsertError::UpdateFailed { moved_from, error }),
|
|
}
|
|
}
|
|
|
|
/// PUT /api/addressbook/folders/:scope/:folder/entries/:entry — Update an entry,
|
|
/// creating it if there is none. Admin only.
|
|
///
|
|
/// Read-modify-write: the stored entry is read first and the request's
|
|
/// fields are merged on top, so credentials the request omits are kept (see
|
|
/// `merge_entry_update`). With `?move=true` the entry is first located by
|
|
/// `external_id` anywhere in the scope and moved here, credentials included,
|
|
/// by the same path as `POST .../move` (see `relocate_by_external_id`).
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn ab_update_entry(
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: axum::http::HeaderMap,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
trusted: Option<Extension<TrustedProxies>>,
|
|
Extension(database): Extension<Db>,
|
|
Extension(vault): Extension<VaultState>,
|
|
Path((scope, folder, entry)): Path<(String, String, String)>,
|
|
Query(query): Query<EntryPutQuery>,
|
|
Json(data): Json<AddressBookEntry>,
|
|
) -> impl IntoResponse {
|
|
let admin_email = match identity.as_ref() {
|
|
Some(Extension(id)) if id.has_role("admin") => id.display_name().to_string(),
|
|
_ => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
let ip = audit_client_ip(&headers, &addr, trusted.as_ref());
|
|
let external_id = data.external_id.clone();
|
|
|
|
let result = upsert_entry(vault.as_ref(), &scope, &folder, &entry, data, query.move_).await;
|
|
|
|
// A move that happened is recorded whatever came after it, as the move
|
|
// endpoint records it, so the audit log says where the entry went.
|
|
let (moved_from, incomplete) = match &result {
|
|
Ok(outcome) => (outcome.moved_from.as_ref(), false),
|
|
Err(UpsertError::UpdateFailed { moved_from, .. }) => (moved_from.as_ref(), false),
|
|
Err(UpsertError::MoveIncomplete { from, .. }) => (Some(from), true),
|
|
Err(UpsertError::Rejected(..)) => (None, false),
|
|
};
|
|
if let Some(from) = moved_from {
|
|
let details = json!({
|
|
"to_scope": scope,
|
|
"to_folder": folder,
|
|
"to_name": entry,
|
|
"external_id": external_id,
|
|
})
|
|
.to_string();
|
|
log_ab_event(
|
|
&database,
|
|
&admin_email,
|
|
if incomplete {
|
|
"move_entry_incomplete"
|
|
} else {
|
|
"move_entry"
|
|
},
|
|
&scope,
|
|
&from.folder,
|
|
Some(&from.name),
|
|
&ip,
|
|
Some(&details),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
match result {
|
|
Ok(outcome) => {
|
|
let details = json!({
|
|
"type": outcome.entry.session_type,
|
|
"moved_from": outcome.moved_from,
|
|
})
|
|
.to_string();
|
|
log_ab_event(
|
|
&database,
|
|
&admin_email,
|
|
"update_entry",
|
|
&scope,
|
|
&folder,
|
|
Some(&entry),
|
|
&ip,
|
|
Some(&details),
|
|
)
|
|
.await;
|
|
match outcome.moved_from {
|
|
Some(from) => Json(json!({"ok": true, "moved_from": from})).into_response(),
|
|
None => Json(json!({"ok": true})).into_response(),
|
|
}
|
|
}
|
|
Err(UpsertError::Rejected(status, msg)) => ab_error(status, msg),
|
|
Err(UpsertError::MoveIncomplete { error, .. }) => (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({
|
|
"error": format!(
|
|
"entry was copied to the target folder but the original could not be removed: {error}"
|
|
),
|
|
"copied": true,
|
|
})),
|
|
)
|
|
.into_response(),
|
|
Err(UpsertError::UpdateFailed { error, .. }) => (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"error": error.to_string()})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// Body for `POST .../entries/{entry}/move` and `.../copy`.
|
|
#[derive(Deserialize)]
|
|
pub struct MoveEntryRequest {
|
|
pub target_scope: String,
|
|
pub target_folder: String,
|
|
/// Copy only: the name of the new entry. Defaults to the source name.
|
|
#[serde(default)]
|
|
pub new_name: Option<String>,
|
|
}
|
|
|
|
fn ab_error(status: StatusCode, msg: impl Into<String>) -> Response {
|
|
(status, Json(json!({ "error": msg.into() }))).into_response()
|
|
}
|
|
|
|
/// Copy an entry as stored in Vault, secrets included, to a target that
|
|
/// must not already exist. Returns what was copied.
|
|
///
|
|
/// The browser cannot do this itself: it never receives an entry's secrets
|
|
/// (password, private key, jump host credentials, tokens, or a `$variable`
|
|
/// reference held in the password field), so a copy built from the edit
|
|
/// form silently dropped all of them (#232).
|
|
///
|
|
/// Every endpoint that uses this is admin-only, like every other change to
|
|
/// connection entries. If editing is ever delegated per folder (#231), they
|
|
/// must require edit rights on the source AND the target: copying moves
|
|
/// working credentials, so rights on the target alone would let an editor
|
|
/// pull them out of a folder they cannot reach into one they can, and
|
|
/// connect with them there.
|
|
async fn copy_stored_entry<S: EntryStore>(
|
|
store: &S,
|
|
scope: &str,
|
|
folder: &str,
|
|
entry: &str,
|
|
target_scope: &str,
|
|
target_folder: &str,
|
|
target_name: &str,
|
|
) -> Result<AddressBookEntry, (StatusCode, String)> {
|
|
let full = store
|
|
.get_entry(scope, folder, entry)
|
|
.await
|
|
.map_err(ab_vault_error)?;
|
|
// Vault refuses the write if anything already exists at the target, so
|
|
// an entry is never overwritten even by a concurrent writer.
|
|
match store
|
|
.put_entry_if_absent(target_scope, target_folder, target_name, &full)
|
|
.await
|
|
{
|
|
Ok(true) => Ok(full),
|
|
Ok(false) => Err((
|
|
StatusCode::CONFLICT,
|
|
"an entry with that name already exists in the target folder".into(),
|
|
)),
|
|
Err(e) => Err(ab_vault_error(e)),
|
|
}
|
|
}
|
|
|
|
/// Why a move did not complete.
|
|
#[derive(Debug)]
|
|
enum MoveError {
|
|
/// Nothing was changed: the copy to the target was refused.
|
|
Copy(StatusCode, String),
|
|
/// The copy is in place but the original could not be removed, so the
|
|
/// entry now exists in both folders.
|
|
Incomplete(VaultError),
|
|
}
|
|
|
|
/// Move an entry with everything stored in it: the full entry is copied to
|
|
/// the target and only then is the original removed, so a failure leaves it
|
|
/// in both folders rather than in neither. This is the server-side move
|
|
/// behind `POST .../move`; `PUT ...?move=true` goes through it too.
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn move_stored_entry<S: EntryStore>(
|
|
store: &S,
|
|
scope: &str,
|
|
folder: &str,
|
|
entry: &str,
|
|
target_scope: &str,
|
|
target_folder: &str,
|
|
target_name: &str,
|
|
) -> Result<AddressBookEntry, MoveError> {
|
|
let full = copy_stored_entry(
|
|
store,
|
|
scope,
|
|
folder,
|
|
entry,
|
|
target_scope,
|
|
target_folder,
|
|
target_name,
|
|
)
|
|
.await
|
|
.map_err(|(status, msg)| MoveError::Copy(status, msg))?;
|
|
store
|
|
.delete_entry(scope, folder, entry)
|
|
.await
|
|
.map_err(MoveError::Incomplete)?;
|
|
Ok(full)
|
|
}
|
|
|
|
/// Status for a Vault error on the move/copy path: a rejected name or
|
|
/// scope is the caller's mistake, not the backend's.
|
|
fn ab_vault_error(e: VaultError) -> (StatusCode, String) {
|
|
match e {
|
|
VaultError::NotFound => (StatusCode::NOT_FOUND, "entry not found".into()),
|
|
VaultError::BadName(msg) => (StatusCode::BAD_REQUEST, msg),
|
|
other => (StatusCode::BAD_GATEWAY, other.to_string()),
|
|
}
|
|
}
|
|
|
|
fn require_ab_admin(
|
|
identity: &Option<Extension<AuthIdentity>>,
|
|
) -> Result<String, (StatusCode, String)> {
|
|
match identity {
|
|
Some(Extension(id)) if id.has_role("admin") => Ok(id.display_name().to_string()),
|
|
_ => Err((StatusCode::FORBIDDEN, "admin role required".into())),
|
|
}
|
|
}
|
|
|
|
/// POST /api/addressbook/folders/:scope/:folder/entries/:entry/move
|
|
///
|
|
/// Moves an entry with everything stored in it: the full entry is copied to
|
|
/// the target and only then is the original removed. An existing entry of
|
|
/// the same name in the target is never overwritten.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn ab_move_entry(
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: axum::http::HeaderMap,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
trusted: Option<Extension<TrustedProxies>>,
|
|
Extension(database): Extension<Db>,
|
|
Extension(vault): Extension<VaultState>,
|
|
Path((scope, folder, entry)): Path<(String, String, String)>,
|
|
Json(req): Json<MoveEntryRequest>,
|
|
) -> impl IntoResponse {
|
|
let admin_email = match require_ab_admin(&identity) {
|
|
Ok(e) => e,
|
|
Err((status, msg)) => return ab_error(status, msg),
|
|
};
|
|
if req.target_scope == scope && req.target_folder == folder {
|
|
return ab_error(StatusCode::BAD_REQUEST, "entry is already in that folder");
|
|
}
|
|
|
|
let ip = audit_client_ip(&headers, &addr, trusted.as_ref());
|
|
let details = json!({
|
|
"to_scope": req.target_scope,
|
|
"to_folder": req.target_folder,
|
|
})
|
|
.to_string();
|
|
|
|
match move_stored_entry(
|
|
vault.as_ref(),
|
|
&scope,
|
|
&folder,
|
|
&entry,
|
|
&req.target_scope,
|
|
&req.target_folder,
|
|
&entry,
|
|
)
|
|
.await
|
|
{
|
|
Ok(_) => {}
|
|
Err(MoveError::Copy(status, msg)) => return ab_error(status, msg),
|
|
// The copy is in place, so the entry is in both folders rather than
|
|
// in neither. Say so instead of reporting a plain error the caller
|
|
// might answer by retrying.
|
|
Err(MoveError::Incomplete(e)) => {
|
|
log_ab_event(
|
|
&database,
|
|
&admin_email,
|
|
"move_entry_incomplete",
|
|
&scope,
|
|
&folder,
|
|
Some(&entry),
|
|
&ip,
|
|
Some(&details),
|
|
)
|
|
.await;
|
|
return (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({
|
|
"error": format!(
|
|
"entry was copied to the target folder but the original could not be removed: {e}"
|
|
),
|
|
"copied": true,
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
log_ab_event(
|
|
&database,
|
|
&admin_email,
|
|
"move_entry",
|
|
&scope,
|
|
&folder,
|
|
Some(&entry),
|
|
&ip,
|
|
Some(&details),
|
|
)
|
|
.await;
|
|
(StatusCode::OK, Json(json!({"ok": true}))).into_response()
|
|
}
|
|
|
|
/// Most entries one bulk request may name.
|
|
const BULK_MAX_ITEMS: usize = 1000;
|
|
/// Entries processed at once within a bulk request.
|
|
const BULK_CONCURRENCY: usize = 8;
|
|
/// Highest suffix tried when renaming around a clash (`name-2` .. `name-N`).
|
|
const BULK_MAX_RENAME: u32 = 99;
|
|
|
|
#[derive(Deserialize, Clone, PartialEq, Eq, Hash)]
|
|
pub struct BulkItem {
|
|
pub scope: String,
|
|
pub folder: String,
|
|
pub name: String,
|
|
}
|
|
|
|
#[derive(Deserialize, Clone)]
|
|
pub struct BulkTarget {
|
|
pub scope: String,
|
|
pub folder: String,
|
|
}
|
|
|
|
#[derive(Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum BulkAction {
|
|
Move,
|
|
Copy,
|
|
Delete,
|
|
}
|
|
|
|
/// What to do when the target already has an entry of the same name.
|
|
#[derive(Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum OnConflict {
|
|
/// Leave that entry where it is and report it as skipped.
|
|
#[default]
|
|
Skip,
|
|
/// Use the first free `name-2`, `name-3`, ... instead.
|
|
Rename,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct BulkRequest {
|
|
pub action: BulkAction,
|
|
pub items: Vec<BulkItem>,
|
|
#[serde(default)]
|
|
pub target: Option<BulkTarget>,
|
|
#[serde(default)]
|
|
pub on_conflict: OnConflict,
|
|
}
|
|
|
|
/// Names to try for an entry copied around a clash: the name itself, then
|
|
/// `name-2` .. `name-99`, trimming the base so every candidate stays within
|
|
/// Vault's 64-character name limit.
|
|
fn rename_candidates(name: &str, rename: bool) -> Vec<String> {
|
|
let mut out = vec![name.to_string()];
|
|
if rename {
|
|
for n in 2..=BULK_MAX_RENAME {
|
|
let suffix = format!("-{n}");
|
|
let keep = 64usize.saturating_sub(suffix.len()).min(name.len());
|
|
// Names are validated ASCII, so a byte cut is a char boundary.
|
|
out.push(format!("{}{}", &name[..keep], suffix));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Copy a stored entry, secrets included, into the target folder under the
|
|
/// first free name allowed by `on_conflict`. Returns the name used, or
|
|
/// `None` when the name was taken and the policy is to skip.
|
|
async fn copy_entry_into(
|
|
vault: &VaultState,
|
|
item: &BulkItem,
|
|
target: &BulkTarget,
|
|
on_conflict: OnConflict,
|
|
) -> Result<Option<String>, (StatusCode, String)> {
|
|
let full = vault
|
|
.get_entry(&item.scope, &item.folder, &item.name)
|
|
.await
|
|
.map_err(ab_vault_error)?;
|
|
for candidate in rename_candidates(&item.name, on_conflict == OnConflict::Rename) {
|
|
// Vault refuses the write if anything exists there, so a clash is
|
|
// detected atomically and nothing is ever overwritten.
|
|
match vault
|
|
.put_entry_if_absent(&target.scope, &target.folder, &candidate, &full)
|
|
.await
|
|
{
|
|
Ok(true) => return Ok(Some(candidate)),
|
|
Ok(false) => continue,
|
|
Err(e) => return Err(ab_vault_error(e)),
|
|
}
|
|
}
|
|
if on_conflict == OnConflict::Rename {
|
|
Err((
|
|
StatusCode::CONFLICT,
|
|
"no free name found for the copy".into(),
|
|
))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
/// One entry of a bulk request, handled on its own so a failure never
|
|
/// affects the others. Audits what it did.
|
|
async fn bulk_one(
|
|
vault: &VaultState,
|
|
database: &Db,
|
|
admin_email: &str,
|
|
ip: &str,
|
|
req: &BulkRequest,
|
|
item: &BulkItem,
|
|
) -> serde_json::Value {
|
|
let mut result = json!({
|
|
"scope": item.scope,
|
|
"folder": item.folder,
|
|
"name": item.name,
|
|
});
|
|
let set = |r: &mut serde_json::Value, k: &str, v: serde_json::Value| {
|
|
r[k] = v;
|
|
};
|
|
|
|
if req.action == BulkAction::Delete {
|
|
// Vault reports success for deleting a path that does not exist, so
|
|
// look first: otherwise a missing entry would be reported (and
|
|
// audited) as deleted.
|
|
if let Err(e) = vault.get_entry(&item.scope, &item.folder, &item.name).await {
|
|
let (_, msg) = ab_vault_error(e);
|
|
set(&mut result, "status", json!("failed"));
|
|
set(&mut result, "error", json!(msg));
|
|
return result;
|
|
}
|
|
match vault
|
|
.delete_entry(&item.scope, &item.folder, &item.name)
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
log_ab_event(
|
|
database,
|
|
admin_email,
|
|
"delete_entry",
|
|
&item.scope,
|
|
&item.folder,
|
|
Some(&item.name),
|
|
ip,
|
|
Some(r#"{"bulk":true}"#),
|
|
)
|
|
.await;
|
|
set(&mut result, "status", json!("deleted"));
|
|
}
|
|
Err(e) => {
|
|
let (_, msg) = ab_vault_error(e);
|
|
set(&mut result, "status", json!("failed"));
|
|
set(&mut result, "error", json!(msg));
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Move and copy both have a target (checked by the caller).
|
|
let target = req.target.as_ref().expect("target checked");
|
|
if req.action == BulkAction::Move && item.scope == target.scope && item.folder == target.folder
|
|
{
|
|
set(&mut result, "status", json!("skipped"));
|
|
set(&mut result, "error", json!("already in that folder"));
|
|
return result;
|
|
}
|
|
|
|
let used = match copy_entry_into(vault, item, target, req.on_conflict).await {
|
|
Ok(Some(name)) => name,
|
|
Ok(None) => {
|
|
set(&mut result, "status", json!("skipped"));
|
|
set(
|
|
&mut result,
|
|
"error",
|
|
json!("an entry with that name already exists in the target folder"),
|
|
);
|
|
return result;
|
|
}
|
|
Err((_, msg)) => {
|
|
set(&mut result, "status", json!("failed"));
|
|
set(&mut result, "error", json!(msg));
|
|
return result;
|
|
}
|
|
};
|
|
if used != item.name {
|
|
set(&mut result, "new_name", json!(used));
|
|
}
|
|
let details = json!({
|
|
"bulk": true,
|
|
"to_scope": target.scope,
|
|
"to_folder": target.folder,
|
|
"to_name": used,
|
|
})
|
|
.to_string();
|
|
|
|
if req.action == BulkAction::Copy {
|
|
log_ab_event(
|
|
database,
|
|
admin_email,
|
|
"copy_entry",
|
|
&item.scope,
|
|
&item.folder,
|
|
Some(&item.name),
|
|
ip,
|
|
Some(&details),
|
|
)
|
|
.await;
|
|
set(&mut result, "status", json!("copied"));
|
|
return result;
|
|
}
|
|
|
|
// Move: the copy is in place, so a failed removal leaves the entry in
|
|
// both folders, never in neither. Report that distinctly.
|
|
match vault
|
|
.delete_entry(&item.scope, &item.folder, &item.name)
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
log_ab_event(
|
|
database,
|
|
admin_email,
|
|
"move_entry",
|
|
&item.scope,
|
|
&item.folder,
|
|
Some(&item.name),
|
|
ip,
|
|
Some(&details),
|
|
)
|
|
.await;
|
|
set(&mut result, "status", json!("moved"));
|
|
}
|
|
Err(e) => {
|
|
log_ab_event(
|
|
database,
|
|
admin_email,
|
|
"move_entry_incomplete",
|
|
&item.scope,
|
|
&item.folder,
|
|
Some(&item.name),
|
|
ip,
|
|
Some(&details),
|
|
)
|
|
.await;
|
|
set(&mut result, "status", json!("copied_not_removed"));
|
|
set(
|
|
&mut result,
|
|
"error",
|
|
json!(format!(
|
|
"copied, but the original could not be removed: {}",
|
|
ab_vault_error(e).1
|
|
)),
|
|
);
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
/// POST /api/addressbook/bulk
|
|
///
|
|
/// Move, copy or delete many entries in one request. Each entry is handled
|
|
/// on its own, through the same stored-entry copy as the single-entry
|
|
/// endpoints (credentials included, never overwriting), and the response
|
|
/// reports what happened to each. Admin only. See `copy_stored_entry` for
|
|
/// the rule any future per-folder delegation must follow.
|
|
pub async fn ab_bulk(
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: axum::http::HeaderMap,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
trusted: Option<Extension<TrustedProxies>>,
|
|
Extension(database): Extension<Db>,
|
|
Extension(vault): Extension<VaultState>,
|
|
Json(req): Json<BulkRequest>,
|
|
) -> impl IntoResponse {
|
|
use futures_util::StreamExt;
|
|
|
|
let admin_email = match require_ab_admin(&identity) {
|
|
Ok(e) => e,
|
|
Err((status, msg)) => return ab_error(status, msg),
|
|
};
|
|
if req.items.is_empty() {
|
|
return ab_error(StatusCode::BAD_REQUEST, "no entries given");
|
|
}
|
|
if req.items.len() > BULK_MAX_ITEMS {
|
|
return ab_error(
|
|
StatusCode::BAD_REQUEST,
|
|
format!("at most {BULK_MAX_ITEMS} entries per request"),
|
|
);
|
|
}
|
|
if req.action != BulkAction::Delete && req.target.is_none() {
|
|
return ab_error(StatusCode::BAD_REQUEST, "move and copy need a target");
|
|
}
|
|
|
|
// The same entry named twice would race itself (a move whose source is
|
|
// gone by the second pass), so act on each once.
|
|
let mut seen = std::collections::HashSet::new();
|
|
let items: Vec<BulkItem> = req
|
|
.items
|
|
.iter()
|
|
.filter(|i| seen.insert((*i).clone()))
|
|
.cloned()
|
|
.collect();
|
|
|
|
let ip = audit_client_ip(&headers, &addr, trusted.as_ref());
|
|
// Each future owns what it uses: borrowing into a buffered stream does
|
|
// not satisfy the handler's Send bound.
|
|
let req = Arc::new(req);
|
|
let results: Vec<serde_json::Value> = futures_util::stream::iter(items)
|
|
.map(|item| {
|
|
let (vault, database, admin_email, ip, req) = (
|
|
vault.clone(),
|
|
database.clone(),
|
|
admin_email.clone(),
|
|
ip.clone(),
|
|
req.clone(),
|
|
);
|
|
async move { bulk_one(&vault, &database, &admin_email, &ip, &req, &item).await }
|
|
})
|
|
.buffered(BULK_CONCURRENCY)
|
|
.collect()
|
|
.await;
|
|
|
|
let mut summary = std::collections::BTreeMap::<String, usize>::new();
|
|
for r in &results {
|
|
if let Some(s) = r["status"].as_str() {
|
|
*summary.entry(s.to_string()).or_default() += 1;
|
|
}
|
|
}
|
|
(
|
|
StatusCode::OK,
|
|
Json(json!({ "results": results, "summary": summary })),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
/// GET /api/addressbook/folder-paths
|
|
///
|
|
/// Every folder in both scopes, for the bulk move/copy picker. Admin only,
|
|
/// as it is only used to choose where entries go. The tree view loads
|
|
/// subfolders lazily, so it cannot supply this itself.
|
|
pub async fn ab_folder_paths(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(vault): Extension<VaultState>,
|
|
) -> impl IntoResponse {
|
|
if let Err((status, msg)) = require_ab_admin(&identity) {
|
|
return ab_error(status, msg);
|
|
}
|
|
let (top, unavailable) = match vault.list_all_folders().await {
|
|
Ok(t) => t,
|
|
Err(e) => return ab_error(StatusCode::BAD_GATEWAY, e.to_string()),
|
|
};
|
|
let mut queue: Vec<(String, String)> = top
|
|
.into_iter()
|
|
.map(|f| (f.scope, f.path.unwrap_or(f.name)))
|
|
.collect();
|
|
let mut folders = Vec::new();
|
|
while let Some((scope, path)) = queue.pop() {
|
|
if let Ok(subs) = vault.list_subfolders(&scope, &path).await {
|
|
for s in subs {
|
|
queue.push((scope.clone(), s.path.unwrap_or_else(|| s.name.clone())));
|
|
}
|
|
}
|
|
folders.push(json!({ "scope": scope, "path": path }));
|
|
}
|
|
folders.sort_by(|a, b| {
|
|
(a["scope"].as_str(), a["path"].as_str()).cmp(&(b["scope"].as_str(), b["path"].as_str()))
|
|
});
|
|
Json(json!({ "folders": folders, "unavailable": unavailable })).into_response()
|
|
}
|
|
|
|
/// POST /api/addressbook/folders/:scope/:folder/entries/:entry/copy
|
|
///
|
|
/// Copies an entry with everything stored in it, under `new_name` in the
|
|
/// target folder. Used by Clone, which then applies the form's edits with
|
|
/// an ordinary update (which keeps stored secrets it is not given).
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn ab_copy_entry(
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: axum::http::HeaderMap,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
trusted: Option<Extension<TrustedProxies>>,
|
|
Extension(database): Extension<Db>,
|
|
Extension(vault): Extension<VaultState>,
|
|
Path((scope, folder, entry)): Path<(String, String, String)>,
|
|
Json(req): Json<MoveEntryRequest>,
|
|
) -> impl IntoResponse {
|
|
let admin_email = match require_ab_admin(&identity) {
|
|
Ok(e) => e,
|
|
Err((status, msg)) => return ab_error(status, msg),
|
|
};
|
|
let new_name = req.new_name.clone().unwrap_or_else(|| entry.clone());
|
|
if req.target_scope == scope && req.target_folder == folder && new_name == entry {
|
|
return ab_error(StatusCode::BAD_REQUEST, "copy needs a new name or folder");
|
|
}
|
|
if let Err((status, msg)) = copy_stored_entry(
|
|
vault.as_ref(),
|
|
&scope,
|
|
&folder,
|
|
&entry,
|
|
&req.target_scope,
|
|
&req.target_folder,
|
|
&new_name,
|
|
)
|
|
.await
|
|
{
|
|
return ab_error(status, msg);
|
|
}
|
|
let ip = audit_client_ip(&headers, &addr, trusted.as_ref());
|
|
let details = json!({
|
|
"to_scope": req.target_scope,
|
|
"to_folder": req.target_folder,
|
|
"to_name": new_name,
|
|
})
|
|
.to_string();
|
|
log_ab_event(
|
|
&database,
|
|
&admin_email,
|
|
"copy_entry",
|
|
&scope,
|
|
&folder,
|
|
Some(&entry),
|
|
&ip,
|
|
Some(&details),
|
|
)
|
|
.await;
|
|
(StatusCode::CREATED, Json(json!({"ok": true}))).into_response()
|
|
}
|
|
|
|
/// DELETE /api/addressbook/folders/:scope/:folder/entries/:entry — Delete an entry. Admin only.
|
|
pub async fn ab_delete_entry(
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: axum::http::HeaderMap,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
trusted: Option<Extension<TrustedProxies>>,
|
|
Extension(database): Extension<Db>,
|
|
Extension(vault): Extension<VaultState>,
|
|
Path((scope, folder, entry)): Path<(String, String, String)>,
|
|
) -> impl IntoResponse {
|
|
let admin_email = match identity.as_ref() {
|
|
Some(Extension(id)) if id.has_role("admin") => id.display_name().to_string(),
|
|
_ => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
// Vault reports success for deleting a path that does not exist, so
|
|
// look first rather than answer (and audit) a delete of nothing.
|
|
match vault.get_entry(&scope, &folder, &entry).await {
|
|
Ok(_) => {}
|
|
Err(VaultError::NotFound) => {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({"error": "entry not found"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
Err(e) => {
|
|
return (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"error": e.to_string()})),
|
|
)
|
|
.into_response()
|
|
}
|
|
}
|
|
|
|
match vault.delete_entry(&scope, &folder, &entry).await {
|
|
Ok(()) => {
|
|
let ip = audit_client_ip(&headers, &addr, trusted.as_ref());
|
|
log_ab_event(
|
|
&database,
|
|
&admin_email,
|
|
"delete_entry",
|
|
&scope,
|
|
&folder,
|
|
Some(&entry),
|
|
&ip,
|
|
None,
|
|
)
|
|
.await;
|
|
StatusCode::NO_CONTENT.into_response()
|
|
}
|
|
Err(VaultError::NotFound) => (
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({"error": "entry not found"})),
|
|
)
|
|
.into_response(),
|
|
Err(e) => (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"error": e.to_string()})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
// ── User API Tokens ──
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct CreateTokenRequest {
|
|
pub name: String,
|
|
pub max_role: Option<String>,
|
|
pub expires_at: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct AdminCreateTokenRequest {
|
|
pub email: String,
|
|
pub name: String,
|
|
pub max_role: Option<String>,
|
|
pub expires_at: Option<String>,
|
|
}
|
|
|
|
/// POST /api/me/tokens — Create a personal API token. Requires poweruser+.
|
|
pub async fn create_my_token(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: axum::http::HeaderMap,
|
|
trusted: Option<Extension<TrustedProxies>>,
|
|
Json(req): Json<CreateTokenRequest>,
|
|
) -> impl IntoResponse {
|
|
let id = match identity {
|
|
Some(Extension(ref id)) => id.clone(),
|
|
None => {
|
|
return (
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(json!({"error": "authentication required"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
// Only poweruser+ can self-create tokens
|
|
if !id.has_role("poweruser") {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "poweruser role or higher required to create tokens"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
// Must be an OIDC user (not an API key admin)
|
|
let email = match &id {
|
|
AuthIdentity::User { email, .. } => email.clone(),
|
|
AuthIdentity::ApiKey(_) => {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({"error": "API key admins cannot create user tokens — use the admin endpoint"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
// Validate max_role if provided
|
|
if let Some(ref max_role) = req.max_role {
|
|
if !["admin", "poweruser", "operator", "viewer"].contains(&max_role.as_str()) {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({"error": "max_role must be admin, poweruser, operator, or viewer"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
if role_level(max_role) > role_level(id.role()) {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "max_role cannot exceed your current role"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
if req.name.is_empty() || req.name.len() > 100 {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({"error": "token name must be 1-100 characters"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let db_clone = database.clone();
|
|
let email_clone = email.clone();
|
|
let user =
|
|
match tokio::task::spawn_blocking(move || db::get_user_by_email(&db_clone, &email_clone))
|
|
.await
|
|
{
|
|
Ok(Ok(u)) => u,
|
|
_ => {
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to look up user"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
let db_clone = database.clone();
|
|
let name = req.name.clone();
|
|
let max_role = req.max_role.clone();
|
|
let expires_at = req.expires_at.clone();
|
|
let result = tokio::task::spawn_blocking(move || {
|
|
db::create_user_token(
|
|
&db_clone,
|
|
user.id,
|
|
&name,
|
|
max_role.as_deref(),
|
|
expires_at.as_deref(),
|
|
)
|
|
})
|
|
.await;
|
|
|
|
match result {
|
|
Ok(Ok((token_id, plaintext))) => {
|
|
let proxies = trusted.map(|Extension(t)| t.0).unwrap_or_default();
|
|
let ip = client_ip(&headers, addr.ip(), &proxies);
|
|
let details = serde_json::to_string(&json!({
|
|
"max_role": req.max_role,
|
|
"expires_at": req.expires_at,
|
|
}))
|
|
.ok();
|
|
let db_clone = database.clone();
|
|
let email_clone = email.clone();
|
|
let name_clone = req.name.clone();
|
|
let _ = tokio::task::spawn_blocking(move || {
|
|
db::log_token_event(
|
|
&db_clone,
|
|
Some(token_id),
|
|
Some(&name_clone),
|
|
&email_clone,
|
|
"created",
|
|
Some(&ip.to_string()),
|
|
details.as_deref(),
|
|
)
|
|
})
|
|
.await;
|
|
|
|
Json(json!({
|
|
"id": token_id,
|
|
"name": req.name,
|
|
"token": plaintext,
|
|
"max_role": req.max_role,
|
|
"expires_at": req.expires_at,
|
|
}))
|
|
.into_response()
|
|
}
|
|
Ok(Err(e)) => {
|
|
let msg = e.to_string();
|
|
if msg.contains("UNIQUE constraint") {
|
|
(
|
|
StatusCode::CONFLICT,
|
|
Json(json!({"error": format!("token name '{}' already exists", req.name)})),
|
|
)
|
|
.into_response()
|
|
} else {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to create token"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
}
|
|
_ => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to create token"})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// GET /api/me/tokens — List own tokens.
|
|
pub async fn list_my_tokens(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
) -> impl IntoResponse {
|
|
let email = match identity {
|
|
Some(Extension(AuthIdentity::User { ref email, .. })) => email.clone(),
|
|
_ => {
|
|
return (
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(json!({"error": "OIDC authentication required"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
let db_clone = database.clone();
|
|
let user =
|
|
match tokio::task::spawn_blocking(move || db::get_user_by_email(&db_clone, &email)).await {
|
|
Ok(Ok(u)) => u,
|
|
_ => {
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to look up user"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
let db_clone = database.clone();
|
|
match tokio::task::spawn_blocking(move || db::list_user_tokens(&db_clone, user.id)).await {
|
|
Ok(Ok(tokens)) => Json(json!(tokens)).into_response(),
|
|
_ => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to list tokens"})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// DELETE /api/me/tokens/{id} — Revoke own token. Requires poweruser+.
|
|
pub async fn revoke_my_token(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: axum::http::HeaderMap,
|
|
trusted: Option<Extension<TrustedProxies>>,
|
|
Path(token_id): Path<i64>,
|
|
) -> impl IntoResponse {
|
|
let (email, role) = match identity {
|
|
Some(Extension(AuthIdentity::User {
|
|
ref email,
|
|
ref role,
|
|
..
|
|
})) => (email.clone(), role.clone()),
|
|
_ => {
|
|
return (
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(json!({"error": "OIDC authentication required"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
if role_level(&role) < role_level("poweruser") {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "poweruser role or higher required to manage tokens"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let db_clone = database.clone();
|
|
let email_clone = email.clone();
|
|
let user =
|
|
match tokio::task::spawn_blocking(move || db::get_user_by_email(&db_clone, &email_clone))
|
|
.await
|
|
{
|
|
Ok(Ok(u)) => u,
|
|
_ => {
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to look up user"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
let db_clone = database.clone();
|
|
let user_id = user.id;
|
|
match tokio::task::spawn_blocking(move || db::revoke_user_token(&db_clone, user_id, token_id))
|
|
.await
|
|
{
|
|
Ok(Ok(true)) => {
|
|
let proxies = trusted.map(|Extension(t)| t.0).unwrap_or_default();
|
|
let ip = client_ip(&headers, addr.ip(), &proxies);
|
|
let db_clone = database.clone();
|
|
let _ = tokio::task::spawn_blocking(move || {
|
|
db::log_token_event(
|
|
&db_clone,
|
|
Some(token_id),
|
|
None,
|
|
&email,
|
|
"revoked",
|
|
Some(&ip.to_string()),
|
|
Some("self-service revocation"),
|
|
)
|
|
})
|
|
.await;
|
|
Json(json!({"ok": true})).into_response()
|
|
}
|
|
Ok(Ok(false)) => (
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({"error": "token not found or not yours"})),
|
|
)
|
|
.into_response(),
|
|
_ => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to revoke token"})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
// ── User credential variables ──
|
|
|
|
/// Mask a credential value for the UI: never echo a stored secret back.
|
|
fn mask_credential(name: &str, value: &str, scope: &str) -> serde_json::Value {
|
|
let display = if value.is_empty() {
|
|
String::new()
|
|
} else if name.ends_with("_password") || name.ends_with("_key") {
|
|
"••••••••".to_string()
|
|
} else {
|
|
value.to_string()
|
|
};
|
|
json!({ "set": !value.is_empty(), "display": display, "scope": scope })
|
|
}
|
|
|
|
/// Compute the (local, shared) credential maps to persist from a save batch,
|
|
/// preserving untouched keys and honouring "leave blank = keep".
|
|
///
|
|
/// - `existing_local` / `existing_shared`: current stored maps. Single-Vault
|
|
/// passes the one store as `existing_local` with an empty `existing_shared`.
|
|
/// - `incoming`: name -> value for the keys being saved (blank value = keep the
|
|
/// existing value, moving it to the target scope if that changed).
|
|
/// - `scopes`: name -> "shared"|"local" target for touched keys.
|
|
/// - `default_scope`: fallback when a touched key has no explicit scope.
|
|
/// - `split`: false in single-Vault — everything lands in the returned local
|
|
/// map (the caller writes it once) and the shared map stays empty.
|
|
fn partition_credential_writes(
|
|
mut existing_local: std::collections::HashMap<String, String>,
|
|
mut existing_shared: std::collections::HashMap<String, String>,
|
|
incoming: &std::collections::HashMap<String, String>,
|
|
scopes: &std::collections::HashMap<String, String>,
|
|
default_scope: &str,
|
|
split: bool,
|
|
) -> (
|
|
std::collections::HashMap<String, String>,
|
|
std::collections::HashMap<String, String>,
|
|
) {
|
|
for (name, val) in incoming {
|
|
// Resolve the value: keep the existing one when left blank.
|
|
let value = if !val.is_empty() {
|
|
val.clone()
|
|
} else {
|
|
existing_local
|
|
.get(name)
|
|
.or_else(|| existing_shared.get(name))
|
|
.cloned()
|
|
.unwrap_or_default()
|
|
};
|
|
// Blank with no existing value: nothing to store.
|
|
if value.is_empty() {
|
|
existing_local.remove(name);
|
|
existing_shared.remove(name);
|
|
continue;
|
|
}
|
|
let to_shared = split
|
|
&& scopes
|
|
.get(name)
|
|
.map(String::as_str)
|
|
.unwrap_or(default_scope)
|
|
== "shared";
|
|
if to_shared {
|
|
existing_shared.insert(name.clone(), value);
|
|
existing_local.remove(name);
|
|
} else {
|
|
existing_local.insert(name.clone(), value);
|
|
existing_shared.remove(name);
|
|
}
|
|
}
|
|
(existing_local, existing_shared)
|
|
}
|
|
|
|
/// GET /api/me/credentials — List own credential variables (names and masked
|
|
/// values). Also reports whether the shared/local scope choice is available
|
|
/// (`creds_split`) and the default scope for new variables, so the UI can show
|
|
/// or hide the per-credential toggle.
|
|
pub async fn get_my_credentials(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(vault): Extension<VaultState>,
|
|
Extension(default_scope): Extension<CredentialDefaultScope>,
|
|
) -> impl IntoResponse {
|
|
let email = match identity {
|
|
Some(Extension(AuthIdentity::User { ref email, .. })) => email.clone(),
|
|
_ => {
|
|
return (
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(json!({"error": "OIDC authentication required"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
let split = vault.creds_split();
|
|
let local = match vault.get_user_credentials_scoped(&email, false).await {
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
return (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"error": format!("Failed to read credentials: {}", e)})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
// Shared backend errors are tolerated (its creds just don't show); a
|
|
// central-Vault blip shouldn't block editing local credentials.
|
|
let shared = if split {
|
|
vault
|
|
.get_user_credentials_scoped(&email, true)
|
|
.await
|
|
.unwrap_or_default()
|
|
} else {
|
|
std::collections::HashMap::new()
|
|
};
|
|
|
|
// Build masked entries; local wins on a name collision, and `scope`
|
|
// reflects where each value currently lives.
|
|
let mut masked = serde_json::Map::new();
|
|
for (k, v) in &shared {
|
|
masked.insert(k.clone(), mask_credential(k, v, "shared"));
|
|
}
|
|
for (k, v) in &local {
|
|
masked.insert(k.clone(), mask_credential(k, v, "local"));
|
|
}
|
|
|
|
Json(json!({
|
|
"credentials": masked,
|
|
"creds_split": split,
|
|
"default_scope": default_scope.0,
|
|
}))
|
|
.into_response()
|
|
}
|
|
|
|
/// PUT /api/me/credentials — Save credential variables. Operator+ required.
|
|
///
|
|
/// Body: `{ "credentials": { name: value }, "scopes": { name: "shared"|"local" } }`.
|
|
/// A blank value keeps the existing value (moving it to the target scope if
|
|
/// `scopes` changed it); untouched keys are preserved. When only one Vault
|
|
/// backend is configured the scope is ignored and everything is written to the
|
|
/// single store, so the quick-start path is unchanged.
|
|
pub async fn put_my_credentials(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(vault): Extension<VaultState>,
|
|
Extension(default_scope): Extension<CredentialDefaultScope>,
|
|
Json(body): Json<serde_json::Value>,
|
|
) -> impl IntoResponse {
|
|
let email = match identity {
|
|
Some(Extension(AuthIdentity::User {
|
|
ref email,
|
|
ref role,
|
|
..
|
|
})) if role_level(role) >= role_level("operator") => email.clone(),
|
|
Some(Extension(AuthIdentity::User { .. })) => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "operator role required"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
_ => {
|
|
return (
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(json!({"error": "OIDC authentication required"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
// Expect { "credentials": { "corp_user": "alice", ... } }
|
|
let creds_obj = match body.get("credentials").and_then(|v| v.as_object()) {
|
|
Some(obj) => obj,
|
|
None => {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({"error": "missing or invalid 'credentials' object"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
// Validate variable names: [a-z0-9_-]+
|
|
let mut incoming = std::collections::HashMap::new();
|
|
for (k, v) in creds_obj {
|
|
if !k
|
|
.chars()
|
|
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
|
|
{
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({"error": format!("invalid variable name '{}': use lowercase alphanumeric, underscores and hyphens", k)})),
|
|
)
|
|
.into_response();
|
|
}
|
|
incoming.insert(k.clone(), v.as_str().unwrap_or("").to_string());
|
|
}
|
|
|
|
// Optional per-key target scope: { "scopes": { name: "shared"|"local" } }.
|
|
let mut scopes = std::collections::HashMap::new();
|
|
if let Some(obj) = body.get("scopes").and_then(|v| v.as_object()) {
|
|
for (k, v) in obj {
|
|
if let Some(s) = v.as_str() {
|
|
if s == "shared" || s == "local" {
|
|
scopes.insert(k.clone(), s.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let split = vault.creds_split();
|
|
|
|
// Read existing maps. Abort on a hard read error rather than risk writing a
|
|
// partial set (which would drop untouched keys).
|
|
let existing_local = match vault.get_user_credentials_scoped(&email, false).await {
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
return (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"error": format!("Failed to read existing credentials: {}", e)})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
let existing_shared = if split {
|
|
match vault.get_user_credentials_scoped(&email, true).await {
|
|
Ok(m) => m,
|
|
Err(e) => return (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(
|
|
json!({"error": format!("Failed to read existing shared credentials: {}", e)}),
|
|
),
|
|
)
|
|
.into_response(),
|
|
}
|
|
} else {
|
|
std::collections::HashMap::new()
|
|
};
|
|
|
|
let (new_local, new_shared) = partition_credential_writes(
|
|
existing_local,
|
|
existing_shared,
|
|
&incoming,
|
|
&scopes,
|
|
&default_scope.0,
|
|
split,
|
|
);
|
|
|
|
// Write the local store first (in single-Vault this is the only store).
|
|
if let Err(e) = vault
|
|
.put_user_credentials_scoped(&email, false, &new_local)
|
|
.await
|
|
{
|
|
return (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"error": format!("Failed to save credentials: {}", e)})),
|
|
)
|
|
.into_response();
|
|
}
|
|
if split {
|
|
if let Err(e) = vault
|
|
.put_user_credentials_scoped(&email, true, &new_shared)
|
|
.await
|
|
{
|
|
return (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"error": format!("Failed to save shared credentials: {}", e)})),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
let count = new_local.len() + new_shared.len();
|
|
tracing::info!(user = %email, count, "User credentials updated");
|
|
Json(json!({"ok": true, "count": count})).into_response()
|
|
}
|
|
|
|
/// Abstraction over the folder tree + per-folder access, used to gather
|
|
/// credential variables. Kept as a trait so the traversal below is unit-
|
|
/// testable without a live Vault.
|
|
#[allow(async_fn_in_trait)]
|
|
trait FolderSource {
|
|
/// Whether the user may directly access (enter) this folder.
|
|
async fn accessible(&self, scope: &str, path: &str) -> bool;
|
|
/// Full paths of this folder's immediate subfolders.
|
|
async fn subfolders(&self, scope: &str, path: &str) -> Vec<String>;
|
|
/// Credential-variable names referenced by entries in this folder.
|
|
async fn folder_var_names(&self, scope: &str, path: &str) -> Vec<String>;
|
|
}
|
|
|
|
/// Walk the folder tree from `roots`, collecting credential-variable names and
|
|
/// their reference counts. Descends into every subfolder so a grant on a deep
|
|
/// folder nested under an inaccessible ancestor is still reached (mirroring the
|
|
/// connections tree, which shows a folder when a descendant is accessible), but
|
|
/// only collects variables from folders the user can directly access.
|
|
///
|
|
/// This replaced an earlier walk that short-circuited the whole subtree on a
|
|
/// directly-inaccessible ancestor, hiding credential variables from any entry
|
|
/// nested under it (an operator granted access at a subfolder level saw "No
|
|
/// credential variables" in My Credentials even though they could connect the
|
|
/// entry).
|
|
async fn gather_credential_variables<S: FolderSource>(
|
|
src: &S,
|
|
roots: Vec<(String, String)>,
|
|
) -> std::collections::HashMap<String, u32> {
|
|
let mut all_vars: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
|
|
let mut stack = roots;
|
|
while let Some((scope, path)) = stack.pop() {
|
|
if src.accessible(&scope, &path).await {
|
|
for var in src.folder_var_names(&scope, &path).await {
|
|
*all_vars.entry(var).or_insert(0) += 1;
|
|
}
|
|
}
|
|
// Always descend: a deeper folder may be directly granted even when
|
|
// this one is not.
|
|
for sub in src.subfolders(&scope, &path).await {
|
|
stack.push((scope.clone(), sub));
|
|
}
|
|
}
|
|
all_vars
|
|
}
|
|
|
|
/// Real [`FolderSource`] backed by Vault and the caller's identity.
|
|
struct VaultFolderSource<'a> {
|
|
vault: &'a VaultBackends,
|
|
id: &'a AuthIdentity,
|
|
}
|
|
|
|
impl FolderSource for VaultFolderSource<'_> {
|
|
async fn accessible(&self, scope: &str, path: &str) -> bool {
|
|
check_folder_access(self.vault, scope, path, self.id)
|
|
.await
|
|
.is_ok()
|
|
}
|
|
|
|
async fn subfolders(&self, scope: &str, path: &str) -> Vec<String> {
|
|
match self.vault.list_subfolders(scope, path).await {
|
|
Ok(subs) => subs
|
|
.into_iter()
|
|
.map(|sub| sub.path.unwrap_or_else(|| format!("{}/{}", path, sub.name)))
|
|
.collect(),
|
|
Err(_) => Vec::new(),
|
|
}
|
|
}
|
|
|
|
async fn folder_var_names(&self, scope: &str, path: &str) -> Vec<String> {
|
|
let mut out = Vec::new();
|
|
let entries = self
|
|
.vault
|
|
.list_entries(scope, path)
|
|
.await
|
|
.unwrap_or_default();
|
|
for entry_name in &entries {
|
|
if let Ok(entry) = self.vault.get_entry(scope, path, entry_name).await {
|
|
out.extend(crate::vault::entry_credential_variables(&entry));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
/// GET /api/credential-variables — List all unique credential variables across address book entries.
|
|
/// Returns grouped by domain prefix, with entry counts.
|
|
pub async fn list_credential_variables(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(vault): Extension<VaultState>,
|
|
) -> impl IntoResponse {
|
|
let id = match identity {
|
|
Some(Extension(ref id)) if id.has_role("operator") => id.clone(),
|
|
_ => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "operator role required"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
// Scan all accessible folders and entries for variable references,
|
|
// recursing into subfolders so vars inside nested trees surface too.
|
|
let folders = match vault.list_all_folders().await {
|
|
Ok((f, _)) => f,
|
|
Err(e) => {
|
|
return (
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"error": format!("Failed to list folders: {}", e)})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
let roots: Vec<(String, String)> = folders.into_iter().map(|f| (f.scope, f.name)).collect();
|
|
let all_vars = gather_credential_variables(
|
|
&VaultFolderSource {
|
|
vault: &vault,
|
|
id: &id,
|
|
},
|
|
roots,
|
|
)
|
|
.await;
|
|
|
|
// Group by domain prefix (everything before the last _user/_password/_domain/_key suffix)
|
|
let mut domains: std::collections::HashMap<String, Vec<serde_json::Value>> =
|
|
std::collections::HashMap::new();
|
|
for (var, count) in &all_vars {
|
|
let domain = var
|
|
.rsplit_once('_')
|
|
.map(|(prefix, _suffix)| prefix.to_string())
|
|
.unwrap_or_else(|| var.clone());
|
|
domains
|
|
.entry(domain)
|
|
.or_default()
|
|
.push(json!({"name": var, "entry_count": count}));
|
|
}
|
|
|
|
Json(json!({ "variables": all_vars, "domains": domains })).into_response()
|
|
}
|
|
|
|
/// POST /api/admin/user-tokens — Admin creates a token for any user.
|
|
pub async fn admin_create_user_token(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: axum::http::HeaderMap,
|
|
trusted: Option<Extension<TrustedProxies>>,
|
|
Json(req): Json<AdminCreateTokenRequest>,
|
|
) -> impl IntoResponse {
|
|
let admin_name = match identity {
|
|
Some(Extension(ref id)) if id.has_role("admin") => id.display_name().to_string(),
|
|
_ => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
if req.name.is_empty() || req.name.len() > 100 {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({"error": "token name must be 1-100 characters"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
if let Some(ref max_role) = req.max_role {
|
|
if !["admin", "poweruser", "operator", "viewer"].contains(&max_role.as_str()) {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({"error": "max_role must be admin, poweruser, operator, or viewer"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
let db_clone = database.clone();
|
|
let target_email = req.email.clone();
|
|
let user =
|
|
match tokio::task::spawn_blocking(move || db::get_user_by_email(&db_clone, &target_email))
|
|
.await
|
|
{
|
|
Ok(Ok(u)) => u,
|
|
Ok(Err(_)) => {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({"error": "user not found"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
_ => {
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "database error"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
if let Some(ref max_role) = req.max_role {
|
|
if role_level(max_role) > role_level(&user.role) {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({"error": format!("max_role '{}' exceeds user's role '{}'", max_role, user.role)})),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
let db_clone = database.clone();
|
|
let name = req.name.clone();
|
|
let max_role = req.max_role.clone();
|
|
let expires_at = req.expires_at.clone();
|
|
let user_id = user.id;
|
|
let result = tokio::task::spawn_blocking(move || {
|
|
db::create_user_token(
|
|
&db_clone,
|
|
user_id,
|
|
&name,
|
|
max_role.as_deref(),
|
|
expires_at.as_deref(),
|
|
)
|
|
})
|
|
.await;
|
|
|
|
match result {
|
|
Ok(Ok((token_id, plaintext))) => {
|
|
let proxies = trusted.map(|Extension(t)| t.0).unwrap_or_default();
|
|
let ip = client_ip(&headers, addr.ip(), &proxies);
|
|
let details = serde_json::to_string(&json!({
|
|
"created_by": admin_name,
|
|
"for_user": req.email,
|
|
"max_role": req.max_role,
|
|
"expires_at": req.expires_at,
|
|
}))
|
|
.ok();
|
|
let db_clone = database.clone();
|
|
let email_clone = req.email.clone();
|
|
let name_clone = req.name.clone();
|
|
let _ = tokio::task::spawn_blocking(move || {
|
|
db::log_token_event(
|
|
&db_clone,
|
|
Some(token_id),
|
|
Some(&name_clone),
|
|
&email_clone,
|
|
"created",
|
|
Some(&ip.to_string()),
|
|
details.as_deref(),
|
|
)
|
|
})
|
|
.await;
|
|
|
|
Json(json!({
|
|
"id": token_id,
|
|
"name": req.name,
|
|
"email": req.email,
|
|
"token": plaintext,
|
|
"max_role": req.max_role,
|
|
"expires_at": req.expires_at,
|
|
}))
|
|
.into_response()
|
|
}
|
|
Ok(Err(e)) => {
|
|
let msg = e.to_string();
|
|
if msg.contains("UNIQUE constraint") {
|
|
(
|
|
StatusCode::CONFLICT,
|
|
Json(json!({"error": format!("token name '{}' already exists for this user", req.name)})),
|
|
)
|
|
.into_response()
|
|
} else {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to create token"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
}
|
|
_ => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to create token"})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// GET /api/admin/user-tokens — List all user tokens. Admin only.
|
|
pub async fn admin_list_user_tokens(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
) -> impl IntoResponse {
|
|
if let Some(Extension(ref id)) = identity {
|
|
if !id.has_role("admin") {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
let db_clone = database.clone();
|
|
match tokio::task::spawn_blocking(move || db::list_all_user_tokens(&db_clone)).await {
|
|
Ok(Ok(tokens)) => {
|
|
let entries: Vec<_> = tokens
|
|
.into_iter()
|
|
.map(|(t, email)| {
|
|
json!({
|
|
"id": t.id,
|
|
"user_id": t.user_id,
|
|
"email": email,
|
|
"name": t.name,
|
|
"max_role": t.max_role,
|
|
"expires_at": t.expires_at,
|
|
"disabled": t.disabled,
|
|
"created_at": t.created_at,
|
|
"last_used_at": t.last_used_at,
|
|
})
|
|
})
|
|
.collect();
|
|
Json(json!(entries)).into_response()
|
|
}
|
|
_ => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to list tokens"})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// DELETE /api/admin/user-tokens/{id} — Admin revoke any token.
|
|
pub async fn admin_revoke_user_token(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: axum::http::HeaderMap,
|
|
trusted: Option<Extension<TrustedProxies>>,
|
|
Path(token_id): Path<i64>,
|
|
) -> impl IntoResponse {
|
|
let admin_name = match identity {
|
|
Some(Extension(ref id)) if id.has_role("admin") => id.display_name().to_string(),
|
|
_ => {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
let db_clone = database.clone();
|
|
match tokio::task::spawn_blocking(move || db::admin_revoke_user_token(&db_clone, token_id))
|
|
.await
|
|
{
|
|
Ok(Ok(true)) => {
|
|
let proxies = trusted.map(|Extension(t)| t.0).unwrap_or_default();
|
|
let ip = client_ip(&headers, addr.ip(), &proxies);
|
|
let db_clone = database.clone();
|
|
let _ = tokio::task::spawn_blocking(move || {
|
|
db::log_token_event(
|
|
&db_clone,
|
|
Some(token_id),
|
|
None,
|
|
&admin_name,
|
|
"admin_revoked",
|
|
Some(&ip.to_string()),
|
|
None,
|
|
)
|
|
})
|
|
.await;
|
|
Json(json!({"ok": true})).into_response()
|
|
}
|
|
Ok(Ok(false)) => (
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({"error": "token not found"})),
|
|
)
|
|
.into_response(),
|
|
_ => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to revoke token"})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct AuditLogQuery {
|
|
pub limit: Option<u32>,
|
|
pub email: Option<String>,
|
|
}
|
|
|
|
/// GET /api/admin/token-audit — View token audit log. Admin only.
|
|
pub async fn admin_token_audit(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
Query(query): Query<AuditLogQuery>,
|
|
) -> impl IntoResponse {
|
|
if let Some(Extension(ref id)) = identity {
|
|
if !id.has_role("admin") {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
let limit = query.limit.unwrap_or(200).min(1000);
|
|
let email = query.email.clone();
|
|
let db_clone = database.clone();
|
|
match tokio::task::spawn_blocking(move || {
|
|
db::list_token_audit_log(&db_clone, limit, email.as_deref())
|
|
})
|
|
.await
|
|
{
|
|
Ok(Ok(entries)) => Json(json!(entries)).into_response(),
|
|
_ => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to list audit log"})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
/// GET /api/admin/addressbook-audit — View connections audit log. Admin only.
|
|
pub async fn admin_addressbook_audit(
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
Extension(database): Extension<Db>,
|
|
Query(query): Query<AuditLogQuery>,
|
|
) -> impl IntoResponse {
|
|
if let Some(Extension(ref id)) = identity {
|
|
if !id.has_role("admin") {
|
|
return (
|
|
StatusCode::FORBIDDEN,
|
|
Json(json!({"error": "admin role required"})),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
|
|
let limit = query.limit.unwrap_or(200).min(1000);
|
|
let email = query.email.clone();
|
|
let db_clone = database.clone();
|
|
match tokio::task::spawn_blocking(move || {
|
|
db::list_addressbook_audit_log(&db_clone, limit, email.as_deref())
|
|
})
|
|
.await
|
|
{
|
|
Ok(Ok(entries)) => Json(json!(entries)).into_response(),
|
|
_ => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(json!({"error": "failed to list audit log"})),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
// ── Quick Connect (external integrations) ──
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct QuickConnectQuery {
|
|
/// "ssh", "rdp", "vnc", "web"
|
|
pub protocol: Option<String>,
|
|
pub hostname: Option<String>,
|
|
pub port: Option<u16>,
|
|
pub username: Option<String>,
|
|
pub url: Option<String>,
|
|
// Display
|
|
pub width: Option<u32>,
|
|
pub height: Option<u32>,
|
|
pub dpi: Option<u32>,
|
|
// Address book mode
|
|
pub scope: Option<String>,
|
|
pub folder: Option<String>,
|
|
pub entry: Option<String>,
|
|
}
|
|
|
|
/// GET /api/connect — Quick-connect endpoint for external integrations.
|
|
/// Creates a session and 302-redirects to the client page.
|
|
/// If not authenticated, redirects to /auth/login?next=<original-url>.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn quick_connect(
|
|
State(manager): State<AppState>,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: axum::http::HeaderMap,
|
|
identity: Option<Extension<AuthIdentity>>,
|
|
trusted: Option<Extension<TrustedProxies>>,
|
|
Extension(vault): Extension<VaultState>,
|
|
Extension(oidc_enabled): Extension<OidcEnabled>,
|
|
request: axum::extract::Request,
|
|
) -> Response {
|
|
// Reconstruct query string from the request URI
|
|
let query_string = request.uri().query().unwrap_or("");
|
|
|
|
let query: QuickConnectQuery = match serde_urlencoded::from_str(query_string) {
|
|
Ok(q) => q,
|
|
Err(e) => {
|
|
return quick_connect_error(
|
|
StatusCode::BAD_REQUEST,
|
|
&format!("Invalid query parameters: {}", e),
|
|
);
|
|
}
|
|
};
|
|
|
|
let id = match identity {
|
|
Some(Extension(ref id)) => id.clone(),
|
|
None => {
|
|
// Not authenticated — redirect to login with next= param
|
|
if oidc_enabled.0 {
|
|
let next = format!("/api/connect?{}", query_string);
|
|
let encoded = urlencoding::encode(&next);
|
|
return Redirect::temporary(&format!("/auth/login?next={}", encoded))
|
|
.into_response();
|
|
}
|
|
return quick_connect_error(
|
|
StatusCode::UNAUTHORIZED,
|
|
"Authentication required. Sign in via SSO or provide an API key.",
|
|
);
|
|
}
|
|
};
|
|
|
|
let proxies = trusted.map(|Extension(t)| t.0).unwrap_or_default();
|
|
let client_ip = client_ip(&headers, addr.ip(), &proxies);
|
|
let admin_name = id.display_name().to_string();
|
|
|
|
// Address book mode: scope + folder + entry all provided
|
|
if let (Some(scope), Some(folder), Some(entry)) = (
|
|
query.scope.as_ref(),
|
|
query.folder.as_ref(),
|
|
query.entry.as_ref(),
|
|
) {
|
|
if !id.has_role("operator") {
|
|
return quick_connect_error(
|
|
StatusCode::FORBIDDEN,
|
|
"Operator role or higher required for address book connections.",
|
|
);
|
|
}
|
|
|
|
if !vault.any_connected().await {
|
|
return quick_connect_error(
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Address book is temporarily unavailable (Vault offline).",
|
|
);
|
|
}
|
|
|
|
if check_folder_access(&vault, scope, folder, &id)
|
|
.await
|
|
.is_err()
|
|
{
|
|
return quick_connect_error(StatusCode::FORBIDDEN, "No access to this folder.");
|
|
}
|
|
|
|
let ab_entry = match vault.get_entry(scope, folder, entry).await {
|
|
Ok(e) => e,
|
|
Err(VaultError::NotFound) => {
|
|
return quick_connect_error(
|
|
StatusCode::NOT_FOUND,
|
|
&format!("Entry '{}' not found in {}/{}.", entry, scope, folder),
|
|
);
|
|
}
|
|
Err(e) => {
|
|
return quick_connect_error(
|
|
StatusCode::BAD_GATEWAY,
|
|
&format!("Failed to read address book entry: {}", e),
|
|
);
|
|
}
|
|
};
|
|
|
|
// Same folder defaults as the Connections connect endpoint, so an
|
|
// entry that works from the page works from a link too.
|
|
let ab_entry = lend_folder_defaults(vault.as_ref(), scope, folder, entry, ab_entry).await;
|
|
|
|
// Resolve credential variable references
|
|
let ab_entry = if !crate::vault::entry_credential_variables(&ab_entry).is_empty() {
|
|
let user_email = match &id {
|
|
AuthIdentity::User { email, .. } => Some(email.clone()),
|
|
_ => None,
|
|
};
|
|
if let Some(email) = user_email {
|
|
match vault.get_user_credentials(&email).await {
|
|
Ok(user_creds) => {
|
|
match crate::vault::resolve_credential_variables(&ab_entry, &user_creds) {
|
|
Ok(resolved) => resolved,
|
|
Err(missing) => {
|
|
return quick_connect_error(
|
|
StatusCode::PRECONDITION_FAILED,
|
|
&format!(
|
|
"Missing credential variables: {}. Set them in My Credentials.",
|
|
missing.join(", ")
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("Failed to read user credentials from Vault: {}", e);
|
|
ab_entry
|
|
}
|
|
}
|
|
} else {
|
|
ab_entry
|
|
}
|
|
} else {
|
|
ab_entry
|
|
};
|
|
|
|
// Check if we need to prompt for credentials before connecting
|
|
let needs_prompt = ab_entry.session_type != "web"
|
|
&& (ab_entry.prompt_credentials == Some(true)
|
|
|| (ab_entry.password.as_ref().is_none_or(|p| p.is_empty())
|
|
&& ab_entry.private_key.as_ref().is_none_or(|k| k.is_empty())));
|
|
|
|
if needs_prompt {
|
|
return quick_connect_credential_form(
|
|
scope,
|
|
folder,
|
|
entry,
|
|
&ab_entry.session_type,
|
|
ab_entry.username.as_deref(),
|
|
ab_entry.domain.as_deref(),
|
|
ab_entry.display_name.as_deref(),
|
|
);
|
|
}
|
|
|
|
let session_type = match ab_entry.session_type.as_str() {
|
|
"ssh" => SessionType::Ssh,
|
|
"rdp" => SessionType::Rdp,
|
|
"vnc" => SessionType::Vnc,
|
|
"web" => SessionType::Web,
|
|
other => {
|
|
return quick_connect_error(
|
|
StatusCode::BAD_REQUEST,
|
|
&format!("Unknown session type: {}", other),
|
|
);
|
|
}
|
|
};
|
|
|
|
let ab_entry_key = format!("{}/{}/{}", scope, folder, entry);
|
|
let create_req = CreateSessionRequest {
|
|
session_type,
|
|
hostname: ab_entry.hostname,
|
|
port: ab_entry.port,
|
|
username: ab_entry.username,
|
|
password: ab_entry.password,
|
|
private_key: ab_entry.private_key,
|
|
generate_keypair: None,
|
|
url: ab_entry.url,
|
|
domain: ab_entry.domain,
|
|
security: ab_entry.security,
|
|
server_layout: ab_entry.server_layout,
|
|
ignore_cert: ab_entry.ignore_cert,
|
|
auth_pkg: ab_entry.auth_pkg,
|
|
kdc_url: ab_entry.kdc_url,
|
|
kerberos_cache: None,
|
|
color_depth: ab_entry.color_depth,
|
|
jump_hosts: ab_entry.jump_hosts,
|
|
jump_host: None,
|
|
jump_port: None,
|
|
jump_username: None,
|
|
jump_password: None,
|
|
jump_private_key: None,
|
|
width: query.width,
|
|
height: query.height,
|
|
dpi: query.dpi,
|
|
banner: ab_entry.banner,
|
|
enable_drive: ab_entry.enable_drive,
|
|
remote_app: ab_entry.remote_app,
|
|
remote_app_dir: ab_entry.remote_app_dir,
|
|
remote_app_args: ab_entry.remote_app_args,
|
|
enable_recording: ab_entry.enable_recording,
|
|
record_typescript: ab_entry.record_typescript,
|
|
address_book_entry: Some(ab_entry_key),
|
|
address_book_folder: Some(folder.to_string()),
|
|
entry_display_name: ab_entry.display_name.clone(),
|
|
max_recordings: ab_entry.max_recordings,
|
|
login_script: ab_entry.login_script,
|
|
autofill: ab_entry.autofill,
|
|
allowed_domains: ab_entry.allowed_domains,
|
|
disable_copy: ab_entry.disable_copy,
|
|
disable_paste: ab_entry.disable_paste,
|
|
enable_gfx: ab_entry.enable_gfx,
|
|
enable_desktop_composition: ab_entry.enable_desktop_composition,
|
|
enable_wallpaper: ab_entry.enable_wallpaper,
|
|
enable_theming: ab_entry.enable_theming,
|
|
enable_full_window_drag: ab_entry.enable_full_window_drag,
|
|
force_lossless: ab_entry.force_lossless,
|
|
enable_h264: ab_entry.enable_h264,
|
|
container_image: ab_entry.container_image,
|
|
container_cpu_limit: ab_entry.container_cpu_limit,
|
|
container_memory_limit: ab_entry.container_memory_limit,
|
|
container_env: ab_entry.container_env,
|
|
container_idle_timeout_mins: ab_entry.container_idle_timeout_mins,
|
|
container_username: ab_entry.container_username,
|
|
container_password: ab_entry.container_password,
|
|
allow_sharing: ab_entry.allow_sharing,
|
|
fullscreen_on_connect: ab_entry.fullscreen_on_connect,
|
|
autohide_side_tabs: ab_entry.autohide_side_tabs,
|
|
spice_tls: ab_entry.spice_tls,
|
|
spice_tls_port: ab_entry.spice_tls_port,
|
|
spice_ca_cert: ab_entry.spice_ca_cert,
|
|
spice_cert_subject: ab_entry.spice_cert_subject,
|
|
spice_proxy: ab_entry.spice_proxy,
|
|
proxmox_url: None,
|
|
proxmox_node: None,
|
|
proxmox_vmid: None,
|
|
proxmox_token_id: None,
|
|
proxmox_token_secret: None,
|
|
proxmox_verify_tls: None,
|
|
max_monitors: None,
|
|
owner: None,
|
|
fence_all_targets: false,
|
|
ssh_font_size: ab_entry.ssh_font_size,
|
|
wol_send_packet: ab_entry.wol_send_packet,
|
|
wol_mac_addr: ab_entry.wol_mac_addr,
|
|
wol_broadcast_addr: ab_entry.wol_broadcast_addr,
|
|
wol_udp_port: ab_entry.wol_udp_port,
|
|
wol_wait_time: ab_entry.wol_wait_time,
|
|
};
|
|
|
|
tracing::info!(
|
|
user = %admin_name,
|
|
client_ip = %client_ip,
|
|
scope = %scope,
|
|
folder = %folder,
|
|
entry = %entry,
|
|
"Quick connect (address book)"
|
|
);
|
|
|
|
return match manager.create_session(create_req, admin_name).await {
|
|
Ok(info) => {
|
|
Redirect::temporary(&format!("/client/{}", info.session_id)).into_response()
|
|
}
|
|
Err(e) => quick_connect_error(StatusCode::BAD_GATEWAY, &e.to_string()),
|
|
};
|
|
}
|
|
|
|
// Ad-hoc mode: requires poweruser+
|
|
if !id.has_role("poweruser") {
|
|
return quick_connect_error(
|
|
StatusCode::FORBIDDEN,
|
|
"Poweruser role or higher required for ad-hoc connections.",
|
|
);
|
|
}
|
|
|
|
let session_type = match query.protocol.as_deref() {
|
|
Some("rdp") => SessionType::Rdp,
|
|
Some("vnc") => SessionType::Vnc,
|
|
Some("web") => SessionType::Web,
|
|
Some("ssh") | None => SessionType::Ssh,
|
|
Some(other) => {
|
|
return quick_connect_error(
|
|
StatusCode::BAD_REQUEST,
|
|
&format!("Unknown protocol '{}'. Use ssh, rdp, vnc, or web.", other),
|
|
);
|
|
}
|
|
};
|
|
|
|
tracing::info!(
|
|
user = %admin_name,
|
|
client_ip = %client_ip,
|
|
protocol = query.protocol.as_deref().unwrap_or("ssh"),
|
|
hostname = query.hostname.as_deref().unwrap_or("?"),
|
|
"Quick connect (ad-hoc)"
|
|
);
|
|
|
|
let create_req = CreateSessionRequest {
|
|
session_type,
|
|
hostname: query.hostname,
|
|
port: query.port,
|
|
username: query.username,
|
|
password: None,
|
|
private_key: None,
|
|
generate_keypair: None,
|
|
url: query.url,
|
|
domain: None,
|
|
security: None,
|
|
server_layout: None,
|
|
ignore_cert: None,
|
|
auth_pkg: None,
|
|
kdc_url: None,
|
|
kerberos_cache: None,
|
|
color_depth: None,
|
|
jump_hosts: None,
|
|
jump_host: None,
|
|
jump_port: None,
|
|
jump_username: None,
|
|
jump_password: None,
|
|
jump_private_key: None,
|
|
width: query.width,
|
|
height: query.height,
|
|
dpi: query.dpi,
|
|
banner: None,
|
|
enable_drive: None,
|
|
remote_app: None,
|
|
remote_app_dir: None,
|
|
remote_app_args: None,
|
|
enable_recording: None,
|
|
record_typescript: None,
|
|
address_book_entry: None,
|
|
address_book_folder: None,
|
|
entry_display_name: None,
|
|
max_recordings: None,
|
|
login_script: None,
|
|
autofill: None,
|
|
allowed_domains: None,
|
|
disable_copy: None,
|
|
disable_paste: None,
|
|
enable_gfx: None,
|
|
enable_desktop_composition: None,
|
|
enable_wallpaper: None,
|
|
enable_theming: None,
|
|
enable_full_window_drag: None,
|
|
force_lossless: None,
|
|
enable_h264: None,
|
|
container_image: None,
|
|
container_cpu_limit: None,
|
|
container_memory_limit: None,
|
|
container_env: None,
|
|
container_idle_timeout_mins: None,
|
|
container_username: None,
|
|
container_password: None,
|
|
allow_sharing: None,
|
|
fullscreen_on_connect: None,
|
|
autohide_side_tabs: None,
|
|
spice_tls: None,
|
|
spice_tls_port: None,
|
|
spice_ca_cert: None,
|
|
spice_cert_subject: None,
|
|
spice_proxy: None,
|
|
proxmox_url: None,
|
|
proxmox_node: None,
|
|
proxmox_vmid: None,
|
|
proxmox_token_id: None,
|
|
proxmox_token_secret: None,
|
|
proxmox_verify_tls: None,
|
|
max_monitors: None,
|
|
owner: None,
|
|
fence_all_targets: false,
|
|
ssh_font_size: None,
|
|
wol_send_packet: None,
|
|
wol_mac_addr: None,
|
|
wol_broadcast_addr: None,
|
|
wol_udp_port: None,
|
|
wol_wait_time: None,
|
|
};
|
|
|
|
match manager.create_session(create_req, admin_name).await {
|
|
Ok(info) => Redirect::temporary(&format!("/client/{}", info.session_id)).into_response(),
|
|
Err(e) => quick_connect_error(StatusCode::BAD_GATEWAY, &e.to_string()),
|
|
}
|
|
}
|
|
|
|
/// Return an inline HTML credential form for quick-connect when prompting is needed.
|
|
fn quick_connect_credential_form(
|
|
scope: &str,
|
|
folder: &str,
|
|
entry: &str,
|
|
session_type: &str,
|
|
username: Option<&str>,
|
|
domain: Option<&str>,
|
|
display_name: Option<&str>,
|
|
) -> Response {
|
|
let title = display_name.unwrap_or(entry);
|
|
let user_val = html_escape(username.unwrap_or(""));
|
|
let domain_val = html_escape(domain.unwrap_or(""));
|
|
let domain_display = if session_type == "rdp" {
|
|
"block"
|
|
} else {
|
|
"none"
|
|
};
|
|
let html = format!(
|
|
r##"<!DOCTYPE html>
|
|
<html><head><title>Connect — {title}</title>
|
|
<style>
|
|
*{{box-sizing:border-box}}
|
|
body{{font-family:system-ui,sans-serif;background:#1a1a2e;color:#e0e0e0;margin:0;
|
|
display:flex;justify-content:center;align-items:center;min-height:100vh}}
|
|
.card{{background:#16213e;border-radius:12px;padding:32px;width:100%;max-width:400px;
|
|
box-shadow:0 4px 24px rgba(0,0,0,.4)}}
|
|
h2{{margin:0 0 4px;color:#fff;font-size:1.3em}}
|
|
.sub{{color:#8899aa;font-size:.85em;margin-bottom:20px}}
|
|
label{{display:block;color:#aab;font-size:.85em;margin-bottom:4px;margin-top:14px}}
|
|
input{{width:100%;padding:10px 12px;border:1px solid #2a3a5e;border-radius:6px;
|
|
background:#0f1629;color:#e0e0e0;font-size:1em}}
|
|
input:focus{{outline:none;border-color:#4a6fa5}}
|
|
.domain-row{{display:{domain_display}}}
|
|
button{{width:100%;margin-top:20px;padding:12px;border:none;border-radius:6px;
|
|
background:#4a6fa5;color:#fff;font-size:1em;cursor:pointer;font-weight:600}}
|
|
button:hover{{background:#5a8fbf}}
|
|
button:disabled{{opacity:.6;cursor:wait}}
|
|
.error{{color:#f66;font-size:.85em;margin-top:12px;display:none}}
|
|
</style></head>
|
|
<body>
|
|
<div class="card">
|
|
<h2>{title}</h2>
|
|
<div class="sub">{session_type_upper} connection</div>
|
|
<form id="cred-form" autocomplete="on"
|
|
data-scope="{scope}" data-folder="{folder}" data-entry="{entry}">
|
|
<label for="username">Username</label>
|
|
<input id="username" name="username" type="text" value="{user_val}" autocomplete="username" autofocus>
|
|
<label for="password">Password</label>
|
|
<input id="password" name="password" type="password" autocomplete="current-password">
|
|
<div class="domain-row">
|
|
<label for="domain">Domain</label>
|
|
<input id="domain" name="domain" type="text" value="{domain_val}">
|
|
</div>
|
|
<button type="submit" id="btn">Connect</button>
|
|
<div class="error" id="err"></div>
|
|
</form>
|
|
</div>
|
|
<script>
|
|
document.getElementById('cred-form').addEventListener('submit', async function(e) {{
|
|
e.preventDefault();
|
|
const form = e.target;
|
|
const btn = document.getElementById('btn');
|
|
const err = document.getElementById('err');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Connecting…';
|
|
err.style.display = 'none';
|
|
const apiPath = '/api/addressbook/folders/'
|
|
+ encodeURIComponent(form.dataset.scope) + '/'
|
|
+ encodeURIComponent(form.dataset.folder) + '/entries/'
|
|
+ encodeURIComponent(form.dataset.entry) + '/connect';
|
|
const body = {{
|
|
username: document.getElementById('username').value || undefined,
|
|
password: document.getElementById('password').value || undefined,
|
|
domain: document.getElementById('domain').value || undefined,
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
dpi: Math.round(window.devicePixelRatio * 96) || 96,
|
|
}};
|
|
try {{
|
|
const headers = {{'Content-Type': 'application/json'}};
|
|
const apiKey = sessionStorage.getItem('api_key');
|
|
if (apiKey) headers['X-API-Key'] = apiKey;
|
|
const resp = await fetch(apiPath, {{
|
|
method: 'POST',
|
|
headers: headers,
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify(body),
|
|
}});
|
|
if (resp.ok) {{
|
|
const data = await resp.json();
|
|
window.location.href = '/client/' + data.session_id;
|
|
}} else {{
|
|
const data = await resp.json().catch(() => ({{}}));
|
|
throw new Error(data.error || ('HTTP ' + resp.status));
|
|
}}
|
|
}} catch (ex) {{
|
|
err.textContent = ex.message;
|
|
err.style.display = 'block';
|
|
btn.disabled = false;
|
|
btn.textContent = 'Connect';
|
|
}}
|
|
}});
|
|
</script>
|
|
</body></html>"##,
|
|
title = html_escape(title),
|
|
session_type_upper = session_type.to_uppercase(),
|
|
domain_display = domain_display,
|
|
scope = html_escape(scope),
|
|
folder = html_escape(folder),
|
|
entry = html_escape(entry),
|
|
user_val = user_val,
|
|
domain_val = domain_val,
|
|
);
|
|
(StatusCode::OK, axum::response::Html(html)).into_response()
|
|
}
|
|
|
|
/// Return an HTML error page for quick-connect failures (browser redirect flow).
|
|
fn quick_connect_error(status: StatusCode, message: &str) -> Response {
|
|
let html = format!(
|
|
r#"<!DOCTYPE html>
|
|
<html><head><title>Connection Error</title>
|
|
<style>body{{font-family:system-ui,sans-serif;max-width:600px;margin:80px auto;padding:0 20px}}
|
|
h1{{color:#c00}}a{{color:#06c}}</style></head>
|
|
<body><h1>Connection Error</h1><p>{}</p>
|
|
<p><a href="/">Return to home page</a></p></body></html>"#,
|
|
html_escape(message)
|
|
);
|
|
(status, axum::response::Html(html)).into_response()
|
|
}
|
|
|
|
/// Minimal HTML escaping for error messages.
|
|
fn html_escape(s: &str) -> String {
|
|
s.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
.replace('\'', "'")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::path::Path;
|
|
|
|
#[test]
|
|
fn entry_put_query_parses_the_move_flag() {
|
|
// `PUT .../entries/:entry?move=true` is what the NetBox upsert sends;
|
|
// the flag must default to off and accept only an explicit true.
|
|
let q: EntryPutQuery = serde_urlencoded::from_str("move=true").unwrap();
|
|
assert!(q.move_);
|
|
let q: EntryPutQuery = serde_urlencoded::from_str("move=false").unwrap();
|
|
assert!(!q.move_);
|
|
let q: EntryPutQuery = serde_urlencoded::from_str("").unwrap();
|
|
assert!(!q.move_, "move defaults to off");
|
|
assert!(
|
|
serde_urlencoded::from_str::<EntryPutQuery>("move=yes").is_err(),
|
|
"a non-boolean value is rejected rather than treated as true"
|
|
);
|
|
}
|
|
|
|
// ── `PUT .../entries/:entry?move=true`: the entry is matched on its
|
|
// stored external_id, never on its name across folders; a match
|
|
// elsewhere is moved whole by the server-side move and only then
|
|
// updated, so no secret the payload lacks is ever lost. ──
|
|
|
|
/// In-memory stand-in for the Vault entry store, keyed by folder + name.
|
|
#[derive(Default)]
|
|
struct FakeStore {
|
|
entries: std::sync::Mutex<std::collections::BTreeMap<(String, String), AddressBookEntry>>,
|
|
/// Make every delete fail, for the incomplete-move path.
|
|
fail_delete: bool,
|
|
}
|
|
|
|
impl FakeStore {
|
|
fn with(entries: &[(&str, &str, AddressBookEntry)]) -> Self {
|
|
let store = FakeStore::default();
|
|
for (folder, name, e) in entries {
|
|
store
|
|
.entries
|
|
.lock()
|
|
.unwrap()
|
|
.insert((folder.to_string(), name.to_string()), e.clone());
|
|
}
|
|
store
|
|
}
|
|
fn stored(&self, folder: &str, name: &str) -> Option<AddressBookEntry> {
|
|
self.entries
|
|
.lock()
|
|
.unwrap()
|
|
.get(&(folder.to_string(), name.to_string()))
|
|
.cloned()
|
|
}
|
|
/// Every `folder/name` held, sorted.
|
|
fn keys(&self) -> Vec<String> {
|
|
self.entries
|
|
.lock()
|
|
.unwrap()
|
|
.keys()
|
|
.map(|(f, n)| format!("{f}/{n}"))
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
impl EntryStore for FakeStore {
|
|
async fn folder_paths(&self, _scope: &str) -> Result<Vec<String>, VaultError> {
|
|
let mut folders: Vec<String> = self
|
|
.entries
|
|
.lock()
|
|
.unwrap()
|
|
.keys()
|
|
.map(|(f, _)| f.clone())
|
|
.collect();
|
|
folders.dedup();
|
|
Ok(folders)
|
|
}
|
|
async fn entry_names(&self, _scope: &str, folder: &str) -> Result<Vec<String>, VaultError> {
|
|
Ok(self
|
|
.entries
|
|
.lock()
|
|
.unwrap()
|
|
.keys()
|
|
.filter(|(f, _)| f == folder)
|
|
.map(|(_, n)| n.clone())
|
|
.collect())
|
|
}
|
|
async fn get_entry(
|
|
&self,
|
|
_scope: &str,
|
|
folder: &str,
|
|
entry: &str,
|
|
) -> Result<AddressBookEntry, VaultError> {
|
|
self.stored(folder, entry).ok_or(VaultError::NotFound)
|
|
}
|
|
async fn put_entry(
|
|
&self,
|
|
_scope: &str,
|
|
folder: &str,
|
|
entry: &str,
|
|
data: &AddressBookEntry,
|
|
) -> Result<(), VaultError> {
|
|
self.entries
|
|
.lock()
|
|
.unwrap()
|
|
.insert((folder.to_string(), entry.to_string()), data.clone());
|
|
Ok(())
|
|
}
|
|
async fn put_entry_if_absent(
|
|
&self,
|
|
_scope: &str,
|
|
folder: &str,
|
|
entry: &str,
|
|
data: &AddressBookEntry,
|
|
) -> Result<bool, VaultError> {
|
|
let mut entries = self.entries.lock().unwrap();
|
|
let key = (folder.to_string(), entry.to_string());
|
|
if entries.contains_key(&key) {
|
|
return Ok(false);
|
|
}
|
|
entries.insert(key, data.clone());
|
|
Ok(true)
|
|
}
|
|
async fn delete_entry(
|
|
&self,
|
|
_scope: &str,
|
|
folder: &str,
|
|
entry: &str,
|
|
) -> Result<(), VaultError> {
|
|
if self.fail_delete {
|
|
return Err(VaultError::Parse("delete refused".into()));
|
|
}
|
|
self.entries
|
|
.lock()
|
|
.unwrap()
|
|
.remove(&(folder.to_string(), entry.to_string()))
|
|
.map(|_| ())
|
|
.ok_or(VaultError::NotFound)
|
|
}
|
|
}
|
|
|
|
/// An entry from JSON, as the webhook or the edit form sends it.
|
|
fn entry(v: serde_json::Value) -> AddressBookEntry {
|
|
serde_json::from_value(v).unwrap()
|
|
}
|
|
|
|
/// Device 1 as stored, with every kind of credential someone adds by
|
|
/// hand: a password holding a `$variable` reference, a private key and
|
|
/// a jump host password.
|
|
fn fw01_stored() -> AddressBookEntry {
|
|
entry(json!({
|
|
"type": "ssh", "hostname": "10.0.0.1", "port": 22, "username": "admin",
|
|
"password": "$tenant_a_password", "private_key": "-----BEGIN KEY-----",
|
|
"jump_hosts": [{"hostname": "bastion", "port": 22, "username": "jump", "password": "jump-pw"}],
|
|
"external_id": "netbox:device:1"
|
|
}))
|
|
}
|
|
|
|
/// What the NetBox webhook sends for device 1: no secrets at all.
|
|
fn fw01_webhook(hostname: &str) -> AddressBookEntry {
|
|
entry(json!({
|
|
"type": "ssh", "hostname": hostname, "port": 22, "username": "admin",
|
|
"jump_hosts": [{"hostname": "bastion", "port": 22, "username": "jump"}],
|
|
"external_id": "netbox:device:1"
|
|
}))
|
|
}
|
|
|
|
fn from(folder: &str, name: &str) -> MovedFrom {
|
|
MovedFrom {
|
|
folder: folder.into(),
|
|
name: name.into(),
|
|
}
|
|
}
|
|
|
|
fn rejected(err: UpsertError) -> (StatusCode, String) {
|
|
match err {
|
|
UpsertError::Rejected(status, msg) => (status, msg),
|
|
other => panic!("expected a rejection, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn move_upsert_leaves_a_same_named_entry_in_another_folder_alone() {
|
|
// Names are not unique across folders: tenant A and tenant B each
|
|
// have a fw01. A's webhook must never touch B's, credentials and all.
|
|
let store = FakeStore::with(&[
|
|
("tenant-a", "fw01", fw01_stored()),
|
|
(
|
|
"tenant-b",
|
|
"fw01",
|
|
entry(
|
|
json!({"type": "ssh", "hostname": "10.0.1.1", "password": "b-secret",
|
|
"external_id": "netbox:device:2"}),
|
|
),
|
|
),
|
|
]);
|
|
let out = upsert_entry(
|
|
&store,
|
|
"shared",
|
|
"tenant-a",
|
|
"fw01",
|
|
fw01_webhook("10.0.0.9"),
|
|
true,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(out.moved_from, None);
|
|
assert_eq!(store.keys(), ["tenant-a/fw01", "tenant-b/fw01"]);
|
|
let b = store.stored("tenant-b", "fw01").unwrap();
|
|
assert_eq!(b.password.as_deref(), Some("b-secret"));
|
|
assert_eq!(b.hostname.as_deref(), Some("10.0.1.1"));
|
|
let a = store.stored("tenant-a", "fw01").unwrap();
|
|
assert_eq!(a.hostname.as_deref(), Some("10.0.0.9"));
|
|
|
|
// A third tenant's own fw01 is created there; the other two are as
|
|
// they were.
|
|
let out = upsert_entry(
|
|
&store,
|
|
"shared",
|
|
"tenant-c",
|
|
"fw01",
|
|
entry(json!({"type": "ssh", "hostname": "10.0.2.1", "external_id": "netbox:device:3"})),
|
|
true,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(out.moved_from, None);
|
|
assert_eq!(
|
|
store.keys(),
|
|
["tenant-a/fw01", "tenant-b/fw01", "tenant-c/fw01"]
|
|
);
|
|
assert_eq!(
|
|
store
|
|
.stored("tenant-a", "fw01")
|
|
.unwrap()
|
|
.password
|
|
.as_deref(),
|
|
Some("$tenant_a_password")
|
|
);
|
|
assert_eq!(
|
|
store
|
|
.stored("tenant-b", "fw01")
|
|
.unwrap()
|
|
.password
|
|
.as_deref(),
|
|
Some("b-secret")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn move_upsert_moves_the_matched_entry_with_its_credentials() {
|
|
// The device changed tenant, so the webhook now writes to tenant B's
|
|
// (nested) folder, and it carries no secrets. The stored entry is
|
|
// moved whole first (#232: the $variable reference, the key and the
|
|
// jump host password come along), and the update lands on top.
|
|
let store = FakeStore::with(&[("tenant-a", "fw01", fw01_stored())]);
|
|
let out = upsert_entry(
|
|
&store,
|
|
"shared",
|
|
"tenants/tenant-b",
|
|
"fw01",
|
|
fw01_webhook("10.0.1.9"),
|
|
true,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(out.moved_from, Some(from("tenant-a", "fw01")));
|
|
assert_eq!(
|
|
store.keys(),
|
|
["tenants/tenant-b/fw01"],
|
|
"the original is gone and nothing else appeared"
|
|
);
|
|
let moved = store.stored("tenants/tenant-b", "fw01").unwrap();
|
|
assert_eq!(
|
|
moved.hostname.as_deref(),
|
|
Some("10.0.1.9"),
|
|
"update applied"
|
|
);
|
|
assert_eq!(moved.password.as_deref(), Some("$tenant_a_password"));
|
|
assert_eq!(moved.private_key.as_deref(), Some("-----BEGIN KEY-----"));
|
|
assert_eq!(
|
|
moved.jump_hosts.as_ref().unwrap()[0].password.as_deref(),
|
|
Some("jump-pw")
|
|
);
|
|
assert_eq!(moved.external_id.as_deref(), Some("netbox:device:1"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn move_upsert_follows_a_rename_within_the_folder() {
|
|
// Same tenant, new device name: matched on the id, so it is a move
|
|
// (with its secrets), not a second entry beside an orphan.
|
|
let store = FakeStore::with(&[("tenant-a", "fw01", fw01_stored())]);
|
|
let out = upsert_entry(
|
|
&store,
|
|
"shared",
|
|
"tenant-a",
|
|
"fw01-core",
|
|
fw01_webhook("10.0.0.1"),
|
|
true,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(out.moved_from, Some(from("tenant-a", "fw01")));
|
|
assert_eq!(store.keys(), ["tenant-a/fw01-core"]);
|
|
assert_eq!(
|
|
store
|
|
.stored("tenant-a", "fw01-core")
|
|
.unwrap()
|
|
.password
|
|
.as_deref(),
|
|
Some("$tenant_a_password")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upsert_without_secrets_keeps_the_stored_ones() {
|
|
// The steady state: the device is where the webhook says. A payload
|
|
// without secrets keeps every stored one, and a form edit that also
|
|
// omits external_id keeps that.
|
|
let store = FakeStore::with(&[("tenant-a", "fw01", fw01_stored())]);
|
|
let out = upsert_entry(
|
|
&store,
|
|
"shared",
|
|
"tenant-a",
|
|
"fw01",
|
|
fw01_webhook("10.0.0.2"),
|
|
true,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(out.moved_from, None, "already there: nothing to move");
|
|
let e = store.stored("tenant-a", "fw01").unwrap();
|
|
assert_eq!(e.hostname.as_deref(), Some("10.0.0.2"));
|
|
assert_eq!(e.password.as_deref(), Some("$tenant_a_password"));
|
|
assert_eq!(e.private_key.as_deref(), Some("-----BEGIN KEY-----"));
|
|
assert_eq!(
|
|
e.jump_hosts.as_ref().unwrap()[0].password.as_deref(),
|
|
Some("jump-pw")
|
|
);
|
|
|
|
// The edit form: no secrets, no external_id, no move flag.
|
|
let form = entry(
|
|
json!({"type": "ssh", "hostname": "10.0.0.3", "port": 2222, "username": "admin"}),
|
|
);
|
|
upsert_entry(&store, "shared", "tenant-a", "fw01", form, false)
|
|
.await
|
|
.unwrap();
|
|
let e = store.stored("tenant-a", "fw01").unwrap();
|
|
assert_eq!(e.port, Some(2222));
|
|
assert_eq!(e.password.as_deref(), Some("$tenant_a_password"));
|
|
assert_eq!(
|
|
e.external_id.as_deref(),
|
|
Some("netbox:device:1"),
|
|
"a form edit does not cut the entry loose from its source"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn move_upsert_adopts_a_hand_made_entry_of_that_name() {
|
|
// Made by hand before syncing began: same folder and name, no id.
|
|
// Updated in place, password kept, and it takes the id.
|
|
let store = FakeStore::with(&[(
|
|
"tenant-a",
|
|
"fw01",
|
|
entry(json!({"type": "ssh", "hostname": "10.0.0.1", "password": "typed-in"})),
|
|
)]);
|
|
let out = upsert_entry(
|
|
&store,
|
|
"shared",
|
|
"tenant-a",
|
|
"fw01",
|
|
fw01_webhook("10.0.0.1"),
|
|
true,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(out.moved_from, None);
|
|
let e = store.stored("tenant-a", "fw01").unwrap();
|
|
assert_eq!(e.password.as_deref(), Some("typed-in"));
|
|
assert_eq!(e.external_id.as_deref(), Some("netbox:device:1"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn move_upsert_does_not_adopt_when_the_id_is_already_elsewhere() {
|
|
// Device 1 moves to tenant B, where someone had made an fw01 by
|
|
// hand. Adopting it would leave device 1's id on two entries and
|
|
// every later sync of the device refused as ambiguous, so it is
|
|
// refused now with both entries as they were.
|
|
let store = FakeStore::with(&[
|
|
("tenant-a", "fw01", fw01_stored()),
|
|
(
|
|
"tenant-b",
|
|
"fw01",
|
|
entry(json!({"type": "ssh", "hostname": "10.0.1.1", "password": "typed-in"})),
|
|
),
|
|
]);
|
|
let (status, msg) = rejected(
|
|
upsert_entry(
|
|
&store,
|
|
"shared",
|
|
"tenant-b",
|
|
"fw01",
|
|
fw01_webhook("10.0.1.9"),
|
|
true,
|
|
)
|
|
.await
|
|
.unwrap_err(),
|
|
);
|
|
assert_eq!(status, StatusCode::CONFLICT);
|
|
assert!(msg.contains("tenant-a/fw01"), "{msg}");
|
|
let b = store.stored("tenant-b", "fw01").unwrap();
|
|
assert_eq!(
|
|
b.external_id, None,
|
|
"the hand-made entry did not take the id"
|
|
);
|
|
assert_eq!(b.hostname.as_deref(), Some("10.0.1.1"));
|
|
assert_eq!(b.password.as_deref(), Some("typed-in"));
|
|
assert_eq!(
|
|
store
|
|
.stored("tenant-a", "fw01")
|
|
.unwrap()
|
|
.password
|
|
.as_deref(),
|
|
Some("$tenant_a_password")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn move_upsert_needs_an_external_id() {
|
|
// Without an id there is nothing to match on but the name, which is
|
|
// exactly what must not happen: refused, and nothing is touched.
|
|
let store = FakeStore::with(&[("tenant-a", "fw01", fw01_stored())]);
|
|
let payload = entry(json!({"type": "ssh", "hostname": "10.0.0.1"}));
|
|
let (status, msg) = rejected(
|
|
upsert_entry(&store, "shared", "tenant-b", "fw01", payload, true)
|
|
.await
|
|
.unwrap_err(),
|
|
);
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
assert!(msg.contains("external_id"), "{msg}");
|
|
assert_eq!(store.keys(), ["tenant-a/fw01"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn move_upsert_refuses_a_name_held_by_another_device() {
|
|
// Device 1 moves to tenant B, where a different device is already
|
|
// fw01. Neither entry is overwritten or removed.
|
|
let store = FakeStore::with(&[
|
|
("tenant-a", "fw01", fw01_stored()),
|
|
(
|
|
"tenant-b",
|
|
"fw01",
|
|
entry(
|
|
json!({"type": "ssh", "hostname": "10.0.1.1", "password": "b-secret",
|
|
"external_id": "netbox:device:2"}),
|
|
),
|
|
),
|
|
]);
|
|
let (status, _) = rejected(
|
|
upsert_entry(
|
|
&store,
|
|
"shared",
|
|
"tenant-b",
|
|
"fw01",
|
|
fw01_webhook("10.0.1.9"),
|
|
true,
|
|
)
|
|
.await
|
|
.unwrap_err(),
|
|
);
|
|
assert_eq!(status, StatusCode::CONFLICT);
|
|
assert_eq!(store.keys(), ["tenant-a/fw01", "tenant-b/fw01"]);
|
|
let b = store.stored("tenant-b", "fw01").unwrap();
|
|
assert_eq!(b.password.as_deref(), Some("b-secret"));
|
|
assert_eq!(b.hostname.as_deref(), Some("10.0.1.1"));
|
|
assert_eq!(
|
|
store
|
|
.stored("tenant-a", "fw01")
|
|
.unwrap()
|
|
.password
|
|
.as_deref(),
|
|
Some("$tenant_a_password")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn move_upsert_refuses_an_ambiguous_external_id() {
|
|
// The same id on two entries is a mess to sort out by hand, not
|
|
// something to guess at.
|
|
let store = FakeStore::with(&[
|
|
("tenant-a", "fw01", fw01_stored()),
|
|
("tenant-c", "fw01-old", fw01_stored()),
|
|
]);
|
|
let (status, msg) = rejected(
|
|
upsert_entry(
|
|
&store,
|
|
"shared",
|
|
"tenant-b",
|
|
"fw01",
|
|
fw01_webhook("10.0.1.9"),
|
|
true,
|
|
)
|
|
.await
|
|
.unwrap_err(),
|
|
);
|
|
assert_eq!(status, StatusCode::CONFLICT);
|
|
assert!(
|
|
msg.contains("tenant-a/fw01") && msg.contains("tenant-c/fw01-old"),
|
|
"{msg}"
|
|
);
|
|
assert_eq!(store.keys(), ["tenant-a/fw01", "tenant-c/fw01-old"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn move_upsert_reports_a_move_it_could_not_finish() {
|
|
// Copied to the target but the original could not be removed: the
|
|
// entry is in both folders, the update is not applied, and the
|
|
// caller is told so rather than given an error it would retry.
|
|
let mut store = FakeStore::with(&[("tenant-a", "fw01", fw01_stored())]);
|
|
store.fail_delete = true;
|
|
match upsert_entry(
|
|
&store,
|
|
"shared",
|
|
"tenant-b",
|
|
"fw01",
|
|
fw01_webhook("10.0.1.9"),
|
|
true,
|
|
)
|
|
.await
|
|
.unwrap_err()
|
|
{
|
|
UpsertError::MoveIncomplete { from: at, .. } => {
|
|
assert_eq!(at, from("tenant-a", "fw01"))
|
|
}
|
|
other => panic!("expected MoveIncomplete, got {other:?}"),
|
|
}
|
|
assert_eq!(store.keys(), ["tenant-a/fw01", "tenant-b/fw01"]);
|
|
assert_eq!(
|
|
store
|
|
.stored("tenant-b", "fw01")
|
|
.unwrap()
|
|
.hostname
|
|
.as_deref(),
|
|
Some("10.0.0.1"),
|
|
"the copy is the stored entry, not the update"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plain_upsert_never_moves() {
|
|
// Without ?move=true the same payload is the plain name upsert it
|
|
// always was: created here, the entry elsewhere left alone.
|
|
let store = FakeStore::with(&[("tenant-a", "fw01", fw01_stored())]);
|
|
let out = upsert_entry(
|
|
&store,
|
|
"shared",
|
|
"tenant-b",
|
|
"fw01",
|
|
fw01_webhook("10.0.1.9"),
|
|
false,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(out.moved_from, None);
|
|
assert_eq!(store.keys(), ["tenant-a/fw01", "tenant-b/fw01"]);
|
|
assert_eq!(
|
|
store.stored("tenant-b", "fw01").unwrap().password,
|
|
None,
|
|
"a plain create has only what the payload carried"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn report_bounds_read_a_bare_date_as_a_whole_local_day() {
|
|
// Sydney in September: UTC+10, so getTimezoneOffset() is -600.
|
|
let tz = -600;
|
|
assert_eq!(
|
|
normalize_report_bound("2026-09-04", tz, false).unwrap(),
|
|
"2026-09-03 14:00:00"
|
|
);
|
|
assert_eq!(
|
|
normalize_report_bound("2026-09-04", tz, true).unwrap(),
|
|
"2026-09-04 13:59:59"
|
|
);
|
|
// No offset given (the scripted case) means UTC, unchanged behaviour.
|
|
assert_eq!(
|
|
normalize_report_bound("2026-09-04", 0, false).unwrap(),
|
|
"2026-09-04 00:00:00"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn report_bounds_convert_zoned_values_and_reject_junk() {
|
|
// A value carrying its own zone is converted from it, offset ignored.
|
|
assert_eq!(
|
|
normalize_report_bound("2026-09-04T02:14:07Z", -600, false).unwrap(),
|
|
"2026-09-04 02:14:07"
|
|
);
|
|
assert_eq!(
|
|
normalize_report_bound("2026-09-04T12:14:07+10:00", 0, false).unwrap(),
|
|
"2026-09-04 02:14:07"
|
|
);
|
|
// Zoneless datetimes are shifted like a bare date.
|
|
assert_eq!(
|
|
normalize_report_bound("2026-09-04T12:14:07", -600, false).unwrap(),
|
|
"2026-09-04 02:14:07"
|
|
);
|
|
assert!(normalize_report_bound("last tuesday", 0, false).is_err());
|
|
assert!(normalize_report_bound("", 0, false).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn report_bounds_clamp_a_nonsense_offset() {
|
|
let q = ReportQuery {
|
|
user: None,
|
|
entry: None,
|
|
session_type: None,
|
|
from: Some("2026-09-04".into()),
|
|
to: None,
|
|
tz_offset: Some(999_999),
|
|
limit: None,
|
|
offset: None,
|
|
};
|
|
// Clamped to +840, not applied verbatim.
|
|
assert_eq!(report_bounds(&q).unwrap().0.unwrap(), "2026-09-04 14:00:00");
|
|
}
|
|
|
|
#[test]
|
|
fn mark_utc_tags_zoneless_sqlite_timestamps() {
|
|
assert_eq!(mark_utc("2026-09-04 02:14:07"), "2026-09-04T02:14:07Z");
|
|
// Already zoned, or not the SQLite shape at all: left alone.
|
|
assert_eq!(mark_utc("2026-09-04T02:14:07Z"), "2026-09-04T02:14:07Z");
|
|
assert_eq!(
|
|
mark_utc("2026-09-04T02:14:07+00:00"),
|
|
"2026-09-04T02:14:07+00:00"
|
|
);
|
|
assert_eq!(mark_utc(""), "");
|
|
}
|
|
|
|
// ── Regression: credential-variable gathering must reach a folder the user
|
|
// can access even when it is nested under an inaccessible ancestor
|
|
// (operator "No credential variables" bug). ──
|
|
struct FakeFolders {
|
|
access: std::collections::HashSet<(String, String)>,
|
|
subs: std::collections::HashMap<(String, String), Vec<String>>,
|
|
vars: std::collections::HashMap<(String, String), Vec<String>>,
|
|
}
|
|
impl FolderSource for FakeFolders {
|
|
async fn accessible(&self, scope: &str, path: &str) -> bool {
|
|
self.access.contains(&(scope.to_string(), path.to_string()))
|
|
}
|
|
async fn subfolders(&self, scope: &str, path: &str) -> Vec<String> {
|
|
self.subs
|
|
.get(&(scope.to_string(), path.to_string()))
|
|
.cloned()
|
|
.unwrap_or_default()
|
|
}
|
|
async fn folder_var_names(&self, scope: &str, path: &str) -> Vec<String> {
|
|
self.vars
|
|
.get(&(scope.to_string(), path.to_string()))
|
|
.cloned()
|
|
.unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
fn sp(a: &str, b: &str) -> (String, String) {
|
|
(a.to_string(), b.to_string())
|
|
}
|
|
|
|
// ── Folder default credentials: the one resolver both connect paths use
|
|
// (the Connections endpoint and /api/connect quick links). ──
|
|
struct FakeDefaultsSource {
|
|
defaults: std::collections::HashMap<String, crate::vault::FolderDefaults>,
|
|
inherit: std::collections::HashSet<String>,
|
|
}
|
|
impl DefaultsSource for FakeDefaultsSource {
|
|
async fn folder_defaults(
|
|
&self,
|
|
_scope: &str,
|
|
folder: &str,
|
|
) -> Result<Option<crate::vault::FolderDefaults>, VaultError> {
|
|
Ok(self.defaults.get(folder).cloned())
|
|
}
|
|
async fn inherits_from_parent(
|
|
&self,
|
|
_scope: &str,
|
|
folder: &str,
|
|
) -> Result<bool, VaultError> {
|
|
Ok(self.inherit.contains(folder))
|
|
}
|
|
}
|
|
|
|
fn defaults_with_ssh_key(key: &str) -> crate::vault::FolderDefaults {
|
|
let mut d = crate::vault::FolderDefaults::new();
|
|
d.insert(
|
|
"ssh".into(),
|
|
crate::vault::DefaultCredentials {
|
|
username: Some("root".into()),
|
|
private_key: Some(key.into()),
|
|
..Default::default()
|
|
},
|
|
);
|
|
d
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn lend_folder_defaults_fills_a_bare_entry_and_respects_the_boundary() {
|
|
let src = FakeDefaultsSource {
|
|
defaults: [("corp".to_string(), defaults_with_ssh_key("corp-key"))]
|
|
.into_iter()
|
|
.collect(),
|
|
inherit: ["corp/linux"].into_iter().map(String::from).collect(),
|
|
};
|
|
let bare = || AddressBookEntry {
|
|
session_type: "ssh".into(),
|
|
hostname: Some("10.0.0.1".into()),
|
|
..Default::default()
|
|
};
|
|
|
|
// a subfolder that inherits gets corp's key and username
|
|
let e = lend_folder_defaults(&src, "shared", "corp/linux", "box", bare()).await;
|
|
assert_eq!(e.private_key.as_deref(), Some("corp-key"));
|
|
assert_eq!(e.username.as_deref(), Some("root"));
|
|
|
|
// an isolated subfolder (inherit_from_parent: false) does not
|
|
let e = lend_folder_defaults(&src, "shared", "corp/guests", "box", bare()).await;
|
|
assert!(e.private_key.is_none() && e.username.is_none());
|
|
|
|
// the entry's own secret and username win
|
|
let own = AddressBookEntry {
|
|
password: Some("own".into()),
|
|
username: Some("admin".into()),
|
|
..bare()
|
|
};
|
|
let e = lend_folder_defaults(&src, "shared", "corp/linux", "box", own).await;
|
|
assert_eq!(e.password.as_deref(), Some("own"));
|
|
assert_eq!(e.username.as_deref(), Some("admin"));
|
|
assert!(e.private_key.is_none());
|
|
|
|
// an entry set to prompt is left to prompt
|
|
let prompting = AddressBookEntry {
|
|
prompt_credentials: Some(true),
|
|
..bare()
|
|
};
|
|
let e = lend_folder_defaults(&src, "shared", "corp/linux", "box", prompting).await;
|
|
assert!(e.private_key.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn lend_folder_defaults_leaves_the_entry_alone_when_vault_fails() {
|
|
struct Down;
|
|
impl DefaultsSource for Down {
|
|
async fn folder_defaults(
|
|
&self,
|
|
_s: &str,
|
|
_f: &str,
|
|
) -> Result<Option<crate::vault::FolderDefaults>, VaultError> {
|
|
Err(VaultError::Forbidden)
|
|
}
|
|
async fn inherits_from_parent(&self, _s: &str, _f: &str) -> Result<bool, VaultError> {
|
|
Err(VaultError::Forbidden)
|
|
}
|
|
}
|
|
let e = AddressBookEntry {
|
|
session_type: "ssh".into(),
|
|
username: Some("admin".into()),
|
|
..Default::default()
|
|
};
|
|
let out = lend_folder_defaults(&Down, "shared", "corp/linux", "box", e).await;
|
|
// a failed read must not change the entry
|
|
assert_eq!(out.username.as_deref(), Some("admin"));
|
|
assert!(out.password.is_none() && out.private_key.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn credential_vars_reach_accessible_folder_under_inaccessible_parent() {
|
|
// shared/corp is NOT accessible; shared/corp/jumpcloud IS (a subfolder
|
|
// grant). The operator can connect that entry via the tree, so its
|
|
// credential variables must surface in My Credentials. Before the fix
|
|
// the walk short-circuited at "corp" and returned nothing.
|
|
let fake = FakeFolders {
|
|
access: std::collections::HashSet::from([sp("shared", "corp/jumpcloud")]),
|
|
subs: std::collections::HashMap::from([(
|
|
sp("shared", "corp"),
|
|
vec!["corp/jumpcloud".to_string()],
|
|
)]),
|
|
vars: std::collections::HashMap::from([(
|
|
sp("shared", "corp/jumpcloud"),
|
|
vec![
|
|
"jumpcloud_username".to_string(),
|
|
"jumpcloud_password".to_string(),
|
|
],
|
|
)]),
|
|
};
|
|
// Seed only the top-level folder, exactly as list_all_folders does.
|
|
let got = gather_credential_variables(&fake, vec![sp("shared", "corp")]).await;
|
|
assert_eq!(got.get("jumpcloud_password").copied(), Some(1));
|
|
assert_eq!(got.get("jumpcloud_username").copied(), Some(1));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn credential_vars_not_collected_from_inaccessible_folders() {
|
|
// Descending past inaccessible folders must never surface THEIR
|
|
// variables — only pass through them to accessible descendants.
|
|
let fake = FakeFolders {
|
|
access: std::collections::HashSet::new(), // nothing accessible
|
|
subs: std::collections::HashMap::new(),
|
|
vars: std::collections::HashMap::from([(
|
|
sp("shared", "secret"),
|
|
vec!["secret_password".to_string()],
|
|
)]),
|
|
};
|
|
let got = gather_credential_variables(&fake, vec![sp("shared", "secret")]).await;
|
|
assert!(
|
|
got.is_empty(),
|
|
"must not collect variables from folders the user cannot access"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_html_escape_special_chars() {
|
|
assert_eq!(html_escape("<script>"), "<script>");
|
|
assert_eq!(html_escape("a&b"), "a&b");
|
|
assert_eq!(html_escape(r#"x"y"#), "x"y");
|
|
assert_eq!(html_escape("it's"), "it's");
|
|
}
|
|
|
|
#[test]
|
|
fn test_html_escape_passthrough() {
|
|
assert_eq!(html_escape("hello world"), "hello world");
|
|
assert_eq!(html_escape(""), "");
|
|
}
|
|
|
|
fn hm(pairs: &[(&str, &str)]) -> std::collections::HashMap<String, String> {
|
|
pairs
|
|
.iter()
|
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn test_partition_single_vault_writes_one_store() {
|
|
// Quick-start invariant: with split=false everything lands in the local
|
|
// map and shared stays empty, so the caller writes the one store once.
|
|
let existing = hm(&[("corp_user", "alice"), ("corp_password", "old")]);
|
|
let incoming = hm(&[("corp_password", "new")]);
|
|
let scopes = hm(&[("corp_password", "shared")]); // ignored when !split
|
|
let (local, shared) = partition_credential_writes(
|
|
existing,
|
|
std::collections::HashMap::new(),
|
|
&incoming,
|
|
&scopes,
|
|
"local",
|
|
false,
|
|
);
|
|
assert_eq!(local.get("corp_user").unwrap(), "alice"); // untouched kept
|
|
assert_eq!(local.get("corp_password").unwrap(), "new"); // updated
|
|
assert!(shared.is_empty(), "single store must not populate shared");
|
|
}
|
|
|
|
#[test]
|
|
fn test_partition_blank_keeps_existing() {
|
|
// Blank value = keep existing (the "leave blank to keep" secret path).
|
|
let existing = hm(&[("corp_password", "secret")]);
|
|
let incoming = hm(&[("corp_password", "")]);
|
|
let (local, _shared) = partition_credential_writes(
|
|
existing,
|
|
std::collections::HashMap::new(),
|
|
&incoming,
|
|
&std::collections::HashMap::new(),
|
|
"local",
|
|
false,
|
|
);
|
|
assert_eq!(local.get("corp_password").unwrap(), "secret");
|
|
}
|
|
|
|
#[test]
|
|
fn test_partition_split_routes_by_scope() {
|
|
let incoming = hm(&[("shared_pw", "s"), ("local_pw", "l")]);
|
|
let scopes = hm(&[("shared_pw", "shared"), ("local_pw", "local")]);
|
|
let (local, shared) = partition_credential_writes(
|
|
std::collections::HashMap::new(),
|
|
std::collections::HashMap::new(),
|
|
&incoming,
|
|
&scopes,
|
|
"local",
|
|
true,
|
|
);
|
|
assert_eq!(shared.get("shared_pw").unwrap(), "s");
|
|
assert!(!shared.contains_key("local_pw"));
|
|
assert_eq!(local.get("local_pw").unwrap(), "l");
|
|
assert!(!local.contains_key("shared_pw"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_partition_split_moves_between_scopes_keeping_value() {
|
|
// A key currently local, re-saved with scope=shared and a blank value,
|
|
// moves to shared carrying its existing value and leaves local.
|
|
let existing_local = hm(&[("corp_pw", "keepme")]);
|
|
let incoming = hm(&[("corp_pw", "")]); // blank = keep value
|
|
let scopes = hm(&[("corp_pw", "shared")]);
|
|
let (local, shared) = partition_credential_writes(
|
|
existing_local,
|
|
std::collections::HashMap::new(),
|
|
&incoming,
|
|
&scopes,
|
|
"local",
|
|
true,
|
|
);
|
|
assert!(!local.contains_key("corp_pw"), "must leave the local store");
|
|
assert_eq!(shared.get("corp_pw").unwrap(), "keepme");
|
|
}
|
|
|
|
#[test]
|
|
fn test_partition_split_default_scope_applies() {
|
|
// No explicit scope for the key → falls back to default_scope.
|
|
let incoming = hm(&[("corp_pw", "v")]);
|
|
let (local, shared) = partition_credential_writes(
|
|
std::collections::HashMap::new(),
|
|
std::collections::HashMap::new(),
|
|
&incoming,
|
|
&std::collections::HashMap::new(),
|
|
"shared",
|
|
true,
|
|
);
|
|
assert_eq!(shared.get("corp_pw").unwrap(), "v");
|
|
assert!(local.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_html_escape_multiple() {
|
|
assert_eq!(
|
|
html_escape(r#"<a href="x">&</a>"#),
|
|
"<a href="x">&</a>"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_safe_recording_name_valid() {
|
|
let dir = std::env::temp_dir().join("rustguac-test-recordings");
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
let f1 = dir.join("session-abc123.guac");
|
|
let f2 = dir.join("2024-01-01_recording.guac");
|
|
std::fs::write(&f1, b"").unwrap();
|
|
std::fs::write(&f2, b"").unwrap();
|
|
assert!(is_safe_recording_name("session-abc123.guac", &dir));
|
|
assert!(is_safe_recording_name("2024-01-01_recording.guac", &dir));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn test_safe_recording_name_no_guac_extension() {
|
|
assert!(!is_safe_recording_name("session.mp4", Path::new("/tmp")));
|
|
assert!(!is_safe_recording_name("session", Path::new("/tmp")));
|
|
}
|
|
|
|
#[test]
|
|
fn test_safe_recording_name_path_traversal() {
|
|
assert!(!is_safe_recording_name(
|
|
"../etc/passwd.guac",
|
|
Path::new("/tmp")
|
|
));
|
|
assert!(!is_safe_recording_name("foo/bar.guac", Path::new("/tmp")));
|
|
assert!(!is_safe_recording_name("foo\\bar.guac", Path::new("/tmp")));
|
|
assert!(!is_safe_recording_name(
|
|
"..%2F..%2Fetc.guac",
|
|
Path::new("/tmp")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_safe_recording_name_empty_and_bare_extension() {
|
|
// Empty string and just-the-extension should never pass.
|
|
assert!(!is_safe_recording_name("", Path::new("/tmp")));
|
|
assert!(!is_safe_recording_name(".guac", Path::new("/tmp")));
|
|
}
|
|
|
|
#[test]
|
|
fn test_safe_recording_name_nul_byte_rejected() {
|
|
// NUL in a filename can truncate the name at the syscall boundary on
|
|
// some platforms — must be rejected pre-canonicalize.
|
|
assert!(!is_safe_recording_name("session\0.guac", Path::new("/tmp")));
|
|
assert!(!is_safe_recording_name(
|
|
"sess.guac\0.txt",
|
|
Path::new("/tmp")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_safe_recording_name_hidden_traversal() {
|
|
// Double-dot must be rejected anywhere in the name, not just as a
|
|
// segment — covers `foo..bar.guac`, `.guac..evil.guac`, etc.
|
|
assert!(!is_safe_recording_name("foo..bar.guac", Path::new("/tmp")));
|
|
assert!(!is_safe_recording_name("..guac", Path::new("/tmp")));
|
|
}
|
|
|
|
#[test]
|
|
fn test_safe_recording_name_extension_case_sensitive() {
|
|
// Rustguac writes lower-case .guac only; allowing .GUAC creates a
|
|
// second naming space that bypasses admin tools expecting lowercase.
|
|
assert!(!is_safe_recording_name("session.GUAC", Path::new("/tmp")));
|
|
assert!(!is_safe_recording_name("session.Guac", Path::new("/tmp")));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_jpeg_magic_happy_path() {
|
|
assert!(is_jpeg_magic(&[0xFF, 0xD8, 0xFF]));
|
|
assert!(is_jpeg_magic(&[0xFF, 0xD8, 0xFF, 0xE0, 0x00]));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_jpeg_magic_rejects_short_body() {
|
|
assert!(!is_jpeg_magic(&[]));
|
|
assert!(!is_jpeg_magic(&[0xFF]));
|
|
assert!(!is_jpeg_magic(&[0xFF, 0xD8]));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_jpeg_magic_rejects_other_formats() {
|
|
// PNG (89 50 4E), GIF (47 49 46), PDF (25 50 44), HTML-ish (3C 21 44).
|
|
assert!(!is_jpeg_magic(b"\x89PNG\r\n\x1a\n"));
|
|
assert!(!is_jpeg_magic(b"GIF89a"));
|
|
assert!(!is_jpeg_magic(b"%PDF-1.4"));
|
|
assert!(!is_jpeg_magic(b"<!DOCTYPE html>"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_jpeg_magic_off_by_one_byte() {
|
|
// One-byte variations must not slip through.
|
|
assert!(!is_jpeg_magic(&[0xFE, 0xD8, 0xFF]));
|
|
assert!(!is_jpeg_magic(&[0xFF, 0xD7, 0xFF]));
|
|
assert!(!is_jpeg_magic(&[0xFF, 0xD8, 0xFE]));
|
|
}
|
|
|
|
#[test]
|
|
fn session_owner_is_trimmed() {
|
|
assert_eq!(
|
|
validate_session_owner(" alice@example.com "),
|
|
Ok("alice@example.com".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn session_owner_rejects_empty_and_junk() {
|
|
assert!(validate_session_owner("").is_err());
|
|
assert!(validate_session_owner(" ").is_err());
|
|
assert!(validate_session_owner("alice @example.com").is_err());
|
|
assert!(validate_session_owner("alice\u{7}@example.com").is_err());
|
|
assert!(validate_session_owner(&"a".repeat(321)).is_err());
|
|
}
|
|
|
|
/// #232: moving and copying entries carries working credentials, so it
|
|
/// stays admin-only like every other entry change.
|
|
#[test]
|
|
fn entry_move_and_copy_are_admin_only() {
|
|
let user = |role: &str| {
|
|
Some(Extension(AuthIdentity::User {
|
|
email: "u@example.com".into(),
|
|
role: role.into(),
|
|
groups: vec![],
|
|
}))
|
|
};
|
|
for role in ["viewer", "operator", "poweruser"] {
|
|
let (status, _) = super::require_ab_admin(&user(role)).unwrap_err();
|
|
assert_eq!(status, StatusCode::FORBIDDEN, "{role}");
|
|
}
|
|
assert!(super::require_ab_admin(&None).is_err());
|
|
assert_eq!(
|
|
super::require_ab_admin(&user("admin")).unwrap(),
|
|
"u@example.com"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn vault_name_errors_are_client_errors() {
|
|
let (status, _) = super::ab_vault_error(VaultError::BadName("reserved name".into()));
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
let (status, _) = super::ab_vault_error(VaultError::NotFound);
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[test]
|
|
fn rename_candidates_skip_policy_is_just_the_name() {
|
|
assert_eq!(super::rename_candidates("web01", false), vec!["web01"]);
|
|
}
|
|
|
|
#[test]
|
|
fn rename_candidates_suffix_and_stay_within_name_limit() {
|
|
let c = super::rename_candidates("web01", true);
|
|
assert_eq!(c[0], "web01");
|
|
assert_eq!(c[1], "web01-2");
|
|
assert_eq!(c.last().unwrap(), "web01-99");
|
|
let long = "a".repeat(64);
|
|
for cand in super::rename_candidates(&long, true) {
|
|
assert!(cand.len() <= 64, "{cand}");
|
|
}
|
|
assert_eq!(
|
|
super::rename_candidates(&long, true)[1],
|
|
format!("{}-2", "a".repeat(62))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bulk_request_defaults_to_skip_and_rejects_unknown_actions() {
|
|
let req: super::BulkRequest = serde_json::from_value(json!({
|
|
"action": "move",
|
|
"items": [{"scope": "shared", "folder": "a", "name": "x"}],
|
|
"target": {"scope": "shared", "folder": "b"},
|
|
}))
|
|
.unwrap();
|
|
assert_eq!(req.action, super::BulkAction::Move);
|
|
assert_eq!(req.on_conflict, super::OnConflict::Skip);
|
|
assert!(serde_json::from_value::<super::BulkRequest>(json!({
|
|
"action": "chmod",
|
|
"items": [],
|
|
}))
|
|
.is_err());
|
|
assert!(
|
|
serde_json::from_value::<super::BulkRequest>(json!({
|
|
"action": "copy",
|
|
"items": [],
|
|
"on_conflict": "overwrite",
|
|
}))
|
|
.is_err(),
|
|
"there is no overwrite policy"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn allowed_users_are_tidied() {
|
|
let got = super::normalize_allowed_users(vec![
|
|
" jsmith@example.com ".into(),
|
|
"".into(),
|
|
"JSmith@Example.com".into(),
|
|
"sub-1234".into(),
|
|
])
|
|
.unwrap();
|
|
assert_eq!(got, vec!["jsmith@example.com", "sub-1234"]);
|
|
}
|
|
|
|
#[test]
|
|
fn allowed_users_reject_junk_and_cap_size() {
|
|
assert!(super::normalize_allowed_users(vec!["a b@example.com".into()]).is_err());
|
|
assert!(super::normalize_allowed_users(vec!["a\u{7}@example.com".into()]).is_err());
|
|
assert!(super::normalize_allowed_users(vec!["x".repeat(321)]).is_err());
|
|
let many: Vec<String> = (0..=super::MAX_ALLOWED_USERS)
|
|
.map(|i| format!("u{i}@example.com"))
|
|
.collect();
|
|
assert!(super::normalize_allowed_users(many).is_err());
|
|
}
|
|
}
|