From d226d0b0b3c0b35d312182dc86895080786a3b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20Janal=C3=ADk?= Date: Thu, 24 Sep 2026 11:21:29 +0200 Subject: [PATCH 1/3] feat(cli)!: log in to the CSCS Keycloak directly with one token for all sites manta-cli now obtains its bearer token itself over OIDC against the CSCS Keycloak instead of sending credentials to manta-server's /v2/auth/token. One token serves every site. - New common::oidc: browser auth-code + PKCE on a loopback redirect (ID token nonce + at_hash verified), device-code flow when headless (config `oidc_headless`, or inside an SSH session, or when no browser opens), and refresh-token exchange. openidconnect runs over manta-cli's reqwest 0.13 through a small AsyncHttpClient adapter. - Token lookup: MANTA_TOKEN env var -> /token.json (refreshed when expired) -> interactive login. No server round-trip; a token the site rejects surfaces as a 401 with a hint to re-login. - New cli.toml keys oidc_issuer_url / oidc_client_id (CSCS defaults; the client id and redirect port are placeholders until the Keycloak client is registered) and oidc_headless. - `config unset auth` deletes token.json; legacy _auth files are left untouched. BREAKING CHANGE: MANTA_CSM_TOKEN is renamed to MANTA_TOKEN, and the token cache moves from per-site _auth files to a single token.json. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MPaEy7U15cDibrbtL2Ei7J --- crates/manta-cli/Cargo.toml | 7 +- crates/manta-cli/src/common/app_context.rs | 8 + crates/manta-cli/src/common/authentication.rs | 577 ++++------- crates/manta-cli/src/common/config.rs | 46 + crates/manta-cli/src/common/mod.rs | 7 +- crates/manta-cli/src/common/oidc.rs | 919 ++++++++++++++++++ .../src/dispatch/config/unset_auth.rs | 71 +- crates/manta-cli/src/dispatch/process.rs | 4 +- crates/manta-cli/src/http_client/client.rs | 193 +--- crates/manta-cli/src/http_client/mod.rs | 4 +- crates/manta-cli/src/main.rs | 14 + crates/manta-shared/src/common/jwt_ops.rs | 32 + 12 files changed, 1263 insertions(+), 619 deletions(-) create mode 100644 crates/manta-cli/src/common/oidc.rs diff --git a/crates/manta-cli/Cargo.toml b/crates/manta-cli/Cargo.toml index 24911310..d411a0de 100644 --- a/crates/manta-cli/Cargo.toml +++ b/crates/manta-cli/Cargo.toml @@ -80,7 +80,7 @@ anyhow = { version = "1.0.44", default-features = false } # Cost of the duplication: a few MB of extra binary size and ~30s # extra cold compile. Revisit when (a) we do the sibling error # refactor, or (b) kube ecosystem catches up to reqwest 0.13. -reqwest = { version = "0.13.4", default-features = false, features = ["blocking", "json", "rustls", "socks", "stream"] } +reqwest = { version = "0.13.4", default-features = false, features = ["blocking", "form", "json", "rustls", "socks", "stream"] } serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" serde_yaml = "0.9.34" @@ -114,6 +114,11 @@ semver = "1.0" # Runtime support for the progenitor-generated API client. The generated # code in OUT_DIR (see build.rs) references types from this crate. progenitor-client = "0.14" +# OIDC login against the CSCS Keycloak (`common::oidc`). Default features +# off: they pull reqwest 0.12; requests go through manta-cli's reqwest +# 0.13 via a small `AsyncHttpClient` adapter instead. +openidconnect = { version = "4.0.1", default-features = false } +webbrowser = "1.0.6" # Server-only deps (axum, axum-server, utoipa*, tower-http, rustls, # rustls-pemfile) moved to crates/manta-server with the HTTP server itself. diff --git a/crates/manta-cli/src/common/app_context.rs b/crates/manta-cli/src/common/app_context.rs index a9e37040..4858e512 100644 --- a/crates/manta-cli/src/common/app_context.rs +++ b/crates/manta-cli/src/common/app_context.rs @@ -87,6 +87,9 @@ pub struct AppContext<'a> { /// backend-mutating verbs before any HTTP request leaves the /// process. `false` by default. pub read_only: bool, + /// Where and how the CLI logs in to obtain its bearer token — + /// `cli.toml`'s `oidc_*` keys with the CSCS defaults filled in. + pub oidc: crate::common::oidc::OidcSettings<'a>, /// Raw loaded `cli.toml` settings; held alongside the parsed /// `CliConfiguration` so handlers can read fields (e.g. `log`) /// that don't live on the typed struct. @@ -147,6 +150,11 @@ mod tests { sat_file_poll_budget_secs: None, sat_file_not_visible_budget_secs: None, read_only: false, + oidc: crate::common::oidc::OidcSettings { + issuer_url: "https://issuer.example", + client_id: "test-client", + headless: false, + }, settings, token: None, session: None, diff --git a/crates/manta-cli/src/common/authentication.rs b/crates/manta-cli/src/common/authentication.rs index ac8086c8..d023d5a3 100644 --- a/crates/manta-cli/src/common/authentication.rs +++ b/crates/manta-cli/src/common/authentication.rs @@ -1,451 +1,262 @@ -//! Token acquisition: env var -> cached file -> interactive Keycloak login. +//! Token acquisition: env var -> cached token -> interactive login. //! -//! Every path checks the candidate token against the configured -//! `manta-server` (via `MantaClient`), which in turn validates it -//! against the CSM/OCHAMI backend. The CLI never reaches a backend -//! directly. +//! The bearer token is issued by the CSCS Keycloak and obtained by the +//! CLI directly (see [`crate::common::oidc`]); `manta-server` takes no +//! part in authentication. One token serves every site, so nothing +//! here depends on the selected site. //! //! ## Resolution order //! -//! [`get_api_token`] walks the candidates in this order and returns -//! the first one that the server's `/v2/auth/validate` accepts: +//! [`get_api_token`] tries each source in turn and returns the first +//! token it gets: //! -//! 1. `MANTA_CSM_TOKEN` environment variable. -//! 2. Cached file at `/_auth` (0600-permissions). The -//! cache directory comes from -//! [`manta_shared::common::config::get_default_cache_path`]. -//! 3. Interactive Keycloak username + password prompt -//! (`dialoguer`-based), retried up to [`MAX_LOGIN_ATTEMPTS`] times -//! against `/v2/auth/token`. A successful interactive login is -//! written back to the cache file. +//! 1. `MANTA_TOKEN` environment variable, used as-is (for scripts). +//! 2. Cached token at `/token.json` (`0600`). The cache +//! directory comes from +//! [`manta_shared::common::config::get_default_cache_path`]. An +//! expired access token is refreshed with the cached refresh token +//! and written back. +//! 3. Interactive OIDC login ([`oidc::login`]: browser, or device code +//! when headless). The result is written to the cache. //! -//! ## Short-circuits +//! Tokens are not checked against the server up front. A token the +//! site rejects surfaces as a `401` on the first real request (see +//! [`crate::http_client::OpenApiResultExt`]). //! -//! Two failures abort the cascade immediately instead of falling -//! through to the next path (or re-prompting), because no other -//! credential source could possibly succeed: -//! -//! - **Unreachable server** (DNS / TCP / TLS) — surfaced by the -//! [`crate::http_client::AuthServerUnreachable`] typed context. -//! Trying the next path would hit the same dead endpoint. -//! - **Unknown site** — a `404` from `/auth/*`, surfaced by the -//! [`crate::http_client::SiteNotFound`] typed context. The server is -//! reachable but doesn't serve the configured `site`; no token can -//! authenticate against a site that doesn't exist, so the cascade -//! stops rather than prompting for credentials. -//! -//! Note: in the bare interactive case (no env var **and** no cache -//! file) the first server contact *is* the credential exchange, so -//! one prompt appears before the `404` can be seen; the short-circuit -//! then suppresses the remaining retries. Zero-prompt failure happens -//! only when an env-var or cached token gives the cascade something to -//! validate against the server *before* prompting. -//! -//! Non-interactive callers (`stdin` is not a TTY) also stop after the -//! cached-token attempt rather than blocking on a prompt that can -//! never be answered. +//! Non-interactive callers (`stdin` is not a TTY) stop after the cache +//! instead of starting a login nobody can complete. -use crate::common::app_context::AppContext; -use crate::http_client::{AuthServerUnreachable, MantaClient, SiteNotFound}; -use anyhow::{Result, anyhow}; -use crossterm::style::Stylize; -use dialoguer::{Input, Password}; +use std::io::{self, IsTerminal}; +use std::path::{Path, PathBuf}; + +use anyhow::{Result, bail}; use manta_shared::common::config::get_default_cache_path; -use std::{ - fs::{File, create_dir_all}, - io::{self, IsTerminal, Read, Write}, - os::unix::fs::OpenOptionsExt, -}; +use manta_shared::common::jwt_ops; -/// `true` if `err`'s typed-context chain contains -/// [`AuthServerUnreachable`] — the marker attached by -/// [`MantaClient::validate_token`] / [`MantaClient::exchange_credentials`] -/// whenever the auth call fails at the TCP / timeout layer. Used -/// by [`cascade_abort_reason`] to decide whether to abort the auth -/// cascade immediately instead of falling through or re-prompting. -/// -/// Uses `downcast_ref` rather than `chain().any(is::<...>())` -/// because anyhow stores contexts behind an internal wrapper type; -/// `downcast_ref` knows how to look through it, but the chain -/// iterator yields the wrapper's concrete type. -fn is_auth_server_unreachable(err: &anyhow::Error) -> bool { - err.downcast_ref::().is_some() -} +use crate::common::app_context::AppContext; +use crate::common::oidc::{self, OidcSettings, TokenStore}; -/// `true` if `err`'s typed-context chain contains [`SiteNotFound`] — -/// the marker attached by [`MantaClient::validate_token`] / -/// [`MantaClient::exchange_credentials`] on a `404` from `/auth/*` -/// (the server is reachable but doesn't serve the configured site). -/// Used by [`cascade_abort_reason`]; no credentials can authenticate -/// against a site that doesn't exist. -fn is_site_not_found(err: &anyhow::Error) -> bool { - err.downcast_ref::().is_some() -} +/// Environment variable holding a ready-made bearer token. +pub const AUTH_TOKEN_ENV_VAR: &str = "MANTA_TOKEN"; -/// Which error variant causes the auth cascade to stop immediately -/// instead of falling through to the next credential source or -/// re-prompting. Returned by [`cascade_abort_reason`]. -enum CascadeAbort { - /// The manta server was unreachable (DNS / TCP / TLS failure). - ServerUnreachable, - /// The server is up but doesn't serve the configured site (404). - SiteNotFound, -} +/// File name of the token cache inside the manta cache directory. +const TOKEN_CACHE_FILE: &str = "token.json"; -/// Return the cascade-abort reason if `err` contains an -/// [`AuthServerUnreachable`] or [`SiteNotFound`] typed marker; -/// `None` for a plain credential rejection (wrong password, etc.) -/// that should let the caller try the next source or re-prompt. -fn cascade_abort_reason(err: &anyhow::Error) -> Option { - if is_auth_server_unreachable(err) { - Some(CascadeAbort::ServerUnreachable) - } else if is_site_not_found(err) { - Some(CascadeAbort::SiteNotFound) - } else { - None - } +/// Path of the token cache file (`/token.json`). +/// +/// # Errors +/// +/// The platform cache directory cannot be resolved. +pub fn token_cache_path() -> Result { + Ok(get_default_cache_path()?.join(TOKEN_CACHE_FILE)) } -/// Environment variable name for the API authentication token. -const AUTH_TOKEN_ENV_VAR: &str = "MANTA_CSM_TOKEN"; - -/// Suffix appended to the site name to form the auth cache filename. -const AUTH_CACHE_FILE_SUFFIX: &str = "_auth"; - -/// Maximum number of interactive login attempts before giving up. -const MAX_LOGIN_ATTEMPTS: u32 = 3; - -/// Obtain a valid API token, trying in order: env var -/// `MANTA_CSM_TOKEN`, cached file, interactive login. Every candidate -/// is validated through `manta-server`. +/// Obtain a bearer token, trying in order: env var `MANTA_TOKEN`, +/// cached token (refreshed when expired), interactive login. /// -/// On a successful interactive login the token is written back to -/// `/_auth` with `0600` permissions so subsequent -/// invocations re-use it. +/// A successful refresh or login is written back to the cache file so +/// later invocations — against any site — reuse it. /// /// # Errors /// /// - No site is set (`ctx.require_site()` fails). -/// - The manta server is unreachable at any point — the cascade -/// aborts and surfaces an -/// [`crate::http_client::AuthServerUnreachable`]-wrapped error. -/// - All three candidates failed (no env var, no cached file or -/// stale cached token, and either the interactive retries hit -/// [`MAX_LOGIN_ATTEMPTS`] or stdin isn't a terminal). -/// - File I/O for the cache write fails after a successful login. -#[tracing::instrument(skip_all, fields(site = ctx.site_name.unwrap_or("")))] +/// - The cache directory cannot be resolved, or the cache cannot be +/// written after a login. +/// - No token is available and stdin is not a terminal. +/// - The interactive login fails. +#[tracing::instrument(skip_all)] pub async fn get_api_token(ctx: &AppContext<'_>) -> Result { // process_cli pre-resolves the token at the top of every // authenticated command and stashes it on AppContext. Handlers // continue to call this function unchanged; we just hand back the - // already-resolved token instead of re-walking the cache+env+IDP - // cascade. Verbs in process_cli's `verb_skips_session` list (no - // pre-resolved token) fall through to the full cascade below. + // already-resolved token instead of re-walking the sources. if let Some(t) = &ctx.token { return Ok(t.clone()); } - // Auth endpoints are the ones that *obtain* or *check* the token, - // so we pass `None` as the bearer here; no default `Authorization` - // header gets attached. - let site_name = ctx.require_site()?; - let client = MantaClient::new(ctx.manta_server_url, site_name)?; + // The token itself is site-independent, but every caller goes on to + // send it to a site — fail on a missing site before starting a login. + ctx.require_site()?; - tracing::info!( - server = %ctx.manta_server_url, - "Beginning authentication" - ); - - match get_token_from_env(&client).await { - Ok(token) => { - tracing::info!("Authentication successful using env var"); - return Ok(token); - } - Err(err) => { - // Short-circuit: an unreachable server or unknown site means no - // other credential source could succeed either, so abort the - // cascade immediately instead of falling through to the next path. - if cascade_abort_reason(&err).is_some() { - return Err(err); - } - tracing::warn!( - error = %err, - "env-var auth failed, trying cached token file" - ); - } + if let Some(token) = token_from_env(std::env::var(AUTH_TOKEN_ENV_VAR).ok()) { + tracing::info!("Using authentication token from env var"); + return Ok(token); } - match get_token_from_local_file(site_name, &client).await { - Ok(token) => { - tracing::info!("Authentication successful using local file"); + let cache_path = token_cache_path()?; + match token_from_cache(&ctx.oidc, &cache_path).await { + Ok(Some(token)) => { + tracing::info!("Using cached authentication token"); return Ok(token); } + Ok(None) => {} Err(err) => { - // Unknown site or unreachable server: bail before prompting — - // interactive login would hit the same dead endpoint or 404. - if cascade_abort_reason(&err).is_some() { - return Err(err); - } - let stdin = io::stdin(); - if !stdin.is_terminal() { - tracing::warn!( - error = %err, - "cached token rejected and stdin is not a terminal; giving up" - ); - return Err(err); - } - tracing::warn!( - error = %err, - "cached token rejected, prompting for credentials interactively" - ); + tracing::warn!(error = %err, "cached token unusable, logging in again"); } } - tracing::info!("Getting CSM authentication token interactively"); - let shasta_token = get_token_interactively(&client).await?; - - store_token_in_local_file(site_name, &shasta_token)?; - tracing::info!("Authentication successful using interactive login"); - Ok(shasta_token) -} - -async fn get_token_from_env(client: &MantaClient) -> Result { - let auth_token_env_name = AUTH_TOKEN_ENV_VAR; - - tracing::info!( - "Looking for authentication token in env var '{}'", - auth_token_env_name - ); - - let shasta_token = std::env::var(auth_token_env_name).map_err(|_| { - anyhow!("authentication token not found in env var '{auth_token_env_name}'") - })?; - - tracing::info!( - "Authentication token found in env var '{}'. Check if it is valid", - auth_token_env_name - ); - - client.validate_token(&shasta_token).await?; - Ok(shasta_token) -} - -async fn get_token_from_local_file( - site_name: &str, - client: &MantaClient, -) -> Result { - let mut path = get_default_cache_path()?; - - path.push(site_name.to_string() + AUTH_CACHE_FILE_SUFFIX); - - tracing::info!( - "Looking for authentication token in filesystem file '{}'", - path.display() - ); - - let mut shasta_token = String::new(); - File::open(&path) - .inspect_err(|e| { - tracing::debug!("Could not open token file '{}': {}", path.display(), e); - }) - .map_err(|_| { - anyhow!("authentication token not found at '{}'", path.display()) - })? - .read_to_string(&mut shasta_token)?; - - tracing::info!( - "Authentication token found in filesystem. Check if it is still valid", - ); + if !io::stdin().is_terminal() { + bail!( + "no valid cached token and stdin is not a terminal, so cannot log \ + in interactively. Run manta interactively once to log in, or set \ + {AUTH_TOKEN_ENV_VAR}." + ); + } - client.validate_token(&shasta_token).await?; - Ok(shasta_token) + tracing::info!(issuer = %ctx.oidc.issuer_url, "Logging in interactively"); + let store = oidc::login(&ctx.oidc).await?; + store.save(&cache_path)?; + tracing::info!(path = %cache_path.display(), "Authentication token cached on disk"); + Ok(store.access_token) } -fn store_token_in_local_file( - site_name: &str, - shasta_token: &str, -) -> Result<()> { - tracing::info!("Store authentication token in filesystem file"); - - let mut path = get_default_cache_path()?; - - create_dir_all(&path)?; - - path.push(site_name.to_string() + AUTH_CACHE_FILE_SUFFIX); - - tracing::info!("Cache file: {:?}", path); - - let mut file: File = File::options() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(&path)?; - file.write_all(shasta_token.as_bytes())?; - - tracing::info!(path = %path.display(), "Authentication token cached on disk"); - Ok(()) +/// Source 1: the token in `MANTA_TOKEN` (passed in as `value`). Used +/// as-is; an expired one only draws a warning, since the server will +/// reject it with a clear error anyway. +fn token_from_env(value: Option) -> Option { + let token = value.filter(|t| !t.trim().is_empty())?; + if let Ok(Some(exp)) = jwt_ops::get_expiration(&token) + && exp <= oidc::now_secs() + { + tracing::warn!("the token in {AUTH_TOKEN_ENV_VAR} has expired"); + } + Some(token) } -async fn get_token_interactively(client: &MantaClient) -> Result { - // Single attempt loop: prompt → try → return on success or cascade - // abort → log and continue on credential rejection. - // - // The do-while pattern (pre-loop prompt + identical prompt in the - // loop body) has been replaced by a plain for-loop so the prompt - // block appears exactly once. `attempt` is 0-indexed, so `attempt + - // 1` gives the correct 1-indexed attempt number in log messages - // without any off-by-one adjustment. - let mut last_err = anyhow!("no login attempt was made"); - - for attempt in 0..MAX_LOGIN_ATTEMPTS { - println!("Please type your {}", "Keycloak credentials".green()); - let username: String = - Input::new().with_prompt("username").interact_text()?; - let password = Password::new().with_prompt("password").interact()?; - - match client.exchange_credentials(&username, &password).await { - Ok(token) => { - if attempt > 0 { - tracing::info!( - attempt = attempt + 1, - "Interactive authentication succeeded after retries" - ); - } - return Ok(token); - } - Err(err) => match cascade_abort_reason(&err) { - Some(CascadeAbort::ServerUnreachable) => { - tracing::warn!( - error = %err, - "auth server unreachable; aborting interactive retries" - ); - return Err(err); - } - Some(CascadeAbort::SiteNotFound) => { - tracing::warn!( - error = %err, - "site not configured on server; aborting interactive retries" - ); - return Err(err); - } - None => { - tracing::warn!( - attempt = attempt + 1, - max_attempts = MAX_LOGIN_ATTEMPTS, - error = %err, - "Interactive authentication attempt failed" - ); - last_err = err; - } - }, +/// Source 2: the cached token at `path`. Returns the access token when +/// still valid; otherwise refreshes it (saving the result) when a +/// refresh token is cached. `Ok(None)` when there is no cache or it +/// cannot be refreshed — the caller then logs in interactively. +async fn token_from_cache( + settings: &OidcSettings<'_>, + path: &Path, +) -> Result> { + if !path.exists() { + return Ok(None); + } + let store = TokenStore::load(path)?; + if !store.is_expired() { + return Ok(Some(store.access_token)); + } + let Some(refresh_token) = &store.refresh_token else { + return Ok(None); + }; + match oidc::refresh(settings, refresh_token).await { + Ok(refreshed) => { + refreshed.save(path)?; + tracing::info!("Access token refreshed"); + Ok(Some(refreshed.access_token)) + } + Err(err) => { + tracing::warn!(error = %err, "token refresh failed"); + Ok(None) } } - - Err(last_err) } #[cfg(test)] mod tests { use super::*; - use std::os::unix::fs::PermissionsExt; - - #[test] - fn store_and_read_token_from_local_file() { - let tmp_dir = tempfile::tempdir().unwrap(); - - let site_name = "test_site"; - let token = "my-secret-token-12345"; - - let mut path = tmp_dir.path().to_path_buf(); - path.push(format!("{site_name}{AUTH_CACHE_FILE_SUFFIX}")); - - let mut file = File::options() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(&path) - .unwrap(); - file.write_all(token.as_bytes()).unwrap(); - - let mut content = String::new(); - File::open(&path) - .unwrap() - .read_to_string(&mut content) - .unwrap(); - assert_eq!(content, token); - let metadata = std::fs::metadata(&path).unwrap(); - let mode = metadata.permissions().mode() & 0o777; - assert_eq!(mode, 0o600, "Token file should have 600 permissions"); + fn settings() -> OidcSettings<'static> { + // Unroutable issuer: any attempt to reach the Keycloak fails fast, + // so a test that expects no network call would surface one. + OidcSettings { + issuer_url: "http://127.0.0.1:9", + client_id: "test-client", + headless: true, + } } - #[test] - fn store_token_overwrites_existing() { - let tmp_dir = tempfile::tempdir().unwrap(); - let mut path = tmp_dir.path().to_path_buf(); - path.push("overwrite_test_auth"); - - let mut file = File::options() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(&path) - .unwrap(); - file.write_all(b"old-token").unwrap(); - - let mut file = File::options() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(&path) - .unwrap(); - file.write_all(b"new-token").unwrap(); - - let mut content = String::new(); - File::open(&path) - .unwrap() - .read_to_string(&mut content) - .unwrap(); - assert_eq!(content, "new-token"); + fn store(expires_at: i64, refresh_token: Option<&str>) -> TokenStore { + TokenStore { + access_token: "cached-access".to_string(), + refresh_token: refresh_token.map(str::to_string), + id_token: None, + expires_at: Some(expires_at), + } } #[test] fn auth_token_env_var_name() { - assert_eq!(AUTH_TOKEN_ENV_VAR, "MANTA_CSM_TOKEN"); + assert_eq!(AUTH_TOKEN_ENV_VAR, "MANTA_TOKEN"); } #[test] - fn auth_cache_file_suffix_value() { - assert_eq!(AUTH_CACHE_FILE_SUFFIX, "_auth"); + fn env_token_is_used_when_set() { + assert_eq!( + token_from_env(Some("abc".to_string())).as_deref(), + Some("abc") + ); } #[test] - fn max_login_attempts_is_reasonable() { - const { assert!(MAX_LOGIN_ATTEMPTS >= 1 && MAX_LOGIN_ATTEMPTS <= 10) }; + fn env_token_absent_or_blank_is_skipped() { + assert!(token_from_env(None).is_none()); + assert!(token_from_env(Some(" ".to_string())).is_none()); } - #[test] - fn site_not_found_marker_is_detected_through_anyhow_context() { - // The cascade's bail-out points rely on `is_site_not_found` seeing - // the marker through anyhow's context wrapper — the same shape - // `MantaClient::map_auth_error` produces on a 404. - let err = anyhow!("HTTP 404").context(SiteNotFound { - site: "nonexistent".to_string(), - }); - assert!(is_site_not_found(&err)); - // Must not be confused with the unreachable-server short-circuit. - assert!(!is_auth_server_unreachable(&err)); + #[tokio::test] + async fn missing_cache_yields_none() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(TOKEN_CACHE_FILE); + assert!( + token_from_cache(&settings(), &path) + .await + .unwrap() + .is_none() + ); } - #[test] - fn plain_error_is_not_site_not_found() { - // A genuine credential rejection carries no marker, so the cascade - // keeps trying / re-prompts rather than bailing. - let err = anyhow!("invalid credentials"); - assert!(!is_site_not_found(&err)); + #[tokio::test] + async fn valid_cached_token_is_returned_without_refresh() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(TOKEN_CACHE_FILE); + store(oidc::now_secs() + 3600, Some("refresh")) + .save(&path) + .unwrap(); + assert_eq!( + token_from_cache(&settings(), &path) + .await + .unwrap() + .as_deref(), + Some("cached-access") + ); + } + + #[tokio::test] + async fn expired_cached_token_without_refresh_token_yields_none() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(TOKEN_CACHE_FILE); + store(0, None).save(&path).unwrap(); + assert!( + token_from_cache(&settings(), &path) + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn failed_refresh_yields_none_and_keeps_cache() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(TOKEN_CACHE_FILE); + let expired = store(0, Some("refresh")); + expired.save(&path).unwrap(); + assert!( + token_from_cache(&settings(), &path) + .await + .unwrap() + .is_none() + ); + assert_eq!(TokenStore::load(&path).unwrap(), expired); + } + + #[tokio::test] + async fn corrupt_cache_is_an_error() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(TOKEN_CACHE_FILE); + std::fs::write(&path, "not json").unwrap(); + assert!(token_from_cache(&settings(), &path).await.is_err()); } } diff --git a/crates/manta-cli/src/common/config.rs b/crates/manta-cli/src/common/config.rs index 4fd0af65..260e59f5 100644 --- a/crates/manta-cli/src/common/config.rs +++ b/crates/manta-cli/src/common/config.rs @@ -85,6 +85,19 @@ pub struct CliConfiguration { /// historical 5 min (300 s). #[serde(default)] pub sat_file_not_visible_budget_secs: Option, + /// Issuer URL of the Keycloak realm the CLI logs in to. `None` uses + /// the CSCS realm ([`crate::common::oidc::DEFAULT_OIDC_ISSUER_URL`]). + #[serde(default)] + pub oidc_issuer_url: Option, + /// OIDC client id the CLI logs in as. `None` uses + /// [`crate::common::oidc::DEFAULT_OIDC_CLIENT_ID`]. + #[serde(default)] + pub oidc_client_id: Option, + /// When `true`, log in with the device-code flow (print a URL and a + /// code) instead of opening a browser. SSH sessions use it + /// automatically. + #[serde(default)] + pub oidc_headless: bool, } #[cfg(test)] @@ -105,6 +118,9 @@ mod tests { sat_file_poll_interval_secs: None, sat_file_poll_budget_secs: None, sat_file_not_visible_budget_secs: None, + oidc_issuer_url: None, + oidc_client_id: None, + oidc_headless: false, }; let toml_str = toml::to_string(&cfg).unwrap(); let parsed: CliConfiguration = toml::from_str(&toml_str).unwrap(); @@ -182,4 +198,34 @@ mod tests { let cfg: CliConfiguration = toml::from_str(toml_str).unwrap(); assert!(!cfg.read_only); } + + #[test] + fn oidc_keys_default_when_absent() { + let toml_str = r#" + log = "info" + manta_server_url = "https://manta-server.cscs.ch:8443" + "#; + let cfg: CliConfiguration = toml::from_str(toml_str).unwrap(); + assert!(cfg.oidc_issuer_url.is_none()); + assert!(cfg.oidc_client_id.is_none()); + assert!(!cfg.oidc_headless); + } + + #[test] + fn oidc_keys_parse_when_present() { + let toml_str = r#" + log = "info" + manta_server_url = "https://manta-server.cscs.ch:8443" + oidc_issuer_url = "https://auth-tds.cscs.ch/auth/realms/cscs" + oidc_client_id = "manta-test" + oidc_headless = true + "#; + let cfg: CliConfiguration = toml::from_str(toml_str).unwrap(); + assert_eq!( + cfg.oidc_issuer_url.as_deref(), + Some("https://auth-tds.cscs.ch/auth/realms/cscs") + ); + assert_eq!(cfg.oidc_client_id.as_deref(), Some("manta-test")); + assert!(cfg.oidc_headless); + } } diff --git a/crates/manta-cli/src/common/mod.rs b/crates/manta-cli/src/common/mod.rs index 398d5a57..ea34e2bc 100644 --- a/crates/manta-cli/src/common/mod.rs +++ b/crates/manta-cli/src/common/mod.rs @@ -2,8 +2,10 @@ //! //! - [`app_context`] — per-invocation context (config, site override, //! global flags) threaded through every dispatch handler. -//! - [`authentication`] — token bootstrap, refresh, and keyring -//! persistence; talks to `manta-server`'s `/auth` endpoints. +//! - [`authentication`] — bearer-token resolution: `MANTA_TOKEN` env +//! var, cached token (refreshed when expired), interactive login. +//! - [`oidc`] — OIDC flows (browser + PKCE, device code, refresh) +//! against the CSCS Keycloak. //! - [`clap_ext`] — `ArgMatches` extension trait with type-safe //! accessors (`req_str`, `opt_str`, …) used by every handler. //! - [`config`] — typed schema for `cli.toml` @@ -24,5 +26,6 @@ pub mod config; pub mod confirm; pub mod hooks; pub mod multi_line; +pub mod oidc; pub mod read_only; pub mod session; diff --git a/crates/manta-cli/src/common/oidc.rs b/crates/manta-cli/src/common/oidc.rs new file mode 100644 index 00000000..ebf436a1 --- /dev/null +++ b/crates/manta-cli/src/common/oidc.rs @@ -0,0 +1,919 @@ +//! OIDC login against the CSCS Keycloak. +//! +//! manta-cli obtains its bearer token directly from the CSCS Keycloak — +//! `manta-server` takes no part in authentication and simply forwards +//! the token to the site backends. One token serves every site. +//! +//! Three flows, all against the public client [`OidcSettings::client_id`]: +//! +//! - **Browser** ([`login`], default): authorization code + PKCE (S256), +//! redirected to a loopback listener on +//! `http://localhost:`[`REDIRECT_PORT`]. The ID token's nonce and +//! `at_hash` are verified against the realm's JWKS. +//! - **Device code** ([`login`] when headless): prints a verification +//! URL + user code and polls the token endpoint. Used when +//! `oidc_headless = true`, inside an SSH session, or when no browser +//! can be opened. +//! - **Refresh** ([`refresh`]): exchanges a cached refresh token for a +//! new access token without user interaction. +//! +//! The resulting [`TokenStore`] is cached as JSON by +//! [`crate::common::authentication`]. + +use std::path::Path; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, anyhow, bail}; +use openidconnect::core::{CoreClient, CoreProviderMetadata, CoreResponseType}; +use openidconnect::{ + AccessTokenHash, AuthenticationFlow, AuthorizationCode, ClientId, CsrfToken, + HttpRequest, HttpResponse, IssuerUrl, Nonce, OAuth2TokenResponse, + PkceCodeChallenge, RedirectUrl, Scope, TokenResponse as _, +}; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpListener; + +/// Default issuer URL of the CSCS Keycloak realm. Overridable with +/// `oidc_issuer_url` in `cli.toml`. +pub const DEFAULT_OIDC_ISSUER_URL: &str = + "https://auth.cscs.ch/auth/realms/cscs"; + +/// Default OIDC client id. Overridable with `oidc_client_id` in +/// `cli.toml`. +/// +/// PLACEHOLDER: the manta client is not registered in the CSCS realm +/// yet. Replace with the real client id once it exists. +pub const DEFAULT_OIDC_CLIENT_ID: &str = "PLACEHOLDER-manta-cli"; + +/// Loopback port the browser flow listens on. Must match the redirect +/// URI registered on the Keycloak client (`http://localhost:`). +/// +/// PLACEHOLDER: confirm against the client registration once it exists. +pub const REDIRECT_PORT: u16 = 8765; + +/// How long the browser flow waits for the redirect before giving up. +const BROWSER_LOGIN_TIMEOUT: Duration = Duration::from_secs(300); + +/// Per-request timeout for every call to the Keycloak. +const HTTP_TIMEOUT: Duration = Duration::from_secs(30); + +/// A cached token is treated as expired this long before its real +/// expiry, so it does not lapse between the check and its use. +const EXPIRY_GRACE_SECS: i64 = 10; + +/// Page shown in the browser once the redirect has been received. +const SUCCESS_HTML: &str = "manta\ +

Authentication successful

\ +

You can close this window and return to the terminal.

"; + +/// Where and how to authenticate. Built from `cli.toml` (with the +/// defaults above) by [`crate::common::authentication`]. +#[derive(Debug, Clone, Copy)] +pub struct OidcSettings<'a> { + /// Keycloak realm issuer URL (discovery lives under + /// `/.well-known/openid-configuration`). + pub issuer_url: &'a str, + /// Public client id registered for manta in the realm. + pub client_id: &'a str, + /// Force the device-code flow instead of opening a browser. + pub headless: bool, +} + +/// Tokens returned by the Keycloak, persisted in the token cache file. +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct TokenStore { + /// Bearer token sent to manta-server. + pub access_token: String, + /// Refresh token, used by [`refresh`] once the access token expires. + pub refresh_token: Option, + /// ID token from the login; kept for completeness, not sent anywhere. + pub id_token: Option, + /// Access-token expiry as seconds since the Unix epoch. `None` means + /// unknown, which [`TokenStore::is_expired`] treats as expired. + pub expires_at: Option, +} + +impl std::fmt::Debug for TokenStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TokenStore") + .field("access_token", &"") + .field( + "refresh_token", + &self.refresh_token.as_ref().map(|_| ""), + ) + .field("id_token", &self.id_token.as_ref().map(|_| "")) + .field("expires_at", &self.expires_at) + .finish() + } +} + +impl TokenStore { + /// `true` once the access token is within [`EXPIRY_GRACE_SECS`] of + /// its expiry (or its expiry is unknown). + pub fn is_expired(&self) -> bool { + self.is_expired_at(now_secs()) + } + + fn is_expired_at(&self, now: i64) -> bool { + self + .expires_at + .is_none_or(|exp| now + EXPIRY_GRACE_SECS >= exp) + } + + /// Read a token store from `path`. + /// + /// # Errors + /// + /// The file cannot be read or is not a valid token store. + pub fn load(path: &Path) -> Result { + let content = std::fs::read_to_string(path).with_context(|| { + format!("could not read token cache '{}'", path.display()) + })?; + serde_json::from_str(&content) + .with_context(|| format!("token cache '{}' is corrupt", path.display())) + } + + /// Write the token store to `path` with `0600` permissions, creating + /// the parent directory (`0700`) when missing. + /// + /// # Errors + /// + /// The directory or file cannot be created or written. + pub fn save(&self, path: &Path) -> Result<()> { + use std::io::Write; + use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; + + if let Some(dir) = path.parent() { + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(dir) + .with_context(|| format!("could not create '{}'", dir.display()))?; + } + let json = serde_json::to_string_pretty(self)?; + let mut file = std::fs::File::options() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .with_context(|| { + format!("could not write token cache '{}'", path.display()) + })?; + file.write_all(json.as_bytes())?; + Ok(()) + } + + fn from_token_response(resp: RawTokenResponse) -> Self { + Self { + access_token: resp.access_token, + refresh_token: resp.refresh_token, + id_token: resp.id_token, + expires_at: resp.expires_in.map(|s| now_secs() + s), + } + } +} + +/// Interactive login: the browser flow, or the device-code flow when +/// headless (see the module docs for when that applies). A browser +/// that cannot be opened also falls back to the device-code flow. +/// +/// # Errors +/// +/// Discovery or any Keycloak call fails, the user denies access, or +/// the login times out. +pub async fn login(settings: &OidcSettings<'_>) -> Result { + if settings.headless || in_ssh_session() { + tracing::debug!("headless login: using the device-code flow"); + return login_via_device_code(settings).await; + } + match login_via_browser(settings).await { + Err(e) if e.downcast_ref::().is_some() => { + tracing::debug!("{e}; falling back to the device-code flow"); + login_via_device_code(settings).await + } + other => other, + } +} + +/// Exchange `refresh_token` for a fresh token set. +/// +/// # Errors +/// +/// Discovery fails, or the Keycloak rejects the refresh token (it +/// expired, was revoked, or the session ended). +pub async fn refresh( + settings: &OidcSettings<'_>, + refresh_token: &str, +) -> Result { + let http = http_client()?; + let discovery = discover(&http, settings.issuer_url).await?; + let resp = http + .post(&discovery.token_endpoint) + .form(&[ + ("grant_type", "refresh_token"), + ("client_id", settings.client_id), + ("refresh_token", refresh_token), + ]) + .send() + .await + .context("failed to send refresh-token request")?; + let status = resp.status(); + let body = resp.bytes().await?; + if !status.is_success() { + bail!("token refresh rejected: {}", oauth_error_message(&body)); + } + let token: RawTokenResponse = serde_json::from_slice(&body) + .context("failed to parse refresh-token response")?; + let mut store = TokenStore::from_token_response(token); + // Keycloak may omit the refresh token when it is not rotated; keep + // the one we have. + if store.refresh_token.is_none() { + store.refresh_token = Some(refresh_token.to_string()); + } + Ok(store) +} + +/// The browser could not be opened; [`login`] falls back to the +/// device-code flow. +#[derive(Debug)] +struct BrowserUnavailable(String); + +impl std::fmt::Display for BrowserUnavailable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "could not open a browser: {}", self.0) + } +} + +impl std::error::Error for BrowserUnavailable {} + +async fn login_via_browser(settings: &OidcSettings<'_>) -> Result { + let http = http_client()?; + let oidc_http = OidcHttp(http.clone()); + let issuer_url = IssuerUrl::new(settings.issuer_url.to_string()) + .context("invalid oidc_issuer_url")?; + let provider_metadata = + CoreProviderMetadata::discover_async(issuer_url, &oidc_http) + .await + .context("OIDC discovery failed")?; + let client = CoreClient::from_provider_metadata( + provider_metadata, + ClientId::new(settings.client_id.to_string()), + None, + ) + .set_redirect_uri(RedirectUrl::new(format!( + "http://localhost:{REDIRECT_PORT}" + ))?); + + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + let (auth_url, csrf_token, nonce) = client + .authorize_url( + AuthenticationFlow::::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ) + .add_scope(Scope::new("openid".to_string())) + .set_pkce_challenge(pkce_challenge) + .url(); + + // Bind before opening the browser so the redirect cannot race us. + let listener = TcpListener::bind(("127.0.0.1", REDIRECT_PORT)) + .await + .with_context(|| { + format!( + "could not listen on localhost:{REDIRECT_PORT} for the login redirect" + ) + })?; + + webbrowser::open(auth_url.as_str()) + .map_err(|e| BrowserUnavailable(e.to_string()))?; + eprintln!( + "Opened a browser to log in to the CSCS Keycloak. If it did not appear, open:\n {auth_url}" + ); + + let code = tokio::time::timeout( + BROWSER_LOGIN_TIMEOUT, + wait_for_redirect(&listener, csrf_token.secret()), + ) + .await + .map_err(|_| anyhow!("timed out waiting for the browser login"))??; + + let token_response = client + .exchange_code(AuthorizationCode::new(code))? + .set_pkce_verifier(pkce_verifier) + .request_async(&oidc_http) + .await + .context("authorization-code exchange failed")?; + + // Nonce check (replay protection) and access-token hash check (the + // access token was not substituted for another one). + let id_token = token_response + .id_token() + .ok_or_else(|| anyhow!("Keycloak did not return an ID token"))?; + let verifier = client.id_token_verifier(); + let claims = id_token + .claims(&verifier, &nonce) + .context("ID token verification failed")?; + if let Some(expected) = claims.access_token_hash() { + let actual = AccessTokenHash::from_token( + token_response.access_token(), + id_token.signing_alg()?, + id_token.signing_key(&verifier)?, + )?; + if actual != *expected { + bail!("access token does not match the ID token's at_hash"); + } + } + + Ok(TokenStore { + access_token: token_response.access_token().secret().clone(), + refresh_token: token_response.refresh_token().map(|t| t.secret().clone()), + id_token: Some(id_token.to_string()), + expires_at: token_response + .expires_in() + .map(|d| now_secs() + i64::try_from(d.as_secs()).unwrap_or(i64::MAX / 2)), + }) +} + +/// Accept connections on `listener` until one carries the OIDC +/// redirect, answer it, and return the authorization code. Other +/// requests (e.g. a browser's `/favicon.ico`) get a `404`. +async fn wait_for_redirect( + listener: &TcpListener, + expected_state: &str, +) -> Result { + loop { + let (mut stream, _) = listener.accept().await?; + let mut request_line = String::new(); + BufReader::new(&mut stream) + .read_line(&mut request_line) + .await?; + + let outcome = parse_redirect(&request_line, expected_state); + let (status, body) = match &outcome { + Some(Ok(_)) => ("200 OK", SUCCESS_HTML.to_string()), + Some(Err(e)) => { + ("400 Bad Request", format!("Authentication failed: {e}")) + } + None => ("404 Not Found", String::new()), + }; + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + // Best effort: the code is already in hand even if the reply fails. + let _ = stream.write_all(response.as_bytes()).await; + + if let Some(result) = outcome { + return result; + } + } +} + +/// Parse the request line of a redirect (`GET /?state=..&code=.. HTTP/1.1`). +/// `None` when it is not the redirect at all; otherwise the code, or +/// the error the Keycloak or a state mismatch produced. +fn parse_redirect( + request_line: &str, + expected_state: &str, +) -> Option> { + let target = request_line.split_whitespace().nth(1)?; + let url = + openidconnect::url::Url::parse(&format!("http://localhost{target}")) + .ok()?; + let param = |name: &str| { + url + .query_pairs() + .find(|(k, _)| k == name) + .map(|(_, v)| v.into_owned()) + }; + + if let Some(error) = param("error") { + let description = param("error_description").unwrap_or(error); + return Some(Err(anyhow!("Keycloak returned an error: {description}"))); + } + let state = param("state")?; + if state != expected_state { + return Some(Err(anyhow!("state mismatch in the login redirect"))); + } + Some(param("code").ok_or_else(|| anyhow!("login redirect carried no code"))) +} + +async fn login_via_device_code( + settings: &OidcSettings<'_>, +) -> Result { + let http = http_client()?; + let discovery = discover(&http, settings.issuer_url).await?; + let device_endpoint = + discovery.device_authorization_endpoint.ok_or_else(|| { + anyhow!( + "the Keycloak at {} does not offer the device authorization flow", + settings.issuer_url + ) + })?; + + let resp = http + .post(&device_endpoint) + .form(&[("client_id", settings.client_id), ("scope", "openid")]) + .send() + .await + .context("failed to request a device code")?; + let status = resp.status(); + let body = resp.bytes().await?; + if !status.is_success() { + bail!( + "device authorization rejected: {}", + oauth_error_message(&body) + ); + } + let device: DeviceAuthResponse = serde_json::from_slice(&body) + .context("failed to parse the device authorization response")?; + + eprintln!(); + if let Some(uri) = &device.verification_uri_complete { + eprintln!("To log in to the CSCS Keycloak, open this URL in a browser:"); + eprintln!(" {uri}"); + } else { + eprintln!("To log in to the CSCS Keycloak, open this URL in a browser:"); + eprintln!(" {}", device.verification_uri); + eprintln!("and enter the code: {}", device.user_code); + } + eprintln!(); + eprintln!("Waiting for authentication..."); + + let mut interval = Duration::from_secs(device.interval.unwrap_or(5)); + let deadline = + tokio::time::Instant::now() + Duration::from_secs(device.expires_in); + + loop { + tokio::time::sleep(interval).await; + if tokio::time::Instant::now() > deadline { + bail!("device code expired before the login completed"); + } + + let resp = http + .post(&discovery.token_endpoint) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ("client_id", settings.client_id), + ("device_code", device.device_code.as_str()), + ]) + .send() + .await + .context("failed to poll the token endpoint")?; + let status = resp.status(); + let body = resp.bytes().await?; + + if status.is_success() { + let token: RawTokenResponse = serde_json::from_slice(&body) + .context("failed to parse the token response")?; + eprintln!("Authentication successful."); + return Ok(TokenStore::from_token_response(token)); + } + + match device_poll_step(&body)? { + PollStep::Continue => {} + PollStep::SlowDown => interval += Duration::from_secs(5), + } + } +} + +/// What to do after a non-success poll of the token endpoint. +#[derive(Debug, PartialEq, Eq)] +enum PollStep { + Continue, + SlowDown, +} + +/// Classify a device-flow token-endpoint error body (RFC 8628 §3.5). +fn device_poll_step(body: &[u8]) -> Result { + let err: OAuthError = serde_json::from_slice(body).with_context(|| { + format!( + "unexpected token endpoint response: {}", + String::from_utf8_lossy(body) + ) + })?; + match err.error.as_str() { + "authorization_pending" => Ok(PollStep::Continue), + "slow_down" => Ok(PollStep::SlowDown), + "expired_token" => bail!("device code expired; please try again"), + "access_denied" => bail!("login was denied"), + _ => bail!( + "authentication error: {}", + err.error_description.unwrap_or(err.error) + ), + } +} + +/// `true` inside an SSH session, where a browser on the remote host +/// could not reach the user anyway. +fn in_ssh_session() -> bool { + std::env::var_os("SSH_CONNECTION").is_some() + || std::env::var_os("SSH_TTY").is_some() +} + +pub(crate) fn now_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX / 2)) +} + +fn http_client() -> Result { + reqwest::Client::builder() + // OIDC endpoints never need redirects; following one could leak + // a code or token to an unexpected host. + .redirect(reqwest::redirect::Policy::none()) + .timeout(HTTP_TIMEOUT) + .build() + .context("failed to build the Keycloak HTTP client") +} + +/// The endpoints manta needs from the realm's discovery document. +#[derive(Deserialize, Debug)] +struct Discovery { + token_endpoint: String, + device_authorization_endpoint: Option, +} + +async fn discover( + http: &reqwest::Client, + issuer_url: &str, +) -> Result { + let url = format!( + "{}/.well-known/openid-configuration", + issuer_url.trim_end_matches('/') + ); + http + .get(&url) + .send() + .await + .and_then(reqwest::Response::error_for_status) + .with_context(|| { + format!("failed to fetch OIDC discovery document '{url}'") + })? + .json() + .await + .context("failed to parse the OIDC discovery document") +} + +#[derive(Deserialize, Debug)] +struct RawTokenResponse { + access_token: String, + refresh_token: Option, + id_token: Option, + expires_in: Option, +} + +#[derive(Deserialize, Debug)] +struct DeviceAuthResponse { + device_code: String, + user_code: String, + verification_uri: String, + verification_uri_complete: Option, + expires_in: u64, + interval: Option, +} + +#[derive(Deserialize, Debug)] +struct OAuthError { + error: String, + error_description: Option, +} + +/// Best-effort human message out of an OAuth error body. +fn oauth_error_message(body: &[u8]) -> String { + serde_json::from_slice::(body).map_or_else( + |_| String::from_utf8_lossy(body).into_owned(), + |e| e.error_description.unwrap_or(e.error), + ) +} + +/// Adapter letting `openidconnect` issue requests through the CLI's +/// reqwest 0.13 (openidconnect's own reqwest integration is built on +/// 0.12). +struct OidcHttp(reqwest::Client); + +impl<'c> openidconnect::AsyncHttpClient<'c> for OidcHttp { + type Error = std::io::Error; + type Future = std::pin::Pin< + Box> + Send + 'c>, + >; + + fn call(&'c self, request: HttpRequest) -> Self::Future { + Box::pin(async move { + let (parts, body) = request.into_parts(); + let resp = self + .0 + .request(parts.method, parts.uri.to_string()) + .headers(parts.headers) + .body(body) + .send() + .await + .map_err(std::io::Error::other)?; + let status = resp.status(); + let headers = resp.headers().clone(); + let body = resp.bytes().await.map_err(std::io::Error::other)?; + let mut response = HttpResponse::new(body.to_vec()); + *response.status_mut() = status; + *response.headers_mut() = headers; + Ok(response) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + fn store(expires_at: Option) -> TokenStore { + TokenStore { + access_token: "access".to_string(), + refresh_token: Some("refresh".to_string()), + id_token: None, + expires_at, + } + } + + #[test] + fn expiry_honours_grace_period() { + let now = 1_000_000; + assert!(!store(Some(now + EXPIRY_GRACE_SECS + 1)).is_expired_at(now)); + assert!(store(Some(now + EXPIRY_GRACE_SECS)).is_expired_at(now)); + assert!(store(Some(now - 1)).is_expired_at(now)); + } + + #[test] + fn unknown_expiry_counts_as_expired() { + assert!(store(None).is_expired_at(0)); + } + + #[test] + fn save_then_load_roundtrips_with_private_permissions() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested").join("token.json"); + let original = store(Some(42)); + + original.save(&path).unwrap(); + assert_eq!(TokenStore::load(&path).unwrap(), original); + + let file_mode = + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(file_mode, 0o600); + let dir_mode = std::fs::metadata(path.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(dir_mode, 0o700); + } + + #[test] + fn save_overwrites_existing_cache() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("token.json"); + store(Some(1)).save(&path).unwrap(); + store(Some(2)).save(&path).unwrap(); + assert_eq!(TokenStore::load(&path).unwrap().expires_at, Some(2)); + } + + #[test] + fn debug_output_redacts_tokens() { + let rendered = format!("{:?}", store(Some(1))); + assert!(!rendered.contains("access\""), "got: {rendered}"); + assert!(!rendered.contains("\"refresh\""), "got: {rendered}"); + assert!(rendered.contains("")); + } + + #[test] + fn redirect_with_matching_state_yields_code() { + let line = "GET /?state=abc&session_state=x&code=the-code HTTP/1.1\r\n"; + assert_eq!(parse_redirect(line, "abc").unwrap().unwrap(), "the-code"); + } + + #[test] + fn redirect_with_wrong_state_is_rejected() { + let line = "GET /?state=evil&code=the-code HTTP/1.1\r\n"; + let err = parse_redirect(line, "abc").unwrap().unwrap_err(); + assert!(err.to_string().contains("state mismatch")); + } + + #[test] + fn redirect_error_is_surfaced() { + let line = "GET /?error=access_denied&error_description=User%20said%20no HTTP/1.1\r\n"; + let err = parse_redirect(line, "abc").unwrap().unwrap_err(); + assert!(err.to_string().contains("User said no"), "got: {err}"); + } + + #[test] + fn unrelated_request_is_not_the_redirect() { + assert!(parse_redirect("GET /favicon.ico HTTP/1.1\r\n", "abc").is_none()); + } + + #[test] + fn device_poll_steps() { + assert_eq!( + device_poll_step(br#"{"error":"authorization_pending"}"#).unwrap(), + PollStep::Continue + ); + assert_eq!( + device_poll_step(br#"{"error":"slow_down"}"#).unwrap(), + PollStep::SlowDown + ); + for (body, needle) in [ + (&br#"{"error":"expired_token"}"#[..], "expired"), + (&br#"{"error":"access_denied"}"#[..], "denied"), + ( + &br#"{"error":"invalid_client","error_description":"bad client"}"#[..], + "bad client", + ), + ] { + let err = device_poll_step(body).unwrap_err().to_string(); + assert!(err.contains(needle), "got: {err}"); + } + } + + // ---- flows against a stub Keycloak ---- + + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::io::AsyncReadExt; + + /// Minimal Keycloak stand-in on an ephemeral port. `handler` gets + /// `(base_url, path, request_body)` and returns `(status, json)`. + /// Returns the base URL, which doubles as the issuer URL. + async fn stub_keycloak(handler: F) -> String + where + F: Fn(&str, &str, &str) -> (u16, String) + Send + Sync + 'static, + { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let handler = Arc::new(handler); + let served_base = base.clone(); + tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + let handler = Arc::clone(&handler); + let base = served_base.clone(); + tokio::spawn(async move { + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + let path = line.split_whitespace().nth(1).unwrap_or("").to_string(); + let mut len = 0; + loop { + let mut header = String::new(); + reader.read_line(&mut header).await.unwrap(); + if header == "\r\n" || header.is_empty() { + break; + } + if let Some(v) = + header.to_ascii_lowercase().strip_prefix("content-length:") + { + len = v.trim().parse().unwrap(); + } + } + let mut body = vec![0; len]; + reader.read_exact(&mut body).await.unwrap(); + let (status, json) = + handler(&base, &path, &String::from_utf8_lossy(&body)); + let out = format!( + "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{json}", + json.len() + ); + reader.into_inner().write_all(out.as_bytes()).await.unwrap(); + }); + } + }); + base + } + + fn discovery_json(base: &str, with_device: bool) -> String { + let device = if with_device { + format!(r#","device_authorization_endpoint":"{base}/device""#) + } else { + String::new() + }; + format!(r#"{{"token_endpoint":"{base}/token"{device}}}"#) + } + + fn stub_settings(issuer_url: &str) -> OidcSettings<'_> { + OidcSettings { + issuer_url, + client_id: "test-client", + headless: true, + } + } + + #[tokio::test] + async fn refresh_exchanges_the_refresh_token() { + let issuer = stub_keycloak(|base, path, body| match path { + "/.well-known/openid-configuration" => (200, discovery_json(base, false)), + "/token" + if body.contains("grant_type=refresh_token") + && body.contains("refresh_token=old-refresh") + && body.contains("client_id=test-client") => + { + ( + 200, + r#"{"access_token":"new-access","refresh_token":"new-refresh","expires_in":300}"# + .to_string(), + ) + } + _ => (404, "{}".to_string()), + }) + .await; + + let store = refresh(&stub_settings(&issuer), "old-refresh") + .await + .unwrap(); + assert_eq!(store.access_token, "new-access"); + assert_eq!(store.refresh_token.as_deref(), Some("new-refresh")); + assert!(!store.is_expired()); + } + + #[tokio::test] + async fn refresh_keeps_refresh_token_when_not_rotated() { + let issuer = stub_keycloak(|base, path, _| match path { + "/.well-known/openid-configuration" => (200, discovery_json(base, false)), + "/token" => ( + 200, + r#"{"access_token":"new-access","expires_in":300}"#.to_string(), + ), + _ => (404, "{}".to_string()), + }) + .await; + + let store = refresh(&stub_settings(&issuer), "old-refresh") + .await + .unwrap(); + assert_eq!(store.refresh_token.as_deref(), Some("old-refresh")); + } + + #[tokio::test] + async fn refresh_rejection_is_an_error() { + let issuer = stub_keycloak(|base, path, _| match path { + "/.well-known/openid-configuration" => (200, discovery_json(base, false)), + "/token" => ( + 400, + r#"{"error":"invalid_grant","error_description":"Session not active"}"# + .to_string(), + ), + _ => (404, "{}".to_string()), + }) + .await; + + let err = refresh(&stub_settings(&issuer), "old-refresh") + .await + .unwrap_err() + .to_string(); + assert!(err.contains("Session not active"), "got: {err}"); + } + + #[tokio::test] + async fn headless_login_polls_device_flow_until_authorized() { + let polls = Arc::new(AtomicUsize::new(0)); + let seen = Arc::clone(&polls); + let issuer = stub_keycloak(move |base, path, body| match path { + "/.well-known/openid-configuration" => (200, discovery_json(base, true)), + "/device" => ( + 200, + r#"{"device_code":"dev-code","user_code":"ABCD-EFGH","verification_uri":"https://kc/device","expires_in":60,"interval":0}"# + .to_string(), + ), + "/token" if body.contains("device_code=dev-code") => { + if seen.fetch_add(1, Ordering::SeqCst) == 0 { + (400, r#"{"error":"authorization_pending"}"#.to_string()) + } else { + ( + 200, + r#"{"access_token":"device-access","refresh_token":"device-refresh","expires_in":300}"# + .to_string(), + ) + } + } + _ => (404, "{}".to_string()), + }) + .await; + + let store = login(&stub_settings(&issuer)).await.unwrap(); + assert_eq!(store.access_token, "device-access"); + assert_eq!(store.refresh_token.as_deref(), Some("device-refresh")); + assert_eq!(polls.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn device_flow_without_device_endpoint_is_an_error() { + let issuer = stub_keycloak(|base, path, _| match path { + "/.well-known/openid-configuration" => (200, discovery_json(base, false)), + _ => (404, "{}".to_string()), + }) + .await; + + let err = login(&stub_settings(&issuer)) + .await + .unwrap_err() + .to_string(); + assert!(err.contains("device authorization"), "got: {err}"); + } +} diff --git a/crates/manta-cli/src/dispatch/config/unset_auth.rs b/crates/manta-cli/src/dispatch/config/unset_auth.rs index d65e3b78..e65a16a5 100644 --- a/crates/manta-cli/src/dispatch/config/unset_auth.rs +++ b/crates/manta-cli/src/dispatch/config/unset_auth.rs @@ -1,76 +1,37 @@ //! Implements the `manta config unset auth` command. //! -//! Lists the cached per-site token files under the manta cache -//! directory, prompts the user to pick one, and deletes it. The next -//! invocation that targets that site will re-run the device-code login -//! flow. - -use std::fs; +//! Deletes the cached token file (`/token.json`). The next +//! command that needs a token runs the interactive login again. +//! +//! Legacy per-site `_auth` files from older manta versions are +//! left alone: nothing reads them any more, and other manta versions +//! may still use them. use anyhow::{Context, Error}; -use dialoguer::Select; +use crate::common::authentication::token_cache_path; use crate::output::action_result; -use manta_shared::common::config::get_default_cache_path; -/// Remove cached authentication credentials. -/// -/// Interactive: prompts via `dialoguer::Select`; not suitable for -/// non-TTY contexts. +/// Remove the cached authentication token. /// /// # Errors /// -/// Returns an error if the cache directory cannot be read, no cached -/// tokens are present, the interactive prompt fails, or the file -/// cannot be removed. +/// Returns an error if the cache directory cannot be resolved, no +/// cached token is present, or the file cannot be removed. pub fn exec() -> Result<(), Error> { - unset_auth() -} - -fn unset_auth() -> Result<(), Error> { - let mut auth_token_list: Vec = vec![]; + let path = token_cache_path()?; - let path_to_manta_authentication_token_file = get_default_cache_path()?; - - for entry in fs::read_dir(&path_to_manta_authentication_token_file) - .context("Failed to read authentication token directory")? - { - auth_token_list.push(entry.context("Failed to read entry")?.path()); - } - - if auth_token_list.is_empty() { - anyhow::bail!("No cached authentication tokens found"); + if !path.exists() { + anyhow::bail!("No cached authentication token found"); } - let selection = Select::new() - .with_prompt("Please choose the site token to delete from the list below") - .default(0) - .items( - auth_token_list - .iter() - .map(|path| { - path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - }) - .collect::>(), - ) - .interact() - .context("Failed to get user selection")?; + std::fs::remove_file(&path) + .with_context(|| format!("Failed to delete '{}'", path.display()))?; action_result::print( - &format!( - "Deleting authentication file: {}", - auth_token_list[selection] - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - ), + &format!("Deleted cached authentication token: {}", path.display()), None, )?; - fs::remove_file(auth_token_list[selection].clone())?; - Ok(()) } diff --git a/crates/manta-cli/src/dispatch/process.rs b/crates/manta-cli/src/dispatch/process.rs index 3d58efec..d278f29b 100644 --- a/crates/manta-cli/src/dispatch/process.rs +++ b/crates/manta-cli/src/dispatch/process.rs @@ -7,8 +7,8 @@ //! has `read_only = true`. Runs before any token acquisition so no //! HTTP call leaves the process on the refusal path. //! 2. (Authenticated verbs only) Token cascade -//! ([`get_api_token`]) — one round-trip if the cache is hot; -//! interactive prompt if cold. +//! ([`get_api_token`]) — no round-trip if the cache is hot; +//! interactive OIDC login if cold. //! 3. (Authenticated verbs only) [`SessionContext::build`] — one //! `GET /groups/available` round-trip plus the JWT claims; cached //! on `AppContext` for the duration of the command. diff --git a/crates/manta-cli/src/http_client/client.rs b/crates/manta-cli/src/http_client/client.rs index 23d35510..2162ea6e 100644 --- a/crates/manta-cli/src/http_client/client.rs +++ b/crates/manta-cli/src/http_client/client.rs @@ -11,15 +11,12 @@ //! (with default `Authorization` header set in the constructor). //! //! Bearer auth is wired once in the constructor via -//! `reqwest::ClientBuilder::default_headers`; the only call sites that -//! pass `None` are `common::authentication` (which is how we *obtain* -//! or *validate* the token in the first place). +//! `reqwest::ClientBuilder::default_headers`. use anyhow::Context; use reqwest::header::{HeaderMap, HeaderValue}; use super::wire::format_request_as_curl; -use crate::openapi_client::types::{AuthTokenRequest, ValidateTokenRequest}; /// Default per-request timeout applied to one-shot REST calls through /// the progenitor-generated `openapi` client when `cli.toml` does not @@ -30,60 +27,6 @@ use crate::openapi_client::types::{AuthTokenRequest, ValidateTokenRequest}; /// — see [`MantaClient::new_with_timeout`]. pub const DEFAULT_API_TIMEOUT_SECS: u64 = 300; -/// Marker error attached as anyhow context whenever an `/auth/*` -/// HTTP call fails at the TCP/timeout layer (i.e., the manta server -/// — and therefore the auth path through it — is unreachable, not -/// "wrong credentials"). Lets [`crate::common::authentication`] tell -/// the two cases apart so an unreachable server short-circuits -/// instead of triggering the re-prompt loop. -#[derive(Debug)] -pub struct AuthServerUnreachable { - /// The base manta server URL (without `/v2`) that was tried. - /// Surfaced in the error message and recoverable by the loop via - /// `downcast_ref` if a caller needs to log it separately. - pub url: String, -} - -impl std::fmt::Display for AuthServerUnreachable { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "cannot reach manta server at {} for authentication. \ - Is the server running, and is `manta_server_url` in your \ - config correct?", - self.url, - ) - } -} - -impl std::error::Error for AuthServerUnreachable {} - -/// Marker error attached as anyhow context whenever an `/auth/*` HTTP -/// call returns `404 Not Found` — the manta server is reachable but the -/// `site` in your config isn't one it serves. Lets -/// [`crate::common::authentication`] short-circuit the -/// env → file → interactive-prompt cascade instead of asking for -/// credentials that can never succeed against a site that doesn't exist. -#[derive(Debug)] -pub struct SiteNotFound { - /// The `X-Manta-Site` value the server rejected. Surfaced in the - /// error message and recoverable via `downcast_ref`. - pub site: String, -} - -impl std::fmt::Display for SiteNotFound { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "site '{}' is not configured on the manta server. \ - Check the `site` value in your `cli.toml`.", - self.site, - ) - } -} - -impl std::error::Error for SiteNotFound {} - /// Convert a `Result, Error>` from the /// progenitor-generated client into an `anyhow::Result`. /// @@ -212,6 +155,13 @@ fn categorise_transport_error(rqe: &reqwest::Error) -> String { /// known timeout shape. Only the timeout-related cases are rewritten; /// everything else passes through as-is. fn categorise_server_error(status: u16, body: &str) -> String { + if status == 401 { + return format!( + "the token was rejected: it has expired, or this site does not \ + accept tokens from the CSCS Keycloak. Run `manta config unset \ + auth` to force a new login. Original body: {body}" + ); + } if status == 408 { return format!( "manta-server per-route request timeout fired. \ @@ -251,6 +201,14 @@ mod into_anyhow_tests { assert!(msg.contains("Request Timeout")); } + #[test] + fn categorise_server_401_explains_token_rejection() { + let msg = categorise_server_error(401, "invalid token"); + assert!(msg.contains("token was rejected")); + assert!(msg.contains("manta config unset auth")); + assert!(msg.contains("invalid token")); + } + #[test] fn categorise_server_500_with_operation_timed_out_explains_csm_hop() { let msg = categorise_server_error( @@ -347,30 +305,12 @@ pub struct MantaClient { } impl MantaClient { - /// Build a client pointing at `server_url` for the given `site_name`. - /// One-shot REST calls get a 5-minute timeout - /// ([`DEFAULT_API_TIMEOUT_SECS`]); streams (SSE / WebSockets) get no - /// timeout. To override either, use - /// [`MantaClient::new_with_timeout`]. - /// - /// If `server_url` has no scheme, `http://` is prepended. This lets users - /// write `manta_server_url = "localhost:8080"` in their config without - /// triggering a "URL scheme is not allowed" error from reqwest. - /// - /// # Errors - /// - /// Propagates failures from [`MantaClient::new_with_timeout`]. - pub fn new(server_url: &str, site_name: &str) -> anyhow::Result { - Self::new_with_timeout(server_url, site_name, None, None) - } - /// Build a client from an `AppContext`, honouring its /// `request_timeout_secs` (loaded from `cli.toml`). /// /// `token` is wired as a default `Authorization: Bearer ` header /// on both the `openapi` client and the `raw` `reqwest::Client` - /// when `Some`. Pass `None` only for the auth path that *obtains* - /// the token (`common::authentication`). + /// when `Some`. /// /// # Errors /// @@ -412,7 +352,9 @@ impl MantaClient { /// `request_timeout_secs` applies here too, which will truncate /// long streams; pick a value larger than your worst-case session. /// - /// URL scheme normalisation matches [`MantaClient::new`]. + /// If `server_url` has no scheme, `http://` is prepended. This lets users + /// write `manta_server_url = "localhost:8080"` in their config without + /// triggering a "URL scheme is not allowed" error from reqwest. /// /// # Errors /// @@ -482,101 +424,6 @@ impl MantaClient { &self.base_url } - /// Strip `/v2` from `base_url` to get the server root URL used - /// in auth-error messages. Both [`Self::validate_token`] and - /// [`Self::exchange_credentials`] perform the same trim — centralised - /// here instead of duplicated at each call site. - fn auth_base_url(&self) -> String { - self.base_url.trim_end_matches("/v2").to_string() - } - - /// Wrap a progenitor `Error` from an `/auth/*` call into an - /// `anyhow::Error`, attaching a typed marker that lets the caller - /// tell "keep trying" failures apart from "stop now" ones: - /// - /// - [`SiteNotFound`] on a `404` — server is reachable but doesn't - /// serve this site; no credential can fix that. - /// - [`AuthServerUnreachable`] on a TCP / timeout-layer failure — - /// the manta server itself is unreachable. - /// - /// Anything else is wrapped plain (a genuine credential rejection, - /// which *should* fall through to the next attempt / re-prompt). - fn map_auth_error( - &self, - err: progenitor_client::Error, - ) -> anyhow::Error - where - progenitor_client::Error: std::fmt::Display, - { - // A reachable server that doesn't serve this site answers 404. - // Tag either ErrorResponse or UnexpectedResponse 404s so the - // cascade short-circuits. - let status = match &err { - progenitor_client::Error::ErrorResponse(rv) => Some(rv.status()), - progenitor_client::Error::UnexpectedResponse(resp) => Some(resp.status()), - _ => None, - }; - if status == Some(reqwest::StatusCode::NOT_FOUND) { - return anyhow::anyhow!("{err}").context(SiteNotFound { - site: self.site_name.clone(), - }); - } - let unreachable = matches!( - &err, - progenitor_client::Error::CommunicationError(e) if e.is_connect() || e.is_timeout() - ); - let message = format!("{err}"); - if unreachable { - anyhow::anyhow!(message).context(AuthServerUnreachable { - url: self.auth_base_url(), - }) - } else { - anyhow::anyhow!(message) - } - } - - /// `POST /v2/auth/validate` — check whether the backend still - /// accepts `token`. Returns `Ok(())` on success; on the "abort - /// cascade" cases returns `Err` with [`AuthServerUnreachable`] or - /// [`SiteNotFound`] context so callers can distinguish them from - /// a plain credential rejection. - pub(crate) async fn validate_token(&self, token: &str) -> anyhow::Result<()> { - self - .openapi - .auth_validate( - self.site_name(), - &ValidateTokenRequest { - token: token.to_owned(), - }, - ) - .await - .map(|_| ()) - .map_err(|e| self.map_auth_error(e)) - } - - /// `POST /v2/auth/token` — exchange Keycloak credentials for a - /// CSM bearer token. Returns `Err` with [`AuthServerUnreachable`] - /// or [`SiteNotFound`] context on cascade-abort cases; plain `Err` - /// on a credential rejection (wrong username/password). - pub(crate) async fn exchange_credentials( - &self, - username: &str, - password: &str, - ) -> anyhow::Result { - let resp = self - .openapi - .auth_token( - self.site_name(), - &AuthTokenRequest { - username: username.to_owned(), - password: password.to_owned(), - }, - ) - .await - .map_err(|e| self.map_auth_error(e))?; - Ok(resp.into_inner().token) - } - /// Emit a `curl` equivalent of `builder` at DEBUG level so an operator /// can replay the request from their shell. Skipped entirely when /// DEBUG is filtered out, so the clone/build/serialize cost is only diff --git a/crates/manta-cli/src/http_client/mod.rs b/crates/manta-cli/src/http_client/mod.rs index d1bc1ffc..683f543a 100644 --- a/crates/manta-cli/src/http_client/mod.rs +++ b/crates/manta-cli/src/http_client/mod.rs @@ -39,9 +39,7 @@ mod console; mod streaming; mod wire; -pub use client::{ - AuthServerUnreachable, MantaClient, OpenApiResultExt, SiteNotFound, -}; +pub use client::{MantaClient, OpenApiResultExt}; pub(super) use wire::ws_base_url; #[cfg(test)] diff --git a/crates/manta-cli/src/main.rs b/crates/manta-cli/src/main.rs index 539399b0..18a9aaa5 100644 --- a/crates/manta-cli/src/main.rs +++ b/crates/manta-cli/src/main.rs @@ -42,6 +42,9 @@ mod output; use crate::common::app_context::AppContext; use crate::common::config::CliConfiguration; +use crate::common::oidc::{ + DEFAULT_OIDC_CLIENT_ID, DEFAULT_OIDC_ISSUER_URL, OidcSettings, +}; use clap::ArgMatches; @@ -135,6 +138,17 @@ async fn run_cli( sat_file_not_visible_budget_secs: configuration .sat_file_not_visible_budget_secs, read_only: configuration.read_only, + oidc: OidcSettings { + issuer_url: configuration + .oidc_issuer_url + .as_deref() + .unwrap_or(DEFAULT_OIDC_ISSUER_URL), + client_id: configuration + .oidc_client_id + .as_deref() + .unwrap_or(DEFAULT_OIDC_CLIENT_ID), + headless: configuration.oidc_headless, + }, settings: &settings, token: None, session: None, diff --git a/crates/manta-shared/src/common/jwt_ops.rs b/crates/manta-shared/src/common/jwt_ops.rs index 4acfb879..616bb068 100644 --- a/crates/manta-shared/src/common/jwt_ops.rs +++ b/crates/manta-shared/src/common/jwt_ops.rs @@ -100,6 +100,24 @@ pub fn get_preferred_username(token: &str) -> Result { } } +/// Extract the `exp` claim (expiry, seconds since the Unix epoch) +/// from a JWT token. +/// +/// Returns `None` when the claim is absent or not an integer. +/// +/// # Errors +/// +/// Returns [`MantaError::JwtMalformed`] when `token` does not parse +/// as `header.payload.signature` Base64, or when the payload is not +/// valid UTF-8 JSON. +pub fn get_expiration(token: &str) -> Result, MantaError> { + Ok( + get_claims_from_jwt_token(token)? + .get("exp") + .and_then(Value::as_i64), + ) +} + /// Extract the `realm_access.roles` claim from a JWT token. /// /// Returns an empty `Vec` when the claim is absent or is not a JSON @@ -187,6 +205,20 @@ mod tests { assert_eq!(get_name(&bearer_token).unwrap(), "Bob Jones"); } + // ---- get_expiration ---- + + #[test] + fn get_expiration_present() { + let token = make_jwt(&serde_json::json!({"exp": 1_777_233_657})); + assert_eq!(get_expiration(&token).unwrap(), Some(1_777_233_657)); + } + + #[test] + fn get_expiration_missing_returns_none() { + let token = make_jwt(&serde_json::json!({"name": "Alice"})); + assert_eq!(get_expiration(&token).unwrap(), None); + } + // ---- get_preferred_username ---- #[test] From 6db1845d93afa28ff630e66090fea3f38361cf3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20Janal=C3=ADk?= Date: Thu, 24 Sep 2026 11:31:14 +0200 Subject: [PATCH 2/3] feat(server)!: remove /v2/auth endpoints, auth rate limiter and Kafka auditor The CLI now obtains its token from the CSCS Keycloak directly, so manta-server no longer handles credentials. The bearer token is still forwarded unchanged to CSM / Vault / k8s, and the jwt_ops-based authorization checks are unchanged. - Drop POST /v2/auth/token and /v2/auth/validate (handlers, service, wire types, OpenAPI entries) and the /v2/auth sub-router with its per-IP rate limiter and body-redaction middleware. - Drop the Kafka auditor: its only event was the auth attempt. Removes [auditor.kafka], [server].auth_rate_limit_per_minute and rdkafka. Existing server.toml files that still set them keep loading; the keys are ignored. - Keep the AuthenticationTrait forwarding in backend_dispatcher so the dispatcher-coverage test still holds every trait method to an override. - Regenerate crates/manta-cli/openapi.json. BREAKING CHANGE: /v2/auth/token and /v2/auth/validate are gone; the [auditor.kafka] and auth_rate_limit_per_minute settings have no effect. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MPaEy7U15cDibrbtL2Ei7J --- Cargo.toml | 1 - crates/manta-cli/openapi.json | 201 ---------------- crates/manta-server/Cargo.toml | 1 - .../src/backend_dispatcher/authentication.rs | 5 + crates/manta-server/src/config.rs | 11 - crates/manta-server/src/main.rs | 20 -- crates/manta-server/src/server/api_doc.rs | 5 - .../src/server/auth_middleware.rs | 145 ------------ .../src/server/auth_middleware/tests.rs | 135 ----------- .../manta-server/src/server/common/audit.rs | 134 ----------- .../manta-server/src/server/common/kafka.rs | 222 ------------------ crates/manta-server/src/server/common/mod.rs | 6 - .../manta-server/src/server/handlers/auth.rs | 177 -------------- .../manta-server/src/server/handlers/mod.rs | 6 +- crates/manta-server/src/server/mod.rs | 12 +- crates/manta-server/src/server/routes.rs | 25 +- crates/manta-server/src/service/auth.rs | 112 --------- crates/manta-server/src/service/mod.rs | 3 +- crates/manta-server/src/wire_conv.rs | 2 +- crates/manta-server/tests/integration.rs | 2 - crates/manta-server/tests/server_routes.rs | 112 ++------- crates/manta-shared/src/common/config/mod.rs | 11 +- crates/manta-shared/src/common/error.rs | 2 +- crates/manta-shared/src/lib.rs | 8 +- crates/manta-shared/src/types/auth.rs | 55 ----- crates/manta-shared/src/types/mod.rs | 3 +- 26 files changed, 34 insertions(+), 1382 deletions(-) delete mode 100644 crates/manta-server/src/server/auth_middleware.rs delete mode 100644 crates/manta-server/src/server/auth_middleware/tests.rs delete mode 100644 crates/manta-server/src/server/common/audit.rs delete mode 100644 crates/manta-server/src/server/common/kafka.rs delete mode 100644 crates/manta-server/src/server/handlers/auth.rs delete mode 100644 crates/manta-server/src/service/auth.rs delete mode 100644 crates/manta-shared/src/types/auth.rs diff --git a/Cargo.toml b/Cargo.toml index bac6b481..ba1df445 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,7 +72,6 @@ dialoguer = { version = "0.12.0", features = ["password"], default-feat directories = "6.0.0" futures = { version = "0.3.31", default-features = false } minijinja = { version = "2.4.0", features = ["custom_syntax"] } -rdkafka = "0.39" regex = "1.6.0" reqwest = { version = "0.12.15", default-features = false, features = ["blocking", "json", "rustls-tls", "socks", "stream"] } serde = { version = "1.0.219", features = ["derive"] } diff --git a/crates/manta-cli/openapi.json b/crates/manta-cli/openapi.json index d47e0f47..3a36841e 100644 --- a/crates/manta-cli/openapi.json +++ b/crates/manta-cli/openapi.json @@ -79,163 +79,6 @@ ] } }, - "/auth/token": { - "post": { - "tags": [ - "auth" - ], - "summary": "POST /v2/auth/token — exchange username/password for a CSM token.", - "operationId": "auth_token", - "parameters": [ - { - "name": "X-Manta-Site", - "in": "header", - "description": "Name of the target cluster (matches a site configured in the server).", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AuthTokenRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Token issued", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AuthTokenResponse" - } - } - } - }, - "401": { - "description": "Invalid credentials", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Unknown site", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "429": { - "description": "Rate limit exceeded", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Internal error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, - "/auth/validate": { - "post": { - "tags": [ - "auth" - ], - "summary": "POST /v2/auth/validate — check whether a CSM token is still valid.", - "operationId": "auth_validate", - "parameters": [ - { - "name": "X-Manta-Site", - "in": "header", - "description": "Name of the target cluster (matches a site configured in the server).", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidateTokenRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Token is valid" - }, - "401": { - "description": "Token rejected", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "Unknown site", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "429": { - "description": "Rate limit exceeded", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "500": { - "description": "Internal error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - } - } - }, "/boot-config": { "post": { "tags": [ @@ -4856,37 +4699,6 @@ } } }, - "AuthTokenRequest": { - "type": "object", - "description": "Request body for `POST /v2/auth/token`.\n\nPaired with [`AuthTokenResponse`] on success. The `/auth/*`\nsub-router is wrapped by `strip_body_for_logs`, so neither the\nrequest body nor the issued token appears in access logs.\n\n# Wire shape\n\n```json\n{ \"username\": \"alice\", \"password\": \"hunter2\" }\n```", - "required": [ - "username", - "password" - ], - "properties": { - "password": { - "type": "string", - "description": "Keycloak password. Never logged by the server (the\n`/auth/*` sub-router is wrapped by `strip_body_for_logs`)." - }, - "username": { - "type": "string", - "description": "Keycloak username submitted to the configured backend." - } - } - }, - "AuthTokenResponse": { - "type": "object", - "description": "Response body for `POST /v2/auth/token`.\n\nReturned in exchange for a valid [`AuthTokenRequest`].\n\n# Wire shape\n\n```json\n{ \"token\": \"eyJhbGciOi...\" }\n```", - "required": [ - "token" - ], - "properties": { - "token": { - "type": "string", - "description": "Bearer token issued by the backend (CSM or OpenCHAMI Keycloak).\nPass this as `Authorization: Bearer ` on every\nsubsequent request." - } - } - }, "BackendSummary": { "type": "object", "description": "One row of the backend-data summary, anchored on an IMS image.\n\nEvery IMS image visible to the caller produces exactly one row.\nThe row's `image_id` and `name` are always populated; the rest are\n`Option` and are filled in only when the corresponding\nrelation resolves.\n\nSee also [`super::configuration_analysis::ConfigurationAnalysis`]\nfor the parallel configuration-centric projection.\n\n# Wire shape\n\n```json\n{\n \"image_id\": \"0a1b2c3d-...\",\n \"name\": \"compute-cos-2.5\",\n \"image_created\": \"2026-05-12T10:14:22Z\",\n \"configuration_name\": \"cos-2.5\",\n \"safe_to_delete\": false\n}\n```", @@ -5871,19 +5683,6 @@ "description": "BMC username for Redfish authentication." } } - }, - "ValidateTokenRequest": { - "type": "object", - "description": "Request body for `POST /v2/auth/validate`.\n\nUsed to check a previously-issued [`AuthTokenResponse::token`]\nbefore relying on it. The server returns `200 OK` for a valid\ntoken and `401` otherwise — there is no dedicated response body.", - "required": [ - "token" - ], - "properties": { - "token": { - "type": "string", - "description": "Bearer token to validate against the backend." - } - } } }, "securitySchemes": { diff --git a/crates/manta-server/Cargo.toml b/crates/manta-server/Cargo.toml index 77bc731e..3e74f548 100644 --- a/crates/manta-server/Cargo.toml +++ b/crates/manta-server/Cargo.toml @@ -44,7 +44,6 @@ rustls = { version = "0.23", default-features = false, features = ["ring # Server-side helpers used by service/* and server/common/* base64 = { workspace = true } chrono = { workspace = true } -rdkafka = { workspace = true } comfy-table = "7.2.2" # Glob-pattern matching for service-layer filters like `get_images` # `--pattern`. Already a transitive workspace dep via csm-rs; promoting diff --git a/crates/manta-server/src/backend_dispatcher/authentication.rs b/crates/manta-server/src/backend_dispatcher/authentication.rs index dfa4fe37..91251fbc 100644 --- a/crates/manta-server/src/backend_dispatcher/authentication.rs +++ b/crates/manta-server/src/backend_dispatcher/authentication.rs @@ -11,6 +11,11 @@ //! structured `tracing::debug` on entry and `tracing::warn` on the //! error path so an auth failure surfaces backend + user metadata at //! the dispatcher boundary. +//! +//! manta itself no longer calls either method: the CLI obtains its +//! token from the CSCS Keycloak directly. The forwarding is kept so +//! `tests/dispatcher_coverage.rs` keeps holding every trait method to +//! an override. use super::*; diff --git a/crates/manta-server/src/config.rs b/crates/manta-server/src/config.rs index 97ad47ff..0a0ac331 100644 --- a/crates/manta-server/src/config.rs +++ b/crates/manta-server/src/config.rs @@ -15,7 +15,6 @@ //! cert = "/etc/manta/tls/server.crt" //! key = "/etc/manta/tls/server.key" //! console_inactivity_timeout_secs = 1800 -//! auth_rate_limit_per_minute = 60 //! //! [sites.alps] //! backend = "csm" @@ -31,7 +30,6 @@ use std::collections::HashMap; -use crate::server::common::audit::Auditor; use manta_backend_dispatcher::types::K8sDetails; use serde::{Deserialize, Serialize}; @@ -99,10 +97,6 @@ pub struct ServerSettings { /// How long a node-console WebSocket stays open without activity /// before the server tears it down. pub console_inactivity_timeout_secs: u64, - /// Per-source-IP rate limit for the `/v2/auth/*` endpoints, - /// in requests per minute. `None` disables in-process rate limiting - /// (operators are then expected to enforce it at the reverse proxy). - pub auth_rate_limit_per_minute: Option, /// Global request timeout applied to every HTTP route, in seconds. /// When this elapses the server returns `408 REQUEST_TIMEOUT`. All /// long-running work (e.g. power transitions) now runs CLI-side, @@ -182,9 +176,6 @@ pub struct ServerConfiguration { /// Per-site backend connection details, keyed by site name. The /// `X-Manta-Site` header on each request picks which one to route to. pub sites: HashMap, - /// Optional Kafka audit forwarder (typically used for `/auth/*` - /// attempts). When `None`, the server emits no audit messages. - pub auditor: Option, } #[cfg(test)] @@ -245,14 +236,12 @@ mod tests { cert: Some("/etc/manta/tls/server.crt".to_string()), key: Some("/etc/manta/tls/server.key".to_string()), console_inactivity_timeout_secs: 1800, - auth_rate_limit_per_minute: Some(60), request_timeout_secs: 300, shutdown_grace_period_secs: 30, migrate_backup_root: None, allow_http: false, }, sites, - auditor: None, }; let toml_str = toml::to_string(&cfg).unwrap(); let parsed: ServerConfiguration = toml::from_str(&toml_str).unwrap(); diff --git a/crates/manta-server/src/main.rs b/crates/manta-server/src/main.rs index dd64392c..966e8558 100644 --- a/crates/manta-server/src/main.rs +++ b/crates/manta-server/src/main.rs @@ -193,25 +193,8 @@ fn print_startup_summary( " console_inactivity_timeout_secs: {}", configuration.server.console_inactivity_timeout_secs ); - println!( - " auth_rate_limit_per_minute: {}", - configuration - .server - .auth_rate_limit_per_minute - .map_or_else(|| "".to_string(), |n| n.to_string()) - ); println!(" log_filter: {}", configuration.log); println!(); - println!("[auditor]"); - match configuration.auditor.as_ref() { - Some(a) => { - println!(" Kafka audit forwarder enabled"); - println!(" brokers: {:?}", a.kafka.brokers); - println!(" topic: {}", a.kafka.topic); - } - None => println!(" disabled (no audit messages will be emitted)"), - } - println!(); } /// Print one `[site: ]` block on stdout, matching the format @@ -420,15 +403,12 @@ async fn run_server( ); } - let auditor = configuration.auditor.as_ref().map(|a| a.kafka.clone()); let shutdown_grace_period = std::time::Duration::from_secs( configuration.server.shutdown_grace_period_secs, ); let server_state = std::sync::Arc::new(server::ServerState { sites, console_inactivity_timeout, - auditor, - auth_rate_limit_per_minute: configuration.server.auth_rate_limit_per_minute, request_timeout, shutdown_grace_period, migrate_backup_root, diff --git a/crates/manta-server/src/server/api_doc.rs b/crates/manta-server/src/server/api_doc.rs index f12f58f3..557ac504 100644 --- a/crates/manta-server/src/server/api_doc.rs +++ b/crates/manta-server/src/server/api_doc.rs @@ -75,8 +75,6 @@ use super::handlers; handlers::apply_hw_configuration, handlers::console_node_ws, handlers::console_session_ws, - handlers::auth_token, - handlers::auth_validate, handlers::get_available_groups, ), components(schemas( @@ -106,9 +104,6 @@ use super::handlers; handlers::DeleteHwComponentRequest, handlers::HwClusterMode, handlers::ApplyHwConfigurationRequest, - manta_shared::types::auth::AuthTokenRequest, - manta_shared::types::auth::AuthTokenResponse, - manta_shared::types::auth::ValidateTokenRequest, crate::service::boot_parameters::UpdateBootParametersParams, manta_shared::types::api::redfish_endpoints::UpdateRedfishEndpointParams, manta_shared::types::dto::NodeDetails, diff --git a/crates/manta-server/src/server/auth_middleware.rs b/crates/manta-server/src/server/auth_middleware.rs deleted file mode 100644 index cda66910..00000000 --- a/crates/manta-server/src/server/auth_middleware.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! Defensive middleware for the `/v2/auth/*` sub-router. -//! -//! Two layers, applied in this order: -//! -//! 1. `rate_limit` — per-source-IP token-bucket. Drops requests that -//! exceed `[server].auth_rate_limit_per_minute` with a 429 response. -//! Source IP comes from the connection (after the optional -//! `X-Forwarded-For` handling that ConnectInfo gives us). Operators -//! are still expected to terminate at a reverse proxy and rate-limit -//! there too — this is defence in depth. -//! -//! 2. `strip_body_for_logs` — explicit, even though the request-logger -//! in `super::log_requests` only logs `method + uri + status` today. -//! Treat it as a hard guarantee that credentials submitted to -//! `/auth/token` never end up in a log line, regardless of what the -//! logger middleware grows into in future. - -use std::collections::HashMap; -use std::net::{IpAddr, SocketAddr}; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; - -use axum::{ - Json, - extract::{ConnectInfo, Request, State}, - http::StatusCode, - middleware::Next, - response::{IntoResponse, Response}, -}; - -use super::ServerState; -use super::handlers::ErrorResponse; - -/// Per-IP state for the token-bucket rate limiter. -struct WindowState { - window_start: Instant, - count: u32, -} - -/// In-memory rate-limit table, sized by the number of distinct -/// source IPs that hit `/auth/*` in the last minute. For typical CLI -/// fleets this is small; entries older than two windows are pruned -/// on every check. -/// -/// Constructed once by [`super::routes::build_router`] and threaded -/// through Axum's `Extension` layer into [`rate_limit`]. The limit -/// (requests per IP per minute) is read from -/// [`super::ServerState::auth_rate_limit_per_minute`] on every -/// request, so it can change at config-reload time without -/// rebuilding the router. -#[derive(Default)] -pub struct AuthRateLimiter { - windows: Mutex>, -} - -impl AuthRateLimiter { - /// Construct a fresh limiter wrapped in an `Arc` so it can be - /// shared via Axum's `Extension` layer across handler invocations. - pub fn new() -> Arc { - Arc::new(Self::default()) - } - - /// Returns `true` if `ip` is allowed to make one more request under - /// the given `limit` (requests per minute), `false` if it would - /// exceed. - fn check(&self, ip: IpAddr, limit: u32) -> bool { - self.check_at(ip, limit, Instant::now()) - } - - /// Testable variant of [`Self::check`] with an explicit clock. The split - /// lets unit tests exercise the window-reset and pruning logic - /// without actually sleeping 60+ seconds. - fn check_at(&self, ip: IpAddr, limit: u32, now: Instant) -> bool { - let window = Duration::from_secs(60); - let mut windows = self.windows.lock().expect("rate limiter mutex poisoned"); - - // Opportunistic pruning of stale entries. - windows - .retain(|_, state| now.duration_since(state.window_start) < window * 2); - - let entry = windows.entry(ip).or_insert(WindowState { - window_start: now, - count: 0, - }); - - if now.duration_since(entry.window_start) >= window { - entry.window_start = now; - entry.count = 0; - } - - if entry.count >= limit { - return false; - } - entry.count += 1; - true - } -} - -/// Per-source-IP rate-limit middleware for the `/v2/auth/*` -/// sub-router. Reads -/// [`super::ServerState::auth_rate_limit_per_minute`]; when `None`, -/// the middleware is a no-op (operators rate-limit at the proxy). -/// When the per-IP request count exceeds the limit, returns -/// `429 Too Many Requests` with an [`ErrorResponse`] body and a -/// `tracing::warn!` event. -pub async fn rate_limit( - State(state): State>, - ConnectInfo(peer): ConnectInfo, - limiter: axum::extract::Extension>, - request: Request, - next: Next, -) -> Response { - let Some(limit) = state.auth_rate_limit_per_minute else { - return next.run(request).await; - }; - if !limiter.check(peer.ip(), limit) { - tracing::warn!( - "auth: rate limit exceeded for source {} (limit={}/min)", - peer.ip(), - limit - ); - return ( - StatusCode::TOO_MANY_REQUESTS, - Json(ErrorResponse { - error: "rate limit exceeded".to_string(), - }), - ) - .into_response(); - } - next.run(request).await -} - -/// Belt-and-braces: ensure no `/auth/*` request body ever reaches a -/// logger. The runtime cost is one logger-scoped `tracing` span with -/// the body field redacted; the body itself is forwarded to the -/// handler untouched, so deserialisation in -/// [`super::handlers::auth_token`] still sees the original payload. -pub async fn strip_body_for_logs(request: Request, next: Next) -> Response { - let span = tracing::info_span!("auth_request", body = ""); - let _enter = span.enter(); - next.run(request).await -} - -#[cfg(test)] -mod tests; diff --git a/crates/manta-server/src/server/auth_middleware/tests.rs b/crates/manta-server/src/server/auth_middleware/tests.rs deleted file mode 100644 index e0e15189..00000000 --- a/crates/manta-server/src/server/auth_middleware/tests.rs +++ /dev/null @@ -1,135 +0,0 @@ -//! Tests for the `/v2/auth/*` defensive middleware. -//! -//! The rate-limiter is the main subject — its token-bucket algorithm -//! has time-based behaviours (window reset, stale-entry pruning) that -//! we exercise via the `check_at` clock-injection helper without -//! actually sleeping for 60+ seconds. The middleware wrapper itself -//! is one branch deep, so we cover its disabled-limit pass-through -//! at the integration level. -//! -//! `strip_body_for_logs` is not unit-tested here: the function only -//! opens a tracing span and forwards the request unchanged. Verifying -//! the span's `body = ""` field would require a full -//! `tracing_subscriber` test harness, which is more setup than the -//! one-line implementation warrants. The body-redaction guarantee -//! is reviewable directly in the source. - -use std::net::{IpAddr, Ipv4Addr}; -use std::time::{Duration, Instant}; - -use super::AuthRateLimiter; - -fn ip(last: u8) -> IpAddr { - IpAddr::V4(Ipv4Addr::new(10, 0, 0, last)) -} - -#[test] -fn first_request_for_a_fresh_ip_is_allowed() { - let limiter = AuthRateLimiter::default(); - assert!(limiter.check_at(ip(1), 60, Instant::now())); -} - -#[test] -fn allows_exactly_limit_requests_then_rejects() { - let limiter = AuthRateLimiter::default(); - let t0 = Instant::now(); - - // The first `limit` calls all succeed. - for i in 0..5 { - assert!( - limiter.check_at(ip(1), 5, t0), - "request {} unexpectedly rejected within the limit", - i + 1 - ); - } - // The (limit + 1)-th call is rejected. - assert!(!limiter.check_at(ip(1), 5, t0)); - // And keeps being rejected within the same window. - assert!(!limiter.check_at(ip(1), 5, t0 + Duration::from_secs(30))); -} - -#[test] -fn different_ips_have_independent_buckets() { - let limiter = AuthRateLimiter::default(); - let t0 = Instant::now(); - - // Exhaust IP A's quota. - for _ in 0..3 { - assert!(limiter.check_at(ip(1), 3, t0)); - } - assert!(!limiter.check_at(ip(1), 3, t0), "IP A should be over limit"); - - // IP B is unaffected. - for _ in 0..3 { - assert!( - limiter.check_at(ip(2), 3, t0), - "IP B's bucket was contaminated by IP A's" - ); - } -} - -#[test] -fn bucket_resets_after_60_seconds() { - let limiter = AuthRateLimiter::default(); - let t0 = Instant::now(); - - for _ in 0..2 { - assert!(limiter.check_at(ip(1), 2, t0)); - } - assert!(!limiter.check_at(ip(1), 2, t0)); - - // Just before the window edge — still rejected. - assert!(!limiter.check_at(ip(1), 2, t0 + Duration::from_secs(59))); - - // At the window edge — the bucket resets and a fresh quota starts. - let t1 = t0 + Duration::from_secs(60); - assert!(limiter.check_at(ip(1), 2, t1)); - assert!(limiter.check_at(ip(1), 2, t1)); - assert!(!limiter.check_at(ip(1), 2, t1)); -} - -#[test] -fn stale_entries_are_pruned_after_two_windows() { - let limiter = AuthRateLimiter::default(); - let t0 = Instant::now(); - - // Touch IP A at t0. - assert!(limiter.check_at(ip(1), 1, t0)); - assert_eq!(limiter.windows.lock().unwrap().len(), 1); - - // Two windows later, a request from any IP triggers the prune. - // IP A's stale entry should be gone, leaving only IP B's fresh one. - let t_stale = t0 + Duration::from_secs(120); - assert!(limiter.check_at(ip(2), 1, t_stale)); - let remaining: Vec = - limiter.windows.lock().unwrap().keys().copied().collect(); - assert_eq!( - remaining, - vec![ip(2)], - "stale entry for IP A should be pruned" - ); -} - -#[test] -fn limit_of_zero_rejects_everything() { - let limiter = AuthRateLimiter::default(); - // Edge case: configuration of `0` means "block all auth attempts". - // Documenting the behaviour even though no operator would set this. - assert!(!limiter.check_at(ip(1), 0, Instant::now())); -} - -// --------------------------------------------------------------------------- -// Middleware wrapper -// -// `rate_limit` itself is two `if` branches over `check`. The no-limit -// pass-through is the one branch worth covering at the wrapper level -// because it's what most production configs hit (`auth_rate_limit_per_minute` -// defaults to `None`). The rate-limited branch is covered indirectly -// by `AuthRateLimiter` tests above plus the existing route smoke -// tests in `crates/manta-server/tests/server_routes.rs`. -// --------------------------------------------------------------------------- - -// (Currently no middleware-wrapper test — the wrapper plumbing -// requires building a full Axum app with `ConnectInfo`, -// which is heavier than the test value. Add one if the wrapper grows -// branches beyond the no-limit early-return.) diff --git a/crates/manta-server/src/server/common/audit.rs b/crates/manta-server/src/server/common/audit.rs deleted file mode 100644 index 1d8dd096..00000000 --- a/crates/manta-server/src/server/common/audit.rs +++ /dev/null @@ -1,134 +0,0 @@ -//! Audit trail helpers: build and send structured JSON messages to Kafka. -//! -//! Audit emission is opt-in via the `[auditor.kafka]` section of -//! `server.toml`; when absent, [`super::super::ServerState::auditor`] -//! is `None` and [`send_auth_audit`] becomes a no-op. Failures inside -//! [`send_auth_audit`] log a warning and never bubble up, so an -//! unreachable Kafka broker cannot abort the outer auth flow. - -use manta_shared::common::error::MantaError; -use serde::{Deserialize, Serialize}; - -use crate::server::common::kafka::Kafka; - -#[derive(Serialize, Deserialize, Debug, Clone)] -/// Wraps a [`Kafka`] instance for sending audit messages. -pub struct Auditor { - /// Kafka producer configured from `[auditor.kafka]` in the binary's - /// config file. - pub kafka: Kafka, -} - -/// Trait for producing audit messages to a message broker. -pub trait Audit { - /// Publish a single audit message payload. Implementations are - /// expected to be fire-and-forget — failures should be logged but - /// not propagated to the caller, since audit failures must not - /// abort the outer operation. - #[allow(async_fn_in_trait)] - async fn produce_message(&self, data: &[u8]) -> Result<(), MantaError>; -} - -/// Serialize a JSON audit message and send it to Kafka. -/// -/// Logs a warning on failure instead of propagating the -/// error, since audit failures should not abort the -/// operation. -async fn send_audit_message(kafka: &Kafka, msg_json: serde_json::Value) { - let msg_data = match serde_json::to_string(&msg_json) { - Ok(data) => data, - Err(e) => { - tracing::warn!("Failed serializing audit message: {}", e); - return; - } - }; - - if let Err(e) = kafka.produce_message(msg_data.as_bytes()).await { - tracing::warn!("Failed producing audit message: {}", e); - } -} - -/// Build the JSON payload that [`send_auth_audit`] sends to Kafka. -/// -/// Split out so unit tests can pin the wire shape (notably: NO -/// password field, by construction — the function doesn't take one). -pub(crate) fn build_auth_audit_message( - outcome: &str, - username: &str, - source_ip: &str, - site: &str, -) -> serde_json::Value { - serde_json::json!({ - "event": "auth_attempt", - "outcome": outcome, - "username": username, - "source_ip": source_ip, - "site": site, - }) -} - -/// Send a structured audit event for an `/v2/auth/token` attempt. -/// -/// Used by the server's auth handler — there is no JWT yet (the user is -/// asking for one), so identity is captured from the submitted username -/// rather than extracted from a token. The password is never logged. -/// -/// Always Kafka-only; failures log a warning and do not abort the -/// outer auth flow. -pub async fn send_auth_audit( - kafka_opt: Option<&Kafka>, - outcome: &str, - username: &str, - source_ip: &str, - site: &str, -) { - let Some(kafka) = kafka_opt else { return }; - send_audit_message( - kafka, - build_auth_audit_message(outcome, username, source_ip, site), - ) - .await; -} - -#[cfg(test)] -mod tests { - use super::*; - - // ---- build_auth_audit_message ---- - - #[test] - fn auth_audit_has_expected_wire_shape() { - let msg = build_auth_audit_message("success", "alice", "10.0.0.1", "alps"); - assert_eq!(msg["event"], "auth_attempt"); - assert_eq!(msg["outcome"], "success"); - assert_eq!(msg["username"], "alice"); - assert_eq!(msg["source_ip"], "10.0.0.1"); - assert_eq!(msg["site"], "alps"); - } - - #[test] - fn auth_audit_payload_has_no_password_field_by_construction() { - // The function doesn't take a password — pin via the wire shape - // that no `password` / `passwd` / `secret` key sneaks in. - let msg = build_auth_audit_message("failure", "alice", "10.0.0.1", "alps"); - let obj = msg.as_object().expect("payload is an object"); - for forbidden in ["password", "passwd", "secret", "token"] { - assert!( - !obj.contains_key(forbidden), - "auth audit payload must not contain `{forbidden}`" - ); - } - } - - #[test] - fn auth_audit_handles_empty_strings_without_panicking() { - // Some auth-failure paths pass empty source_ip or site (when not - // resolvable). The function should still produce a well-formed - // JSON object, not panic or omit keys. - let msg = build_auth_audit_message("failure", "", "", ""); - assert_eq!(msg["username"], ""); - assert_eq!(msg["source_ip"], ""); - assert_eq!(msg["site"], ""); - assert_eq!(msg["event"], "auth_attempt"); - } -} diff --git a/crates/manta-server/src/server/common/kafka.rs b/crates/manta-server/src/server/common/kafka.rs deleted file mode 100644 index 06762bf5..00000000 --- a/crates/manta-server/src/server/common/kafka.rs +++ /dev/null @@ -1,222 +0,0 @@ -//! Lazily-initialised Kafka producer used by the audit subsystem. -//! -//! The producer is a fire-and-forget `FutureProducer` cached behind -//! a `OnceLock`, so the first audit message pays the connection cost -//! and subsequent messages reuse the same client. Delivery uses a -//! zero-duration wait — the audit path never blocks the request -//! that triggered it. - -use std::{fmt, sync::OnceLock, time::Duration}; - -use manta_shared::common::error::MantaError; -use rdkafka::{ - ClientConfig, - producer::{FutureProducer, FutureRecord}, -}; -use serde::{Deserialize, Serialize}; - -use crate::server::common::audit::Audit; - -/// Default Kafka message delivery timeout (milliseconds), used when -/// `server.toml`'s `[auditor.kafka].message_timeout_ms` is absent. -const DEFAULT_KAFKA_MESSAGE_TIMEOUT_MS: u32 = 5000; - -/// Default Kafka delivery-confirmation wait (seconds), used when -/// `server.toml`'s `[auditor.kafka].delivery_wait_secs` is absent. -/// Zero means fire-and-forget. -const DEFAULT_KAFKA_DELIVERY_WAIT_SECS: u64 = 0; - -fn default_kafka_message_timeout_ms() -> u32 { - DEFAULT_KAFKA_MESSAGE_TIMEOUT_MS -} -fn default_kafka_delivery_wait_secs() -> u64 { - DEFAULT_KAFKA_DELIVERY_WAIT_SECS -} - -/// Kafka client configuration for audit message production. -/// -/// The [`FutureProducer`] is lazily created on the first -/// call to [`Audit::produce_message`] and reused for all -/// subsequent calls via an internal [`OnceLock`]. -#[derive(Serialize, Deserialize)] -pub struct Kafka { - /// Bootstrap broker list, e.g. `vec!["kafka.example.com:9092"]`. - pub brokers: Vec, - /// Kafka topic that audit messages are published to. - pub topic: String, - /// librdkafka `message.timeout.ms`: how long a queued audit - /// message tries to deliver before being dropped. - #[serde(default = "default_kafka_message_timeout_ms")] - pub message_timeout_ms: u32, - /// How long `produce_message` blocks waiting for delivery - /// confirmation. Zero (default) is fire-and-forget. - #[serde(default = "default_kafka_delivery_wait_secs")] - pub delivery_wait_secs: u64, - #[serde(skip)] - producer: OnceLock, -} - -impl fmt::Debug for Kafka { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Kafka") - .field("brokers", &self.brokers) - .field("topic", &self.topic) - .field( - "producer", - &if self.producer.get().is_some() { - "Some()" - } else { - "None" - }, - ) - .finish() - } -} - -impl Clone for Kafka { - /// Clone the configuration only; the cached producer is - /// not cloned and will be lazily recreated. - fn clone(&self) -> Self { - Self { - brokers: self.brokers.clone(), - topic: self.topic.clone(), - message_timeout_ms: self.message_timeout_ms, - delivery_wait_secs: self.delivery_wait_secs, - producer: OnceLock::new(), - } - } -} - -impl Kafka { - /// Create a new `Kafka` instance with the given broker list and - /// topic name. Uses the crate-internal `message.timeout.ms` and - /// delivery-wait defaults (5000ms and fire-and-forget, - /// respectively); for non-default values, deserialize a `Kafka` - /// from `server.toml`'s `[auditor.kafka]` block instead. - /// - /// The actual `FutureProducer` is built lazily on the first - /// [`Audit::produce_message`] call, so this constructor is cheap - /// and infallible. - pub fn new(brokers: Vec, topic: String) -> Self { - Self { - brokers, - topic, - message_timeout_ms: DEFAULT_KAFKA_MESSAGE_TIMEOUT_MS, - delivery_wait_secs: DEFAULT_KAFKA_DELIVERY_WAIT_SECS, - producer: OnceLock::new(), - } - } - - /// Return the cached [`FutureProducer`], creating it on - /// first call. - fn get_or_init_producer(&self) -> Result<&FutureProducer, MantaError> { - if let Some(p) = self.producer.get() { - return Ok(p); - } - let brokers = self.brokers.join(","); - let p: FutureProducer = ClientConfig::new() - .set("bootstrap.servers", &brokers) - .set("message.timeout.ms", self.message_timeout_ms.to_string()) - .create() - .map_err(|e| { - MantaError::KafkaError(format!("Failed to create Kafka producer: {e}")) - })?; - // Another thread may have raced us; either value is - // fine since they are configured identically. - Ok(self.producer.get_or_init(|| p)) - } -} - -impl Audit for Kafka { - async fn produce_message(&self, data: &[u8]) -> Result<(), MantaError> { - let producer = self.get_or_init_producer()?; - - let delivery_status = producer - .send::, _, _>( - FutureRecord::to(&self.topic).payload(data), - Duration::from_secs(self.delivery_wait_secs), - ) - .await; - - match delivery_status { - Ok(_) => { - tracing::info!("Delivery status for message received"); - } - Err(e) => { - return Err(MantaError::KafkaError(format!( - "Delivery status for message failed: {:?}", - e.0 - ))); - } - } - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - //! Tests for the non-IO parts of [`Kafka`]: configuration plumbing, - //! clone semantics, and the redacted Debug representation. - //! `produce_message` and `get_or_init_producer` require a broker - //! (or a librdkafka mock) and are exercised via integration tests. - - use super::*; - - #[test] - fn new_round_trips_brokers_and_topic() { - let k = Kafka::new( - vec!["broker1:9092".into(), "broker2:9092".into()], - "audit-events".into(), - ); - assert_eq!(k.brokers, vec!["broker1:9092", "broker2:9092"]); - assert_eq!(k.topic, "audit-events"); - assert!( - k.producer.get().is_none(), - "producer must be uninitialised on construction (lazy init)" - ); - } - - #[test] - fn clone_resets_the_producer_cache() { - // The `Clone` impl deliberately drops the cached producer — - // otherwise two `Kafka` values would share rdkafka state in a - // way the rdkafka APIs don't sanction. A future "fix" that - // shares the OnceLock would break the lazy-init contract; this - // test makes that change deliberate. - let original = Kafka::new(vec!["b:9092".into()], "t".into()); - let cloned = original.clone(); - assert_eq!(cloned.brokers, original.brokers); - assert_eq!(cloned.topic, original.topic); - assert!( - cloned.producer.get().is_none(), - "cloned producer cache must be empty regardless of source state" - ); - } - - #[test] - fn debug_masks_the_producer_and_shows_init_state() { - // The Debug impl deliberately substitutes a placeholder string - // for the FutureProducer — librdkafka internals would otherwise - // appear in log lines if a Kafka value is debug-printed. Pin - // both the placeholder string and that brokers/topic remain - // visible (they're not secret). - let uninit = Kafka::new(vec!["b:9092".into()], "audit".into()); - let s = format!("{uninit:?}"); - assert!(s.contains("brokers"), "brokers field must be visible"); - assert!(s.contains("\"b:9092\""), "broker value must be visible"); - assert!(s.contains("audit"), "topic must be visible"); - assert!( - s.contains("None"), - "uninitialised producer must show as `None`, got: {s}" - ); - // The literal placeholder string used for an initialised producer - // is pinned indirectly: if `Some()` ever leaks - // through to Debug output of an uninit Kafka, this assertion - // catches it. - assert!( - !s.contains("FutureProducer"), - "uninitialised Kafka must not mention FutureProducer in Debug output" - ); - } -} diff --git a/crates/manta-server/src/server/common/mod.rs b/crates/manta-server/src/server/common/mod.rs index d14f79ba..d08cb452 100644 --- a/crates/manta-server/src/server/common/mod.rs +++ b/crates/manta-server/src/server/common/mod.rs @@ -4,18 +4,12 @@ //! (backend dispatcher + base URLs + TLS material + optional //! Vault/k8s URLs). Built by [`super::ServerState::infra_context`] //! on every request. -//! - [`audit`] — structured `auth_attempt` event builder that publishes -//! to Kafka via [`kafka::Kafka`]. Fire-and-forget; failures are -//! logged but never propagated. //! - [`jwt_ops`] — extract `name`, `preferred_username`, and //! `realm_access.roles` from a bearer JWT without verifying the //! signature (see the module-level security caveat). -//! - [`kafka`] — lazily-initialised Kafka producer used by [`audit`]. //! - [`vault`] — HTTP client for the HashiCorp Vault used by //! handlers that need backend secrets (Gitea token, k8s creds). pub mod app_context; -pub mod audit; pub mod jwt_ops; -pub mod kafka; pub mod vault; diff --git a/crates/manta-server/src/server/handlers/auth.rs b/crates/manta-server/src/server/handlers/auth.rs deleted file mode 100644 index 84772f23..00000000 --- a/crates/manta-server/src/server/handlers/auth.rs +++ /dev/null @@ -1,177 +0,0 @@ -//! Public-router auth handlers (`POST /v2/auth/{token,validate}`). -//! -//! Deliberately not behind the `BearerToken` extractor — these are the -//! endpoints clients call *to obtain* a bearer token. The defensive -//! middleware (rate limit, body redaction) lives in -//! `crate::server::auth_middleware`; this file just maps requests to -//! [`crate::service::auth`]. -//! -//! An unknown `X-Manta-Site` is reported explicitly as `404 Not Found` -//! so the CLI can fail fast instead of prompting for credentials that -//! can never succeed. This is a deliberate trade-off: because `/auth/*` -//! takes no bearer token, it lets an *unauthenticated* caller tell a -//! configured site (401) from an unknown one (404) — reversing this -//! module's former "reveal nothing about site config" stance. It is -//! considered acceptable because site names are not secrets and the -//! per-IP rate limiter in [`crate::server::auth_middleware`] bounds -//! enumeration. (The authenticated endpoints already expose the same -//! distinction once *any* syntactically-valid bearer header is present, -//! since [`crate::server::handlers::RequestCtx`] does the site lookup -//! before the token is validated against the backend.) -//! -//! Every *other* auth failure surfaces a generic `401 invalid -//! credentials` so the response never reveals whether a username -//! exists or what the backend actually rejected. The specific reason -//! is captured server-side with `tracing::warn!` and sent to the -//! audit channel when one is configured on [`ServerState`]. - -use std::net::SocketAddr; -use std::sync::Arc; - -use crate::server::common::audit; -use axum::{ - Json, - extract::{ConnectInfo, State}, - http::StatusCode, - response::IntoResponse, -}; -use manta_shared::types::auth::{ - AuthTokenRequest, AuthTokenResponse, ValidateTokenRequest, -}; - -use super::{ErrorResponse, ServerState, SiteHeader, SiteName}; -use crate::service; - -/// Single generic 401 surfaced to clients for any `/auth/*` -/// *credential* failure. Detail stays server-side in `tracing::warn!`. -fn generic_invalid_credentials() -> (StatusCode, Json) { - ( - StatusCode::UNAUTHORIZED, - Json(ErrorResponse { - error: "invalid credentials".to_string(), - }), - ) -} - -/// `404` returned when the `X-Manta-Site` header names a site that is -/// not configured on this server. Unlike credential failures (which -/// stay a generic 401), an unknown site is reported explicitly so the -/// CLI can fail fast instead of prompting for credentials that can -/// never succeed. This intentionally reveals site existence to -/// unauthenticated callers — see the module-level docs for the -/// trade-off. -fn site_not_found(site: &str) -> (StatusCode, Json) { - ( - StatusCode::NOT_FOUND, - Json(ErrorResponse { - error: format!("site '{site}' not found"), - }), - ) -} - -/// POST /v2/auth/token — exchange username/password for a CSM token. -#[utoipa::path(post, path = "/auth/token", tag = "auth", - params(SiteHeader), - request_body = AuthTokenRequest, - responses( - (status = 200, description = "Token issued", body = AuthTokenResponse), - (status = 401, description = "Invalid credentials", body = ErrorResponse), - (status = 404, description = "Unknown site", body = ErrorResponse), - (status = 429, description = "Rate limit exceeded", body = ErrorResponse), - (status = 500, description = "Internal error", body = ErrorResponse), - ) -)] -#[tracing::instrument(skip_all)] -pub async fn auth_token( - State(state): State>, - SiteName(site_name): SiteName, - ConnectInfo(peer): ConnectInfo, - Json(req): Json, -) -> Result)> { - let infra = state.infra_context(&site_name).map_err(|e| { - tracing::warn!("auth_token: site lookup failed: {}", e); - site_not_found(&site_name) - })?; - let source_ip = peer.ip().to_string(); - - tracing::info!( - user = %req.username, - site = %site_name, - from = %source_ip, - "auth_token: credential exchange requested" - ); - - match service::auth::get_api_token(&infra, &req.username, &req.password).await - { - Ok(token) => { - tracing::info!( - user = %req.username, - site = %site_name, - from = %source_ip, - "auth_token: token issued" - ); - audit::send_auth_audit( - state.auditor.as_ref(), - "success", - &req.username, - &source_ip, - &site_name, - ) - .await; - Ok(Json(AuthTokenResponse { token })) - } - Err(e) => { - tracing::warn!( - "auth_token: backend rejected user={} site={} from={}: {}", - req.username, - site_name, - source_ip, - e - ); - audit::send_auth_audit( - state.auditor.as_ref(), - "failure", - &req.username, - &source_ip, - &site_name, - ) - .await; - Err(generic_invalid_credentials()) - } - } -} - -/// POST /v2/auth/validate — check whether a CSM token is still valid. -#[utoipa::path(post, path = "/auth/validate", tag = "auth", - params(SiteHeader), - request_body = ValidateTokenRequest, - responses( - (status = 200, description = "Token is valid"), - (status = 401, description = "Token rejected", body = ErrorResponse), - (status = 404, description = "Unknown site", body = ErrorResponse), - (status = 429, description = "Rate limit exceeded", body = ErrorResponse), - (status = 500, description = "Internal error", body = ErrorResponse), - ) -)] -#[tracing::instrument(skip_all)] -pub async fn auth_validate( - State(state): State>, - SiteName(site_name): SiteName, - Json(req): Json, -) -> Result)> { - let infra = state.infra_context(&site_name).map_err(|e| { - tracing::warn!("auth_validate: site lookup failed: {}", e); - site_not_found(&site_name) - })?; - tracing::info!(site = %site_name, "auth_validate: token check requested"); - match service::auth::validate_api_token(&infra, &req.token).await { - Ok(()) => { - tracing::info!(site = %site_name, "auth_validate: token accepted"); - Ok(StatusCode::OK) - } - Err(e) => { - tracing::warn!("auth_validate: backend rejected token: {}", e); - Err(generic_invalid_credentials()) - } - } -} diff --git a/crates/manta-server/src/server/handlers/mod.rs b/crates/manta-server/src/server/handlers/mod.rs index d57a3c8d..4a7b17c6 100644 --- a/crates/manta-server/src/server/handlers/mod.rs +++ b/crates/manta-server/src/server/handlers/mod.rs @@ -29,7 +29,6 @@ use super::ServerState; use super::common::app_context::InfraContext; mod analysis; -mod auth; mod boot_parameters; mod cluster; mod configuration; @@ -50,7 +49,6 @@ mod session; mod template; pub use analysis::*; -pub use auth::*; pub use boot_parameters::*; pub use cluster::*; pub use configuration::*; @@ -172,8 +170,8 @@ pub struct SiteHeader { /// a configured [`super::SiteBackend`], so [`Self::infra`] inside the /// handler body is infallible. /// -/// The unauthenticated `/auth/*` handlers and the health endpoint -/// still use explicit extractors — they don't need a Bearer token. +/// The health endpoint still uses explicit extractors — it doesn't +/// need a Bearer token. /// /// # Example /// diff --git a/crates/manta-server/src/server/mod.rs b/crates/manta-server/src/server/mod.rs index 94b39cdf..5913d9c7 100644 --- a/crates/manta-server/src/server/mod.rs +++ b/crates/manta-server/src/server/mod.rs @@ -13,15 +13,12 @@ //! requests into service-layer calls. //! - [`routes`] — router registration (one entry per `/v2` //! path). -//! - [`auth_middleware`] — defensive middleware applied to -//! `/v2/auth/*` (per-IP rate limit + body redaction). //! - [`common`] — server-only helpers (per-request `InfraContext`, -//! Kafka audit producer, JWT claim extractors, Vault client). +//! JWT claim extractors, Vault client). //! - [`api_doc`] — utoipa OpenAPI document served at //! `GET /openapi.json` + `GET /docs`. pub mod api_doc; -pub mod auth_middleware; pub mod common; pub mod handlers; pub mod routes; @@ -36,7 +33,6 @@ use std::time::Duration; use crate::dispatcher::StaticBackendDispatcher; use crate::server::common::app_context::InfraContext; -use crate::server::common::kafka::Kafka; /// All per-site connection data the server needs to talk to backend APIs. /// @@ -81,12 +77,6 @@ pub struct ServerState { /// How long a WebSocket console session may be idle before the server /// closes it. Protects against leaked Kubernetes pod attachments. pub console_inactivity_timeout: Duration, - /// Kafka producer for security/audit events (currently used only by - /// `/v2/auth/*`). `None` disables audit emission. - pub auditor: Option, - /// Per-source-IP rate limit on `/v2/auth/*` (requests/minute). - /// `None` disables in-process rate limiting. - pub auth_rate_limit_per_minute: Option, /// Global request timeout applied to every HTTP route (router-level /// `TimeoutLayer`). All long-running work (power transitions, SAT /// dispatch) runs CLI-side, so this is the only request-timeout diff --git a/crates/manta-server/src/server/routes.rs b/crates/manta-server/src/server/routes.rs index dae01684..79b5bd77 100644 --- a/crates/manta-server/src/server/routes.rs +++ b/crates/manta-server/src/server/routes.rs @@ -1,14 +1,12 @@ //! Axum router registration: maps every `/v2/` path to its handler. //! //! The OpenAPI JSON spec is served at `GET /openapi.json` and the -//! Swagger UI is served at `GET /docs`. The `/v2/auth/*` -//! sub-router carries its own defensive layers (rate limit, body -//! redaction) — see [`crate::server::auth_middleware`]. +//! Swagger UI is served at `GET /docs`. use std::sync::Arc; use axum::{ - Extension, Router, + Router, http::StatusCode, middleware, routing::{delete, get, post, put}, @@ -19,9 +17,6 @@ use utoipa_swagger_ui::SwaggerUi; use super::ServerState; use super::api_doc::ApiDoc; -use super::auth_middleware::{ - AuthRateLimiter, rate_limit, strip_body_for_logs, -}; use super::handlers; /// Build the axum router with all API endpoints and OpenAPI doc routes. @@ -32,10 +27,6 @@ use super::handlers; /// `TimeoutLayer`. `POST /power` now returns immediately with a /// PCS transition id (the polling loop runs CLI-side), so it fits /// well under the default timeout — no per-route override is needed. -/// - `/v2/auth/*` — separate sub-router with two layered -/// defences: per-IP rate limit (see [`AuthRateLimiter`]) and body -/// redaction from any log span (see [`strip_body_for_logs`]). No -/// Bearer-token extractor (these endpoints issue the token). /// - `/docs` + `/openapi.json` — Swagger UI and the spec from /// [`ApiDoc`]. /// - HSTS header injected on every response by an @@ -185,20 +176,8 @@ pub fn build_router(state: Arc) -> Router { state.request_timeout, )); - // /v2/auth/* — credential-handling sub-router. No Bearer - // extractor (chicken-and-egg). Two layered defences applied: - // (1) per-IP rate limit, (2) body redaction from any log span. - let limiter = AuthRateLimiter::new(); - let auth = Router::new() - .route("/token", post(handlers::auth_token)) - .route("/validate", post(handlers::auth_validate)) - .layer(middleware::from_fn(strip_body_for_logs)) - .layer(middleware::from_fn_with_state(state.clone(), rate_limit)) - .layer(Extension(limiter)); - Router::new() .nest("/v2", api) - .nest("/v2/auth", auth) .merge(SwaggerUi::new("/docs").url("/openapi.json", ApiDoc::openapi())) // HSTS on every response. Browsers ignore HSTS over plain HTTP // per RFC 6797, so this is a no-op when `allow_http = true` diff --git a/crates/manta-server/src/service/auth.rs b/crates/manta-server/src/service/auth.rs deleted file mode 100644 index 254d7d32..00000000 --- a/crates/manta-server/src/service/auth.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! Authentication service — proxies CLI credential exchange to the -//! configured CSM/OCHAMI backend. -//! -//! The CLI never talks to Keycloak directly; it POSTs username+password -//! to `manta-server /v2/auth/token`, which calls -//! `backend.get_api_token` on the user's behalf and returns the CSM -//! bearer token. `validate_api_token` exposes a lightweight -//! "is-this-token-still-valid" probe the CLI can call before sending -//! a long-running request that would otherwise fail mid-flight. - -use std::time::Instant; - -use manta_backend_dispatcher::error::Error; -use manta_backend_dispatcher::interfaces::authentication::AuthenticationTrait; - -use crate::server::common::app_context::InfraContext; - -/// Exchange `username` + `password` for a CSM bearer token via the -/// site's configured backend. -/// -/// The CLI's `auth` command posts to `/v2/auth/token`, which -/// reaches this function. The returned token is the same bearer the -/// caller then sends as `Authorization: Bearer ...` on every -/// subsequent request. -/// -/// # Errors -/// -/// Whatever the backend's -/// [`AuthenticationTrait::get_api_token`] returns — typically a -/// `BackendError::Unauthorized` for bad credentials, or a -/// `NetError` when the backend's IDP is unreachable. -#[tracing::instrument( - skip_all, - fields( - site = %infra.site_name, - backend = %infra.backend_kind(), - backend_url = %infra.shasta_base_url, - ) -)] -pub async fn get_api_token( - infra: &InfraContext<'_>, - username: &str, - password: &str, -) -> Result { - tracing::info!(user = %username, "backend: requesting token"); - let started = Instant::now(); - infra - .backend - .get_api_token(username, password) - .await - .inspect(|_| { - tracing::debug!( - user = %username, - elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), - "backend: token issued" - ); - }) - .inspect_err(|e| { - tracing::warn!( - user = %username, - elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), - error = %e, - "backend: token request rejected" - ); - }) -} - -/// Verify that `token` is still accepted by the site's backend. -/// -/// Lightweight probe the CLI calls before kicking off a long-running -/// operation that would otherwise fail mid-flight (e.g. a multi-node -/// power transition followed by a poll loop). Does not return any -/// claim from the token — for that, decode it locally with -/// `jwt_ops` instead. -/// -/// # Errors -/// -/// Whatever -/// [`AuthenticationTrait::validate_api_token`] returns — -/// typically `Unauthorized` for an expired or revoked token. -#[tracing::instrument( - skip_all, - fields( - site = %infra.site_name, - backend = %infra.backend_kind(), - backend_url = %infra.shasta_base_url, - ) -)] -pub async fn validate_api_token( - infra: &InfraContext<'_>, - token: &str, -) -> Result<(), Error> { - tracing::info!("backend: validating token"); - let started = Instant::now(); - infra - .backend - .validate_api_token(token) - .await - .inspect(|()| { - tracing::debug!( - elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), - "backend: token accepted" - ); - }) - .inspect_err(|e| { - tracing::warn!( - elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), - error = %e, - "backend: token validation rejected" - ); - }) -} diff --git a/crates/manta-server/src/service/mod.rs b/crates/manta-server/src/service/mod.rs index 4103ac6b..bac24950 100644 --- a/crates/manta-server/src/service/mod.rs +++ b/crates/manta-server/src/service/mod.rs @@ -30,7 +30,7 @@ //! //! - Cross-cutting: [`authorization`], [`infra_backend`], [`analysis`], //! [`sat_groups`], [`node_ops`], [`ims_ops`]. -//! - Per-resource: [`auth`], [`boot_parameters`], [`configuration`], +//! - Per-resource: [`boot_parameters`], [`configuration`], //! [`group`], [`hardware`], [`image`], [`kernel_parameters`], //! [`node`], [`node_details`], [`power`], [`redfish`], [`session`], //! [`template`]. @@ -38,7 +38,6 @@ //! [`hw_cluster`] (the last is a subdirectory module). pub mod analysis; -pub mod auth; pub mod authorization; pub mod boot_parameters; pub mod cluster; diff --git a/crates/manta-server/src/wire_conv.rs b/crates/manta-server/src/wire_conv.rs index 25bcdcc5..f2c1dc1e 100644 --- a/crates/manta-server/src/wire_conv.rs +++ b/crates/manta-server/src/wire_conv.rs @@ -6,7 +6,7 @@ //! Manta uses two error types (see `CLAUDE.md`'s two-tier error rule): //! //! - `manta_shared::common::error::MantaError` — produced by shared -//! helpers (config loader, audit, JWT, kafka). +//! helpers (config loader, JWT). //! - `manta_backend_dispatcher::error::Error` — used everywhere in //! the server's service / backend_dispatcher layers. //! diff --git a/crates/manta-server/tests/integration.rs b/crates/manta-server/tests/integration.rs index 12c7cd95..22201af9 100644 --- a/crates/manta-server/tests/integration.rs +++ b/crates/manta-server/tests/integration.rs @@ -130,8 +130,6 @@ impl TestFixture { let state = Arc::new(ServerState { sites, console_inactivity_timeout: Duration::from_secs(1800), - auditor: None, - auth_rate_limit_per_minute: None, request_timeout: Duration::from_secs(60), shutdown_grace_period: Duration::from_secs(30), migrate_backup_root: None, diff --git a/crates/manta-server/tests/server_routes.rs b/crates/manta-server/tests/server_routes.rs index 4d3803b8..17b90652 100644 --- a/crates/manta-server/tests/server_routes.rs +++ b/crates/manta-server/tests/server_routes.rs @@ -49,8 +49,6 @@ fn router() -> axum::Router { let state = Arc::new(ServerState { sites, console_inactivity_timeout: std::time::Duration::from_secs(1800), - auditor: None, - auth_rate_limit_per_minute: None, request_timeout: std::time::Duration::from_secs(60), shutdown_grace_period: std::time::Duration::from_secs(30), migrate_backup_root: None, @@ -78,8 +76,6 @@ fn router_with_vault() -> axum::Router { let state = Arc::new(ServerState { sites, console_inactivity_timeout: std::time::Duration::from_secs(1800), - auditor: None, - auth_rate_limit_per_minute: None, request_timeout: std::time::Duration::from_secs(60), shutdown_grace_period: std::time::Duration::from_secs(30), migrate_backup_root: None, @@ -121,26 +117,6 @@ fn post_json(uri: &str, body: &str) -> Request { .unwrap() } -/// Build an **unauthenticated** POST to a public `/auth/*` endpoint -/// for an arbitrary `X-Manta-Site`. No Bearer token (these endpoints -/// are how a token is obtained). The `ConnectInfo` extension is -/// injected so `auth_token`'s `ConnectInfo` extractor -/// resolves under `oneshot` (which doesn't set it) and the handler -/// body — where the site lookup lives — actually runs. -fn post_auth(uri: &str, site: &str, body: &str) -> Request { - let mut req = Request::builder() - .method(Method::POST) - .uri(uri) - .header(header::CONTENT_TYPE, "application/json") - .header("X-Manta-Site", site) - .body(Body::from(body.to_string())) - .unwrap(); - req.extensions_mut().insert(axum::extract::ConnectInfo( - "127.0.0.1:65000".parse::().unwrap(), - )); - req -} - // --------------------------------------------------------------------------- // Health check // --------------------------------------------------------------------------- @@ -510,8 +486,6 @@ async fn all_post_routes_are_registered() { "/v2/sat-file/session-templates", "/v2/hardware-clusters/my-cluster/members", "/v2/hardware-clusters/my-cluster/configuration", - "/v2/auth/token", - "/v2/auth/validate", ] { assert_route_exists(Method::POST, uri).await; } @@ -650,81 +624,23 @@ async fn console_session_without_auth_returns_401() { } // --------------------------------------------------------------------------- -// Auth endpoints — unknown site is reported as 404 (issue #102) -// -// The public `/auth/*` endpoints used to collapse every failure -// (including "site not configured") into a generic 401. They now 404 -// on an unknown `X-Manta-Site`, matching every authenticated endpoint, -// so the CLI can fail fast instead of prompting for credentials that -// can never succeed. A *known* site must NOT 404 — the backend call is -// attempted and its failure stays a generic 401. +// Auth endpoints are gone — the CLI obtains its token from the CSCS +// Keycloak directly, so the server no longer handles credentials. // --------------------------------------------------------------------------- #[tokio::test] -async fn auth_token_unknown_site_returns_404() { - let resp = router() - .oneshot(post_auth( - "/v2/auth/token", - "nonexistent-site", - r#"{"username":"alice","password":"hunter2"}"#, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); - let body = body_string(resp.into_body()).await; - assert!( - body.contains("nonexistent-site"), - "404 body should name the missing site, got: {body}" - ); -} - -#[tokio::test] -async fn auth_validate_unknown_site_returns_404() { - let resp = router() - .oneshot(post_auth( - "/v2/auth/validate", - "nonexistent-site", - r#"{"token":"some-token"}"#, - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); - let body = body_string(resp.into_body()).await; - assert!( - body.contains("nonexistent-site"), - "404 body should name the missing site, got: {body}" - ); -} - -#[tokio::test] -async fn auth_token_known_site_is_not_404() { - // Site "test" exists; the stub backend at http://stub.invalid can't - // be reached, so the credential exchange fails — but as a generic - // 401, never a 404 (which is reserved for unknown sites). - let resp = router() - .oneshot(post_auth( - "/v2/auth/token", - "test", - r#"{"username":"alice","password":"hunter2"}"#, - )) - .await - .unwrap(); - assert_ne!(resp.status(), StatusCode::NOT_FOUND); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); -} - -#[tokio::test] -async fn auth_validate_known_site_is_not_404() { - let resp = router() - .oneshot(post_auth( - "/v2/auth/validate", - "test", - r#"{"token":"some-token"}"#, - )) - .await - .unwrap(); - assert_ne!(resp.status(), StatusCode::NOT_FOUND); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +async fn auth_routes_are_not_registered() { + for uri in ["/v2/auth/token", "/v2/auth/validate"] { + let req = Request::builder() + .method(Method::POST) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json") + .header("X-Manta-Site", "test") + .body(Body::from("{}")) + .unwrap(); + let resp = router().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND, "{uri} still routed"); + } } // --------------------------------------------------------------------------- diff --git a/crates/manta-shared/src/common/config/mod.rs b/crates/manta-shared/src/common/config/mod.rs index d5a4de93..604d7f70 100644 --- a/crates/manta-shared/src/common/config/mod.rs +++ b/crates/manta-shared/src/common/config/mod.rs @@ -291,7 +291,7 @@ root_ca_cert_file = "alps_root_cert.pem" /// Migration mapping shown when a legacy `config.toml` is detected. const CLI_CONFIG_MIGRATION: &str = "\ Migration from ~/.config/manta/config.toml: - copy these fields verbatim: log, site, auditor, sites + copy these fields verbatim: log, site, sites add CLI-only (now required): manta_server_url = \"https://...\" (CLI talks only to the manta server) drop (no longer recognised): sites..manta_server_url, audit_file @@ -307,7 +307,6 @@ port = 8443 cert = "/path/to/server.crt" key = "/path/to/server.key" console_inactivity_timeout_secs = 1800 -auth_rate_limit_per_minute = 60 # per source IP for /auth/*; omit to disable # Values shown for the two timeout knobs are the built-in defaults — # delete a line to fall back to the default, or change to override. request_timeout_secs = 300 # global per-route timeout; returns 408 on expiry @@ -319,12 +318,6 @@ shutdown_grace_period_secs = 30 # drain window after SIGTERM / Ctrl+C; # while this is unset. Must be an absolute path to an existing directory. # migrate_backup_root = "/var/lib/manta/migrate" -# [auditor.kafka] # optional: enable Kafka audit emission -# brokers = ["kafka.example.com:9092"] -# topic = "manta-audit" -# message_timeout_ms = 5000 # librdkafka per-message delivery deadline; default 5000 -# delivery_wait_secs = 0 # how long produce_message blocks; 0 = fire-and-forget (default) - [sites.] backend = "csm" shasta_base_url = "https://api.example.com" @@ -334,7 +327,7 @@ root_ca_cert_file = "/path/to/alps_root_cert.pem" /// Migration mapping shown when a legacy `config.toml` is detected. const SERVER_CONFIG_MIGRATION: &str = "\ Migration from ~/.config/manta/config.toml: - copy these fields verbatim: log, auditor, sites + copy these fields verbatim: log, sites add new [server] section: listen_address, port, cert, key, console_inactivity_timeout_secs drop (CLI-only): site, hsm_group, manta_server_url diff --git a/crates/manta-shared/src/common/error.rs b/crates/manta-shared/src/common/error.rs index d68b7de5..fef2c368 100644 --- a/crates/manta-shared/src/common/error.rs +++ b/crates/manta-shared/src/common/error.rs @@ -2,7 +2,7 @@ //! //! Used by the shared `config` loader and re-used by binary-side //! helpers that build on it: `manta-cli`'s SAT-file Jinja renderer and -//! `manta-server`'s `audit`, `jwt_ops`, and `kafka` modules. Lets +//! `manta-server`'s `jwt_ops` module. Lets //! `manta-shared` (and therefore `manta-cli`) avoid pulling in //! `manta_backend_dispatcher::error::Error` for its own error surface. //! diff --git a/crates/manta-shared/src/lib.rs b/crates/manta-shared/src/lib.rs index a66a8e9b..4253fc87 100644 --- a/crates/manta-shared/src/lib.rs +++ b/crates/manta-shared/src/lib.rs @@ -8,10 +8,10 @@ //! - [`common`] — bi-binary behavioural helpers: the [`common::config`] //! loader (returns an untyped `::config::Config`), //! [`common::error::MantaError`], and -//! [`common::log_ops::configure`]. Single-binary helpers (`audit`, -//! `kafka`, `jwt_ops`, the SAT-file Jinja renderer) and the typed -//! config schemas (`CliConfiguration`, `ServerConfiguration`, -//! `Auditor`/`Kafka`) live with whichever binary uses them. +//! [`common::log_ops::configure`]. Single-binary helpers (`jwt_ops`, +//! the SAT-file Jinja renderer) and the typed config schemas +//! (`CliConfiguration`, `ServerConfiguration`) live with whichever +//! binary uses them. //! //! The backend bridge (`StaticBackendDispatcher`, the CSM/OCHAMI trait //! impls, and `authorization` helpers that take a `&StaticBackendDispatcher`) diff --git a/crates/manta-shared/src/types/auth.rs b/crates/manta-shared/src/types/auth.rs deleted file mode 100644 index cb3bf7cd..00000000 --- a/crates/manta-shared/src/types/auth.rs +++ /dev/null @@ -1,55 +0,0 @@ -//! Wire types for the `POST /v2/auth/{token,validate}` endpoints. -//! -//! Carried over the wire by both `manta-cli` (sending requests via -//! `MantaClient`) and `manta-server` (deserializing them in handlers). - -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; - -/// Request body for `POST /v2/auth/token`. -/// -/// Paired with [`AuthTokenResponse`] on success. The `/auth/*` -/// sub-router is wrapped by `strip_body_for_logs`, so neither the -/// request body nor the issued token appears in access logs. -/// -/// # Wire shape -/// -/// ```json -/// { "username": "alice", "password": "hunter2" } -/// ``` -#[derive(Debug, Deserialize, Serialize, ToSchema)] -pub struct AuthTokenRequest { - /// Keycloak username submitted to the configured backend. - pub username: String, - /// Keycloak password. Never logged by the server (the - /// `/auth/*` sub-router is wrapped by `strip_body_for_logs`). - pub password: String, -} - -/// Response body for `POST /v2/auth/token`. -/// -/// Returned in exchange for a valid [`AuthTokenRequest`]. -/// -/// # Wire shape -/// -/// ```json -/// { "token": "eyJhbGciOi..." } -/// ``` -#[derive(Debug, Deserialize, Serialize, ToSchema)] -pub struct AuthTokenResponse { - /// Bearer token issued by the backend (CSM or OpenCHAMI Keycloak). - /// Pass this as `Authorization: Bearer ` on every - /// subsequent request. - pub token: String, -} - -/// Request body for `POST /v2/auth/validate`. -/// -/// Used to check a previously-issued [`AuthTokenResponse::token`] -/// before relying on it. The server returns `200 OK` for a valid -/// token and `401` otherwise — there is no dedicated response body. -#[derive(Debug, Deserialize, Serialize, ToSchema)] -pub struct ValidateTokenRequest { - /// Bearer token to validate against the backend. - pub token: String, -} diff --git a/crates/manta-shared/src/types/mod.rs b/crates/manta-shared/src/types/mod.rs index bcb7aea1..06e47cf0 100644 --- a/crates/manta-shared/src/types/mod.rs +++ b/crates/manta-shared/src/types/mod.rs @@ -4,7 +4,7 @@ //! Everything in this module is "wire-shaped" data: request/response //! bodies, query-string structs, and CLI-built parameter structs //! (`api/`), response DTOs re-exported from upstream crates (`dto`), -//! auth wire shapes (`auth`), plus pure helpers that operate on those +//! plus pure helpers that operate on those //! types (`cluster_status`). There is no business logic and no I/O; //! this module depends only on `serde`, `utoipa`, and //! `manta-backend-dispatcher` type re-exports. Anything that performs @@ -12,6 +12,5 @@ //! [`super::common`]. pub mod api; -pub mod auth; pub mod cluster_status; pub mod dto; From dc6924348bc38cfc64a863015d3716970501e698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20Janal=C3=ADk?= Date: Thu, 24 Sep 2026 11:35:08 +0200 Subject: [PATCH 3/3] docs: document CSCS-Keycloak login and the removed server auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Describe the new authentication model everywhere it was documented: the CLI logs in to the CSCS Keycloak itself (browser + PKCE, device code when headless, silent refresh), caches one token.json for every site, and reads MANTA_TOKEN for scripts; manta-server has no /v2/auth endpoints, rate limiter or Kafka auditor any more. - README / examples: oidc_* cli.toml keys, token resolution order, MANTA_TOKEN, drop auth_rate_limit_per_minute and [auditor.kafka]. - API.md: drop the /auth/* reference and recipes; "Get a token" via the CLI's cache. - ARCHITECTURE.md / SECURITY.md: security model, controls tables, middleware diagram, audit trail, sequence diagram. - GUIDE.md: token-resolution flowchart for scripts. - MIGRATING.md: new §5.14 plus the v1->v2 sections that described the old flow. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MPaEy7U15cDibrbtL2Ei7J --- API.md | 93 ++++++------------------------------ ARCHITECTURE.md | 57 ++++++++++------------ CLAUDE.md | 8 ++-- CLI.md | 2 +- GUIDE.md | 33 ++++++------- MIGRATING.md | 109 +++++++++++++++++++++++++++++-------------- README.md | 28 ++++++----- SECURITY.md | 100 +++++++++++++++++++-------------------- examples/cli.toml | 13 ++++++ examples/server.toml | 15 ------ 10 files changed, 213 insertions(+), 245 deletions(-) diff --git a/API.md b/API.md index 9fb056a4..75a787a8 100644 --- a/API.md +++ b/API.md @@ -8,8 +8,8 @@ The manta HTTP server (`manta-server` binary) exposes a REST + WebSocket API. Th - **Base URL:** `https://:8443/v2` - **Test-environment shortcut:** `manta-server --allow-http --port 8080` starts the server on plain HTTP without needing any cert/key material. Use only against `localhost` or behind an upstream TLS terminator — bearer tokens travel in cleartext otherwise. The flag also has a config-file equivalent, `[server] allow_http = true`. -- **Auth:** every request needs `X-Manta-Site: ` + `Authorization: Bearer `, except for `/health`, `/openapi.json`, `/docs`, and `/v2/auth/*`. -- **Bootstrap a token:** `POST /v2/auth/token` with `{ "username": "...", "password": "..." }` → returns `{ "token": "..." }` from the configured backend. +- **Auth:** every request needs `X-Manta-Site: ` + `Authorization: Bearer `, except for `/health`, `/openapi.json`, and `/docs`. +- **Getting a token:** the token is issued by the CSCS Keycloak, not by the server — one token for every site. The easiest way is to log in once with the `manta` CLI and reuse its cached token (see [Get a token](#get-a-token)). - **Reads / writes:** standard `GET` / `POST` / `PUT` / `DELETE` per resource (sessions, configurations, nodes, groups, images, templates, boot/kernel parameters, redfish endpoints, hardware, group inventory, migrations, SAT files, power, ephemeral envs). - **Streaming:** SSE for CFS session logs (`GET /sessions/{name}/logs`); WebSocket upgrades for interactive consoles (`/nodes/{xname}/console`, `/sessions/{name}/console`). - **Errors:** uniform JSON `{ "error": "..." }` body with conventional HTTP status codes; see the table below. @@ -32,11 +32,11 @@ Every endpoint requires two headers: | Header | Description | |--------|-------------| | `X-Manta-Site` | Site name as configured in `server.toml` `[sites.X]` (e.g. `cscs_prod`) | -| `Authorization` | `Bearer ` — **not** required for `/health`, `/openapi.json`, `/docs`, or `/v2/auth/*` | +| `Authorization` | `Bearer ` (issued by the CSCS Keycloak) — **not** required for `/health`, `/openapi.json`, or `/docs` | ``` X-Manta-Site: cscs_prod -Authorization: Bearer +Authorization: Bearer ``` ## Base URL @@ -1795,55 +1795,7 @@ curl -k -X POST "$MANTA_HOST/v2/sat-file/session-templates" \ ## Authentication -The CLI obtains a bearer token by exchanging Keycloak credentials through the server. These endpoints **do not** themselves require an `Authorization` header (they're the bootstrap), but they do require `X-Manta-Site` so the server can pick the right backend. They sit under `/v2/auth/*` behind a per-source-IP rate limiter (`[server].auth_rate_limit_per_minute`, default 60) and a body-redaction logging layer. - -### POST /auth/token - -Exchange username + password for a backend bearer token. - -**Request body** - -```json -{ "username": "alice", "password": "..." } -``` - -**Response `200`** - -```json -{ "token": "" } -``` - -**Response `401`** — `{ "error": "invalid credentials" }`. The body is intentionally generic regardless of whether the user was unknown or the password was wrong; detail is kept in server-side logs only. - -```bash -curl -k -X POST "$MANTA_HOST/v2/auth/token" \ - -H "X-Manta-Site: $MANTA_SITE" \ - -H 'Content-Type: application/json' \ - -d '{"username":"alice","password":"..."}' -``` - ---- - -### POST /auth/validate - -Check whether a bearer token is still accepted by the backend. - -**Request body** - -```json -{ "token": "" } -``` - -**Response `200`** — no body. The token is currently valid. - -**Response `401`** — `{ "error": "invalid credentials" }`. The token is missing, malformed, or rejected by the backend. - -```bash -curl -k -X POST "$MANTA_HOST/v2/auth/validate" \ - -H "X-Manta-Site: $MANTA_SITE" \ - -H 'Content-Type: application/json' \ - -d "{\"token\":\"$MANTA_TOKEN\"}" -``` +The server has no authentication endpoints and never sees credentials. Clients obtain a bearer token from the CSCS Keycloak themselves — the `manta` CLI does it with an OIDC login (see [README.md](README.md#configuration-files)) — and send it as `Authorization: Bearer `. The same token works for every site; the server forwards it unchanged to the site's backend (CSM / OpenCHAMI API, Vault, k8s), which verifies it. --- @@ -1984,11 +1936,10 @@ If a request fails before reaching the service layer, you'll get one of the code | Status | Most common cause | |---|---| | **400 Bad Request** | Missing/malformed `X-Manta-Site` header, missing JSON body, or body not parseable as the declared `request_body` type. | -| **401 Unauthorized** | No `Authorization: Bearer …` (on a protected endpoint), token expired, or `/auth/token` credentials rejected by the backend. | +| **401 Unauthorized** | No `Authorization: Bearer …` (on a protected endpoint), the token expired, or the site's backend does not accept tokens from the CSCS Keycloak. | | **404 Not Found** | Wrong URL path or the resource ID does not exist for the active site. | | **405 Method Not Allowed** | Sent `GET` to a `POST`-only endpoint (or vice versa) — `curl` defaults to `GET` when `-X` is omitted. | | **408 Request Timeout** | The handler took longer than `[server].request_timeout_secs` (default **600**, i.e. 10 min — bumped from 300 in beta.55 after large multi-site fetches consistently grazed the 5-min ceiling). Most endpoints return well under a second; the 10-min ceiling exists for the few operations that legitimately fan out across the upstream backend (large bulk CFS component fetches, SAT-file applies, migrate-restore re-hydrations). `POST /power` returns immediately with the PCS transition id and the CLI polls `GET /power/transitions/{id}` for completion, so 408 there indicates an unhealthy backend. | -| **429 Too Many Requests** | Per-source-IP rate limit on `/v2/auth/*`. Tune `[server].auth_rate_limit_per_minute` or wait one minute. | | **500 Internal Server Error** | Server-side failure (backend unreachable, bad config). Check `journalctl -u manta-server` (or wherever the server's stderr is logged) for the actual cause. | | **501 Not Implemented** | The endpoint needs Vault or Kubernetes settings that the active site does not provide — see [Server configuration requirements](#server-configuration-requirements). | @@ -2001,7 +1952,7 @@ The recipes below assume: ```bash export MANTA_HOST=https://localhost:8443 export MANTA_SITE=alps -export MANTA_TOKEN=... # see "Bootstrap a token" below +export MANTA_TOKEN=... # see "Get a token" below ``` For a local server running plain HTTP (no `cert`/`key` configured), use `MANTA_HOST=http://localhost:8443` and drop the `-k` flag. @@ -2014,33 +1965,17 @@ curl -k "$MANTA_HOST/openapi.json" | jq .info # Open in browser: $MANTA_HOST/docs ``` -### Bootstrap a token - -```bash -curl -k -X POST "$MANTA_HOST/v2/auth/token" \ - -H "X-Manta-Site: $MANTA_SITE" \ - -H 'Content-Type: application/json' \ - -d '{"username":"","password":""}' -# → { "token": "..." } -``` +### Get a token -Then export it for the recipes below: +Log in once with the CLI (any command that talks to the server triggers the login), then reuse the token it cached: ```bash -export MANTA_TOKEN=$(curl -ks -X POST "$MANTA_HOST/v2/auth/token" \ - -H "X-Manta-Site: $MANTA_SITE" -H 'Content-Type: application/json' \ - -d '{"username":"","password":""}' | jq -r .token) +manta get groups >/dev/null # logs in if needed and caches the token +# Linux; on macOS the cache is ~/Library/Caches/local.cscs.manta/token.json +export MANTA_TOKEN=$(jq -r .access_token ~/.cache/manta/token.json) ``` -### Validate a token - -```bash -curl -k -X POST "$MANTA_HOST/v2/auth/validate" \ - -H "X-Manta-Site: $MANTA_SITE" \ - -H 'Content-Type: application/json' \ - -d "{\"token\":\"$MANTA_TOKEN\"}" -# 200 OK = valid, 401 = rejected -``` +The access token is short-lived; rerun the CLI to refresh the cache, then re-export. ### GET a resource @@ -2092,4 +2027,4 @@ websocat -k --header "X-Manta-Site: $MANTA_SITE" \ 1. Bump the server log: `log = "debug"` in `server.toml`, restart. 2. Re-issue the request; the server now logs which extractor rejected (site lookup vs. JSON parse vs. backend call) and the round-trip into csm-rs/ochami-rs. -3. For auth failures, the server logs the user/site/source-IP and the backend error message — the client only sees a generic `invalid credentials` 401 on purpose. +3. For a 401, check the token's `exp` and `iss` claims (`cut -d. -f2 <<<"$MANTA_TOKEN" | base64 -d 2>/dev/null | jq`): an expired token needs a fresh login, and a site that rejects a valid CSCS token does not trust that issuer yet. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 64a9a646..387ea75a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -30,7 +30,7 @@ A fourth directory, `crates/manta-cache/`, exists in the tree but is **not** a w The backend bridge (`StaticBackendDispatcher` enum and the trait-impl blocks routing to `csm-rs`/`ochami-rs`, plus the `authorization` helpers that take a `&StaticBackendDispatcher`) lives in **`manta-server` only** (`crates/manta-server/src/backend_dispatcher/mod.rs`, `dispatcher.rs`, `service/authorization.rs`). The CLI never reaches them. -`manta-cli` keeps its CLI-only modules under `crates/manta-cli/src/common/` (e.g. `app_context::AppContext`, `config::CliConfiguration`, `authentication`, `hooks`, `confirm`, `multi_line`); the SAT-file Jinja renderer lives next to its caller at `crates/manta-cli/src/dispatch/apply/sat_file/render.rs`. `manta-server` keeps cross-tier helpers under `crates/manta-server/src/server/common/` (`app_context::InfraContext`, `audit`, `jwt_ops`, `kafka`, `vault`) and its typed server config schema at `crates/manta-server/src/config.rs`. The bulk of service-tier orchestration (`node_ops`, `authorization`, `ims_ops`, `boot_parameters`, plus the `hw_cluster` family) lives under `crates/manta-server/src/service/`. +`manta-cli` keeps its CLI-only modules under `crates/manta-cli/src/common/` (e.g. `app_context::AppContext`, `config::CliConfiguration`, `authentication`, `oidc`, `hooks`, `confirm`, `multi_line`); the SAT-file Jinja renderer lives next to its caller at `crates/manta-cli/src/dispatch/apply/sat_file/render.rs`. `manta-server` keeps cross-tier helpers under `crates/manta-server/src/server/common/` (`app_context::InfraContext`, `jwt_ops`, `vault`) and its typed server config schema at `crates/manta-server/src/config.rs`. The bulk of service-tier orchestration (`node_ops`, `authorization`, `ims_ops`, `boot_parameters`, plus the `hw_cluster` family) lives under `crates/manta-server/src/service/`. --- @@ -39,7 +39,8 @@ The backend bridge (`StaticBackendDispatcher` enum and the trait-impl blocks rou ```mermaid flowchart LR User((User)) --> CLI[manta CLI] - CLI -->|HTTPS| Server[manta-server] + CLI -->|OIDC login| KC[(CSCS Keycloak)] + CLI -->|HTTPS + Bearer| Server[manta-server] CLI -. shares .-> Shared Server -. shares .-> Shared @@ -77,7 +78,7 @@ Each binary has its own `main.rs`: Startup runs in two phases: 1. **Single-threaded phase** — parse CLI args, load `cli.toml` from the platform config directory (Linux: `~/.config/manta/cli.toml` or `$XDG_CONFIG_HOME/manta/cli.toml`; macOS: `~/Library/Application Support/local.cscs.manta/cli.toml`) into a `CliConfiguration`. If the optional top-level `socks5_proxy` is set, export `SOCKS5` so `reqwest` picks it up for connections to manta-server. -2. **Multi-threaded phase** — start the tokio runtime, build an `AppContext` (site name, manta-server URL, default HSM group, timeout/poll knobs, raw settings) and hand it to `dispatch::process::process_cli`, which runs the read-only gate → token cascade → `SessionContext` build before routing to the verb. The CLI never instantiates `StaticBackendDispatcher` — every backend operation goes through `MantaClient` HTTPS calls to manta-server, and it emits no audit events of its own (that is server-side only). +2. **Multi-threaded phase** — start the tokio runtime, build an `AppContext` (site name, manta-server URL, default HSM group, timeout/poll knobs, raw settings) and hand it to `dispatch::process::process_cli`, which runs the read-only gate → token cascade → `SessionContext` build before routing to the verb. The token cascade is the only place the CLI talks to anything but manta-server: it logs in to the CSCS Keycloak directly (`common::oidc`). The CLI never instantiates `StaticBackendDispatcher` — every backend operation goes through `MantaClient` HTTPS calls to manta-server. ### `crates/manta-server/src/main.rs` @@ -149,9 +150,9 @@ Genuinely bi-binary helpers: | `config/` | Load `cli.toml` / `server.toml` — returns an untyped `::config::Config`; each binary deserialises into its own typed schema (`CliConfiguration` in `manta-cli`, `ServerConfiguration` in `manta-server`) | | `error` | `MantaError` enum — error type for pure helpers (no backend-dispatcher dep) | | `log_ops` | Logger initialisation; both binaries call `log_ops::configure(...)` on startup | -| `jwt_ops` | JWT **claim extraction** for the audit + authorization paths. Canonical home since the move out of the server; `crates/manta-server/src/server/common/jwt_ops.rs` is now a thin `pub use` shim kept so existing call sites resolve — new code should use `manta_shared::common::jwt_ops` directly. **These helpers do not verify the JWT signature** (see [Security model](#security-model)). | +| `jwt_ops` | JWT **claim extraction** for the authorization paths and the CLI's `SessionContext`. Canonical home since the move out of the server; `crates/manta-server/src/server/common/jwt_ops.rs` is now a thin `pub use` shim kept so existing call sites resolve — new code should use `manta_shared::common::jwt_ops` directly. **These helpers do not verify the JWT signature** (see [Security model](#security-model)). | -CLI-only modules live under `crates/manta-cli/src/common/` (`app_context::AppContext`, `authentication`, `clap_ext` ArgMatches extension trait, `config::CliConfiguration`, `confirm` y/n prompt, `hooks` pre-/post-hook runner, `multi_line` table-cell wrapper, `read_only` local mutating-verb gate, `session::SessionContext` per-invocation JWT + reachable-groups snapshot). The SAT-file Jinja renderer + the run-session local-git-repo helpers live next to their callers at `crates/manta-cli/src/dispatch/apply/sat_file/render.rs` and `crates/manta-cli/src/dispatch/run/session/local_git_repo.rs`. Server-only helpers live under `crates/manta-server/src/server/common/` (`app_context::InfraContext`, `audit`, `kafka`, `vault`, plus `jwt_ops` — a re-export shim over `manta_shared::common::jwt_ops`); the typed `ServerConfiguration` sits at `crates/manta-server/src/config.rs`. Modules that orchestrate backend calls (`authorization`, `node_ops`, `ims_ops`, `boot_parameters`, `hw_cluster::hw_inventory_utils`) live under `crates/manta-server/src/service/` since they're service-tier logic, not handler helpers. +CLI-only modules live under `crates/manta-cli/src/common/` (`app_context::AppContext`, `authentication` token cascade, `oidc` CSCS-Keycloak login flows, `clap_ext` ArgMatches extension trait, `config::CliConfiguration`, `confirm` y/n prompt, `hooks` pre-/post-hook runner, `multi_line` table-cell wrapper, `read_only` local mutating-verb gate, `session::SessionContext` per-invocation JWT + reachable-groups snapshot). The SAT-file Jinja renderer + the run-session local-git-repo helpers live next to their callers at `crates/manta-cli/src/dispatch/apply/sat_file/render.rs` and `crates/manta-cli/src/dispatch/run/session/local_git_repo.rs`. Server-only helpers live under `crates/manta-server/src/server/common/` (`app_context::InfraContext`, `vault`, plus `jwt_ops` — a re-export shim over `manta_shared::common::jwt_ops`); the typed `ServerConfiguration` sits at `crates/manta-server/src/config.rs`. Modules that orchestrate backend calls (`authorization`, `node_ops`, `ims_ops`, `boot_parameters`, `hw_cluster::hw_inventory_utils`) live under `crates/manta-server/src/service/` since they're service-tier logic, not handler helpers. ### `crates/manta-server/src/server/` @@ -160,7 +161,7 @@ Axum HTTPS server. Key files: | File | Purpose | |------|---------| | `mod.rs` | `start_server` — binds TLS, builds router, logs to stderr when the socket is ready to accept connections | -| `routes.rs` | Registers REST endpoints (including the two `/v2/auth/*` endpoints) + 2 WebSocket upgrades under `/v2/`; serves `GET /openapi.json` and `GET /docs` | +| `routes.rs` | Registers REST endpoints + 2 WebSocket upgrades under `/v2/`; serves `GET /openapi.json` and `GET /docs` | | `handlers/` | Module tree: parent `mod.rs` (extractors `BearerToken`/`SiteName`/`RequestCtx`, `ErrorResponse` + `to_handler_error`, guard helpers, `/health`) plus per-resource sub-modules (analysis, auth, boot_parameters, cluster, configuration, console, ephemeral_env, group, hardware, hw_cluster, image, kernel_parameters, migrate, node, power, redfish_endpoints, runtime_configuration, sat_file, session, template). External callers reference `handlers::X` unchanged via `pub use ::*` re-exports. | | `api_doc.rs` | `ApiDoc` struct — assembles the OpenAPI 3.0 spec from all `#[utoipa::path]` annotations; adds `bearerAuth` security scheme and `/v2` server base path | @@ -196,7 +197,7 @@ flowchart TD | Type | Used by | Contents | |------|---------|---------| | `InfraContext<'_>` | Service layer (server-only, in `crates/manta-server/src/server/common/app_context.rs`) | Backend dispatcher, site name, shasta + gitea base URLs, root CA cert, optional vault + k8s URLs (7 borrowed fields) | -| `AppContext<'_>` | CLI layer (in `crates/manta-cli/src/common/app_context.rs`, flat 13-field struct) | `site_name`, `manta_server_url`, `settings_group_name_opt`, `request_timeout_secs`, `power_poll_interval_secs`, `power_max_poll_attempts`, `sat_file_poll_interval_secs`, `sat_file_poll_budget_secs`, `sat_file_not_visible_budget_secs`, `read_only`, `settings`, plus two fields populated by `process_cli` rather than by `cli.toml`: `token` (the resolved bearer token) and `session` (`Option` — JWT-derived facts + one `GET /groups/available`, cached for the command's lifetime so no handler re-runs the auth cascade). The poll/budget knobs are user-tunable from `cli.toml` and feed the dispatcher's compiled defaults when unset. | +| `AppContext<'_>` | CLI layer (in `crates/manta-cli/src/common/app_context.rs`, flat 14-field struct) | `site_name`, `manta_server_url`, `settings_group_name_opt`, `request_timeout_secs`, `power_poll_interval_secs`, `power_max_poll_attempts`, `sat_file_poll_interval_secs`, `sat_file_poll_budget_secs`, `sat_file_not_visible_budget_secs`, `read_only`, `oidc` (Keycloak issuer / client id / headless flag, `cli.toml`'s `oidc_*` keys with defaults filled in), `settings`, plus two fields populated by `process_cli` rather than by `cli.toml`: `token` (the resolved bearer token) and `session` (`Option` — JWT-derived facts + one `GET /groups/available`, cached for the command's lifetime so no handler re-runs the auth cascade). The poll/budget knobs are user-tunable from `cli.toml` and feed the dispatcher's compiled defaults when unset. | | `Arc` | HTTP server | Infrastructure behind a reference-counted pointer; each handler calls `.infra_context()` | `manta_server_url` is a CLI routing decision — proxy requests through the manta HTTP server instead of calling the backend directly. It is not needed by the service layer or the HTTP server. @@ -220,7 +221,7 @@ The two schemas are disjoint: | Schema | Fields | |---|---| | `CliConfiguration` | `log`, `site` (active), top-level `manta_server_url`, optional top-level `socks5_proxy`, optional top-level `request_timeout_secs`. **No `[sites]` map** — CLI only knows about the one manta-server it talks to. The legacy `parent_hsm_group` field was removed (see MIGRATING.md §5.7); the CLI uses `hsm_group` as the default group key. | -| `ServerConfiguration` | `log`, `[server]` (TLS, listen, console timeout, auth rate limit), `auditor`, `sites: HashMap` (per-site backend, URLs, root cert, optional `[sites.X.k8s]` block). | +| `ServerConfiguration` | `log`, `[server]` (TLS, listen, console timeout, request timeout), `sites: HashMap` (per-site backend, URLs, root cert, optional `[sites.X.k8s]` block). | The server has no notion of an "active" site — it hosts every entry in its `sites` table simultaneously, and clients select per-request via the `X-Manta-Site` header. The CLI puts that header on every request based on its own `site = "..."` (overridable with `--site`). @@ -260,8 +261,8 @@ root_ca_cert_file = "ochami_root_cert.pem" | Aspect | CLI | HTTP server | |--------|-----|-------------| | Entry point | `dispatch::process::process_cli` | `server::start_server` | -| Auth source | `MANTA_CSM_TOKEN` env var → cached local file → interactive Keycloak prompt (via `POST /v2/auth/token`) | `Authorization: Bearer` header, per request | -| Context type | `AppContext` (flat 13-field struct in manta-cli) | `Arc` → `infra_context()` | +| Auth source | `MANTA_TOKEN` env var → cached `token.json` (refreshed when expired) → interactive OIDC login against the CSCS Keycloak | `Authorization: Bearer` header, per request | +| Context type | `AppContext` (flat 14-field struct in manta-cli) | `Arc` → `infra_context()` | | Error handling | `eprintln!` + `process::exit()` | JSON `{"error": "..."}` with HTTP status code | | Output | Terminal tables / stdout | JSON response body | | Streaming | stdout | SSE (`/sessions/{name}/logs`) or WebSocket (`/nodes/{xname}/console`) | @@ -274,7 +275,7 @@ root_ca_cert_file = "ochami_root_cert.pem" Three error types, partitioned by layer (the backend-dispatcher rule is enforced by CI): - **`manta_backend_dispatcher::error::Error`** (`BackendError`) — used in `manta-server`'s service layer and handler boundary (`crates/manta-server/src/{server,service,backend_dispatcher,dispatcher.rs}`). -- **`manta_shared::common::error::MantaError`** — used by `manta-shared`'s pure helpers (config loader). Also raised by binary-side helpers that depend on it (`manta-cli`'s sat-file Jinja renderer, `manta-server`'s `audit`/`jwt_ops`/`kafka`). Lets manta-shared have no compile-time dependency on backend-dispatcher's error surface. Converted to `BackendError` at server call sites via `crates/manta-server/src/wire_conv.rs::to_backend(MantaError) -> BackendError`. +- **`manta_shared::common::error::MantaError`** — used by `manta-shared`'s pure helpers (config loader). Also raised by binary-side helpers that depend on it (`manta-cli`'s sat-file Jinja renderer, `manta-server`'s `jwt_ops`). Lets manta-shared have no compile-time dependency on backend-dispatcher's error surface. Converted to `BackendError` at server call sites via `crates/manta-server/src/wire_conv.rs::to_backend(MantaError) -> BackendError`. - **`anyhow::Error`** — allowed only in `crates/manta-cli/src/` handlers and CLI-only helpers. The HTTP server converts typed errors to HTTP status codes via `to_handler_error` in `crates/manta-server/src/server/handlers/mod.rs`. @@ -298,9 +299,9 @@ The filter directive comes from `[log]` in `cli.toml` / `server.toml` (e.g. `"in ## Security model -`manta-server` is a **credential-handling endpoint**: the CLI POSTs Keycloak username/password to `POST /v2/auth/token`, and the server proxies them to the configured backend (CSM or OCHAMI) via `service::auth::get_api_token`. The CSM bearer token comes back to the CLI; subsequent authenticated endpoints use it via `Authorization: Bearer`. +`manta-server` **never handles credentials**. The CLI logs in to the CSCS Keycloak itself (`crates/manta-cli/src/common/oidc.rs`: authorization code + PKCE in a browser, the device-code flow when headless, refresh-token reuse) and sends the resulting access token as `Authorization: Bearer` on every request. One token serves every site. The server forwards it unchanged to the site's backend — the CSM / OpenCHAMI API, Vault's `jwt-manta-` login, and k8s — so each site must trust the CSCS Keycloak issuer. There are no `/v2/auth/*` endpoints. -After Phase 7, the CLI never constructs `StaticBackendDispatcher` and never calls a backend trait method at runtime. Every CLI command (including auth, group-listing, and the previously-direct `apply_session` / `add hardware` / `migrate nodes` / `config_*` paths) goes through `MantaClient`. `AppContext` is a flat 13-field struct of CLI-side knobs (site, server URL, default group, read-only flag, tuneable request and poll timeouts) plus the per-invocation token and `SessionContext`; the server holds all real infra (TLS, backend dispatcher, Vault, k8s). +After Phase 7, the CLI never constructs `StaticBackendDispatcher` and never calls a backend trait method at runtime. Every CLI command (including group-listing and the previously-direct `apply_session` / `add hardware` / `migrate nodes` / `config_*` paths) goes through `MantaClient`. `AppContext` is a flat 14-field struct of CLI-side knobs (site, server URL, default group, read-only flag, Keycloak login settings, tuneable request and poll timeouts) plus the per-invocation token and `SessionContext`; the server holds all real infra (TLS, backend dispatcher, Vault, k8s). Server-side authorization helpers live in `service::authorization`: @@ -308,7 +309,7 @@ Server-side authorization helpers live in `service::authorization`: - `validate_user_group_members_access` — every xname in the request must be a member of an accessible group. - `validate_ansible_limit_membership_access` — the same membership check applied to a comma-separated `ansible_limit` string. -Those helpers read the caller's roles and groups out of the bearer token via `manta_shared::common::jwt_ops`, which **decodes claims without verifying the JWT signature**. That is sound only because every authorized path still makes a backend round-trip, and CSM/OpenCHAMI verifies the signature there — a forged token is rejected at the first real call. The rule this imposes: **never add a code path that acts on a JWT claim and returns before the backend call happens** (a local cache, a short-circuit, a handler that only consults local roles). `is_user_admin` is exactly such a short-circuit, so any new use of it must still be followed by a backend call. +Those helpers read the caller's roles and groups out of the bearer token via `manta_shared::common::jwt_ops`, which **decodes claims without verifying the JWT signature**. That is sound only because every authorized path still makes a backend round-trip, and CSM/OpenCHAMI verifies the signature there (against the CSCS Keycloak) — a forged token is rejected at the first real call. The rule this imposes: **never add a code path that acts on a JWT claim and returns before the backend call happens** (a local cache, a short-circuit, a handler that only consults local roles). `is_user_admin` is exactly such a short-circuit, so any new use of it must still be followed by a backend call. Admin tokens (carrying the `PA_ADMIN` Keycloak role) short-circuit every check. Handlers that operate on a single backend-issued identifier (e.g. `delete_node`, `delete_session`, `add_boot_parameters`) currently rely on backend-side ACLs rather than these helpers; treat any new privileged handler as a candidate for adding the appropriate check. @@ -316,23 +317,21 @@ Admin tokens (carrying the `PA_ADMIN` Keycloak role) short-circuit every check. The wire-type coupling that survived Phase 7 has since been cleaned up: `csm-rs` and `ochami-rs` are gone from `manta-cli`'s transitive deps. `manta-shared::types::dto` now defines a local `NodeDetails` mirror (identical JSON wire shape) instead of re-exporting csm-rs's. `manta-shared::common::error::MantaError` replaced `manta_backend_dispatcher::error::Error` in the pure helpers. The lightweight `manta-backend-dispatcher` crate still appears transitively in the CLI's dep tree for `dto.rs`'s remaining type re-exports (`Group`, `NodeSummary`, `BosSessionTemplate`, `BootParameters`, `CfsConfigurationResponse`, `CfsSessionGetResponse`, `Image`); mirroring those too is a deferred trade-off (~700 LOC vs perpetual mirror maintenance). -This means manta-server is a **single point of compromise** for everyone using it: if it is owned, the attacker gets a chokepoint that sees every auth attempt and holds whatever service-account scoped tokens are configured for the backend. Mitigations split between code and ops: +manta-server is still a **chokepoint** for everyone using it: it never sees passwords, but every request carries a bearer token that works on every site that trusts the CSCS Keycloak, so an attacker who owns the server can replay live tokens until they expire. Login attempts, brute-force protection and their audit trail are the CSCS Keycloak's job now. Mitigations split between code and ops: | Layer | Where | Notes | |---|---|---| -| Per-source-IP rate limit on `/v2/auth/*` | code | `[server].auth_rate_limit_per_minute` (default 60). Implementation in `server::auth_middleware::rate_limit`. | -| Generic 401 on every auth failure | code | `server::handlers::auth_token` returns the same `"invalid credentials"` body regardless of whether the user was unknown or the password was wrong. Detail stays in server-side `tracing::warn!`. | -| Audit event per auth attempt | code | `manta_server::server::common::audit::send_auth_audit` emits `{ outcome, username, source_ip, site }` to the configured Kafka producer. Credentials are never logged. | -| Body redaction on `/auth/*` log spans | code | `server::auth_middleware::strip_body_for_logs`. | -| TLS termination, WAF, reverse-proxy rate limit | **ops** | First line of defence; manta-server's in-process limiter is belt-and-braces. | -| Service-account scoping at CSM / Vault | **ops** | Limit what the manta-server-issued tokens can do at the backend. | +| No credentials on the server | code | Login happens between the CLI and the CSCS Keycloak; the server has no auth endpoints and never sees a password or refresh token. | +| Token cache hygiene on the client | code | `token.json` is written `0600` in a `0700` cache dir; `TokenStore`'s `Debug` redacts every token. | +| TLS termination, WAF, reverse-proxy rate limit | **ops** | First line of defence. | +| Token lifetime and scope in the CSCS Keycloak | **ops** | Short access-token lifetimes bound the replay window; the manta client's claims (`realm_access.roles`, audience) bound what a token can do at the backend. | | Network segmentation | **ops** | Treat manta-server as a privileged host. | ### Middleware layer stack Tower applies layers in **reverse-add order** — the last `.layer()` becomes the outermost middleware. The order below is what an inbound request crosses, top to bottom. -*Flowchart: where each control sits on the two sub-routers.* +*Flowchart: where each control sits.* ```mermaid flowchart TD @@ -342,16 +341,10 @@ flowchart TD Split -->|/v2/*| Tmo[TimeoutLayer
request_timeout_secs] Tmo --> Hdlr[Resource handlers
BearerToken + SiteName + RequestCtx] - Split -->|/v2/auth/*| StripBody[strip_body_for_logs
redacts /auth/* request bodies] - StripBody --> RL[rate_limit
per-source-IP token bucket] - RL --> AuthH[auth_token / auth_validate handlers] - Split -->|/docs, /openapi.json| Swagger[Swagger UI / spec] ``` -The diagram captures three facts that trip up new contributors: the nest split between `/v2/*` and `/v2/auth/*`, the last-added-outermost layer ordering on each sub-router, and which defences live on which path. - -**Deferred:** forwarding the original client IP to Keycloak via `X-Forwarded-For` on the upstream auth call. The current `AuthenticationTrait::get_api_token` signature in `manta-backend-dispatcher` does not take a header argument, so this would require a sibling-repo upgrade (csm-rs + ochami-rs). Tracked as a follow-up. +The timeout covers the `/v2/*` resource router only; Swagger UI and the spec sit outside it. --- @@ -359,7 +352,7 @@ The diagram captures three facts that trip up new contributors: the nest split b A single `tower_http::timeout::TimeoutLayer` lives in `crates/manta-server/src/server/routes.rs::build_router`, configured from `[server].request_timeout_secs` (default 600s). When the timer fires, axum returns `408 REQUEST_TIMEOUT`. -**It covers the resource router only.** The layer is applied to the `api` sub-router (`/v2/*`, including the WebSocket routes merged into it); `/v2/auth/*` is a *separate* sub-router carrying the rate limiter and body redaction, and no timeout is applied to it. So a hung upstream Keycloak on `POST /v2/auth/token` is not bounded by this knob — the middleware diagram above shows the split. Treat that as a known gap rather than a design choice; the fix is to apply the layer on the outer `Router` before the nest split, or to the auth router as well. +The layer is applied to the `api` sub-router (`/v2/*`, including the WebSocket routes merged into it). There's no per-route override on the server. Long-running work runs CLI-side: @@ -382,10 +375,10 @@ The CLI honours `cli.toml`'s optional `request_timeout_secs` via `MantaClient::f | `clap` | CLI argument parsing | | `tokio` | Async runtime | | `minijinja` | Jinja2 template rendering for SAT file processing | -| `rdkafka` | Kafka producer for operation audit trail | | `git2` | Local git repository operations (repo validation, CFS layer source) | | `config` | TOML config file loading with environment variable overrides | | `dialoguer` | Interactive terminal prompts (confirmations, selection lists) | +| `openidconnect` + `webbrowser` | CLI login against the CSCS Keycloak (PKCE, ID-token verification; opening the browser) | | `comfy-table` | Terminal table output | | `reqwest` | HTTP client used by csm-rs and ochami-rs | @@ -420,7 +413,7 @@ Only the CLI has a first-class SOCKS5 knob — `cli.toml`'s optional top-level ` ## Audit trail -Only `manta-server` emits Kafka audit events. Configuration lives under `[auditor.kafka]` in `server.toml` and currently covers `/v2/auth/*` attempts via `send_auth_audit`. Every CLI command goes through HTTP to the server, so the server-side request log + auth-audit stream together cover what the CLI used to record locally. The producer is a lazily-initialised `FutureProducer` in a `OnceLock`; messages are fire-and-forget with a 5-second timeout. Audit calls are made via `common::kafka`. +manta emits no audit stream of its own. Login attempts happen against the CSCS Keycloak, which records them; every CLI command then goes through HTTP to the server, whose request log (`tracing`) records what was done. The former Kafka auditor (`[auditor.kafka]`) covered only the removed `/v2/auth/*` endpoints and was dropped with them. ## Hooks diff --git a/CLAUDE.md b/CLAUDE.md index 74865b7e..2d1e3c2b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ crates/manta-server/ (bin + lib) — Axum HTTPS server + service layer Dep graph: `manta-cli → manta-shared ← manta-server`. The two binaries do **not** depend on each other. -The mental model that explains the whole layout: **the CLI never talks to a backend directly.** Every operation — including auth — is an HTTPS call from `manta` to a running `manta-server`, which holds the per-site credentials and routes to csm-rs/ochami-rs. The entire backend bridge (`StaticBackendDispatcher`, csm-rs, ochami-rs, axum) lives in `manta-server` only; the CLI just formats requests and responses. +The mental model that explains the whole layout: **the CLI never talks to a backend directly.** Every operation is an HTTPS call from `manta` to a running `manta-server`, which holds the per-site infra credentials (Vault, k8s) and routes to csm-rs/ochami-rs. The one exception is login: the CLI gets a single bearer token (valid for every site) from the CSCS Keycloak itself over OIDC (`crates/manta-cli/src/common/oidc.rs`); the server has no auth endpoints and forwards the token unchanged. The entire backend bridge (`StaticBackendDispatcher`, csm-rs, ochami-rs, axum) lives in `manta-server` only; the CLI just formats requests and responses. **`ARCHITECTURE.md` is the authoritative reference** — layer boundaries, the CLI dispatch tree, server request flow + middleware order, the step-by-step "adding a command" recipe, generated code, security model, request timeouts. Read it before any non-trivial change instead of relying on a summary here. `README.md` is the source of truth for config + deploy; `CLI.md`/`API.md`/`GUIDE.md` are user docs; `MIGRATING.md` explains why removed knobs were removed. @@ -55,7 +55,7 @@ RUSTDOCFLAGS="-D rustdoc::broken_intra_doc_links -D rustdoc::invalid_html_tags" ## Error handling (three types, partitioned by layer — CI-enforced) - **`manta_backend_dispatcher::error::Error`** (`BackendError`) — `manta-server`'s service + handler + dispatcher code. -- **`manta_shared::common::error::MantaError`** — `manta-shared`'s pure helpers (`config` loader, `jwt_ops`) and binary-side helpers depending on it (CLI's SAT-file Jinja renderer; server's `audit`/`kafka`). Keeps `manta-shared` free of a compile-time dep on backend-dispatcher's error surface. Converted to `BackendError` at server call sites via `crates/manta-server/src/wire_conv.rs::to_backend`. +- **`manta_shared::common::error::MantaError`** — `manta-shared`'s pure helpers (`config` loader, `jwt_ops`) and binary-side helpers depending on it (CLI's SAT-file Jinja renderer). Keeps `manta-shared` free of a compile-time dep on backend-dispatcher's error surface. Converted to `BackendError` at server call sites via `crates/manta-server/src/wire_conv.rs::to_backend`. - **`anyhow::Error`** — only in `crates/manta-cli/src/` (handlers + CLI helpers, which exit via `eprintln!` + `process::exit()`). **Boundary rule:** server handlers (`crates/manta-server/src/server/handlers/`) call only `service/` functions or `manta-shared` helpers — never CLI code. They map typed errors to HTTP via `to_handler_error` in `server/handlers/mod.rs`. @@ -77,8 +77,8 @@ Only check out a sibling when you're **actively editing unreleased sibling code* Two **disjoint** TOML schemas, one per binary, in the platform config dir (override with `MANTA_CLI_CONFIG` / `MANTA_SERVER_CONFIG`): -- `cli.toml` — `site` (optional; sent as the `X-Manta-Site` header, overridable per-invocation with `--site`), required `manta_server_url`, `hsm_group` (default group), `read_only`, and the timeout/poll knobs. **No `[sites]` block** — the CLI only knows the one server it talks to. `read_only = true` makes the CLI refuse mutating verbs locally before any request leaves the process (`common/read_only.rs`); it is a foot-gun guard, **not** a security control, and has no server-side counterpart. -- `server.toml` — `[server]` (TLS, timeouts, auth rate limit) + a `[sites.]` map. The server hosts **every** site at once; clients pick one per request via `X-Manta-Site`. Fails closed without TLS unless `--allow-http` **or** `[server].allow_http = true` is set (the two are OR-ed). +- `cli.toml` — `site` (optional; sent as the `X-Manta-Site` header, overridable per-invocation with `--site`), required `manta_server_url`, `hsm_group` (default group), `read_only`, the optional `oidc_issuer_url` / `oidc_client_id` / `oidc_headless` login knobs (defaults: CSCS realm; the client id and loopback port in `oidc.rs` are placeholders until the Keycloak client is registered), and the timeout/poll knobs. **No `[sites]` block** — the CLI only knows the one server it talks to. `read_only = true` makes the CLI refuse mutating verbs locally before any request leaves the process (`common/read_only.rs`); it is a foot-gun guard, **not** a security control, and has no server-side counterpart. +- `server.toml` — `[server]` (TLS, timeouts) + a `[sites.]` map. The server hosts **every** site at once; clients pick one per request via `X-Manta-Site`. Fails closed without TLS unless `--allow-http` **or** `[server].allow_http = true` is set (the two are OR-ed). ## Work in flight (NOT on `main` — check before assuming) diff --git a/CLI.md b/CLI.md index 0d9b3386..7507aeed 100644 --- a/CLI.md +++ b/CLI.md @@ -145,7 +145,7 @@ Remove the default HSM group. ### config unset auth -Remove the stored authentication token. +Delete the cached authentication token (`token.json` in manta's cache directory). The next command that needs a token logs in to the CSCS Keycloak again. Legacy per-site `_auth` files from older versions are left untouched. --- diff --git a/GUIDE.md b/GUIDE.md index 9f71c756..65f7a6cf 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -759,27 +759,28 @@ manta run session -n test -r ~/repos/cos-config -H compute --dry-run -o json | j ### Authentication for scripts -Manta resolves the bearer token in a defined order; scripts running in CI / cron / non-interactive shells need to know which branch they will hit. The full resolution lives in `crates/manta-cli/src/common/authentication.rs`; the diagram below is the operator-facing summary. +Manta gets its bearer token from the CSCS Keycloak — one token for every site, obtained by the CLI itself (manta-server is not involved). It resolves the token in a defined order; scripts running in CI / cron / non-interactive shells need to know which branch they will hit. The full resolution lives in `crates/manta-cli/src/common/authentication.rs`; the diagram below is the operator-facing summary. *Flowchart: how `manta` resolves the bearer token in scripted use.* ```mermaid flowchart TD - Start[manta subcommand] --> Env{MANTA_CSM_TOKEN env var set?} - Env -->|yes| ValEnv{server accepts token?} - Env -->|no| Cache - ValEnv -->|yes| Run[run command with Bearer token] - ValEnv -->|no| Cache{cache file exists?
config-dir/site_auth, mode 0600} - Cache -->|yes| ValCache{server accepts token?} - Cache -->|no| TTY{stdin is a TTY?} - ValCache -->|yes| Run - ValCache -->|no| TTY - TTY -->|no| Fail[exit non-zero
no way to prompt non-interactively] - TTY -->|yes| Prompt[prompt user -> POST /auth/token
write 0600 cache] - Prompt --> Run -``` - -For scripts, set `MANTA_CSM_TOKEN` from a secret-fetch step before the `manta` invocation. If the server rejects the token (e.g. expired), the CLI falls through to the cached file and then to an interactive prompt — see the flowchart above. + Start[manta subcommand] --> Env{MANTA_TOKEN env var set?} + Env -->|yes| Run[run command with Bearer token] + Env -->|no| Cache{cached token?
cache-dir/token.json, mode 0600} + Cache -->|valid| Run + Cache -->|expired| Refresh{refresh token accepted
by the Keycloak?} + Refresh -->|yes| Save[write refreshed token to cache] --> Run + Refresh -->|no| TTY + Cache -->|none| TTY{stdin is a TTY?} + TTY -->|no| Fail[exit non-zero
no way to log in non-interactively] + TTY -->|yes| Login[OIDC login against the CSCS Keycloak
browser, or device code when headless
write 0600 cache] + Login --> Run +``` + +For scripts, set `MANTA_TOKEN` from a secret-fetch step before the `manta` invocation. It is used as-is: nothing checks it up front, so an expired or rejected token surfaces as an `HTTP 401` on the first request rather than falling through to the cache. Alternatively, log in once interactively on the same account: while the cached refresh token stays valid, non-interactive runs refresh the access token silently. + +The cache directory is `~/.cache/manta/` on Linux and `~/Library/Caches/local.cscs.manta/` on macOS. `manta config unset auth` deletes the cached token. On a machine without a usable browser (e.g. over SSH, which is detected automatically) the login prints a URL and a code to enter on any other device; set `oidc_headless = true` in `cli.toml` to always use that flow. > **Note:** `manta add group` is the one verb where `-D` (capital) is the short alias for `--description` because `-d` is reserved for `--dry-run`. See [MIGRATING.md §5.11](MIGRATING.md#511---dry-run-on-every-mutating-verb-add-group--d-reassigned). diff --git a/MIGRATING.md b/MIGRATING.md index 94d18287..d1110cce 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -36,7 +36,7 @@ between-release deltas once you are on v2, see | Aspect | v1.x | v2 | |---|---|---| | **Binaries** | One — `manta` (CLI talks to CSM/OCHAMI direct) | Two — `manta` (CLI) and `manta-server` (HTTPS API in front of CSM/OCHAMI) | -| **Auth target** | Each user's CLI authenticates to the backend directly | CLI authenticates to `manta-server`; the server holds the backend creds and tokens | +| **Auth target** | Each user's CLI authenticates to the backend directly, one token per site | CLI logs in to the CSCS Keycloak directly and uses one token for every site; the server forwards it to the backend and holds the per-site infra creds (Vault, k8s) — see §5.14 | | **Config file** | Single `~/.config/manta/config.toml` mixing CLI + backend + (in some setups) server fields | Split into `cli.toml` (workstation) and `server.toml` (server host); the CLI strictly does not need backend URLs | | **CLI verbs** | `apply session`, `apply boot cluster`, `migrate vCluster backup`, `get cluster`, `get hardware cluster`, `power on/off/reset cluster`, `add-nodes-to-groups`, `remove-nodes-from-groups`, `apply hardware cluster`, `update boot-parameters`, `update redfish-endpoints`, `config gen-autocomplete`, … | Renamed and the old forms removed: `run session`, `apply boot group`, `backup vcluster`, `get group-nodes`, `get hardware group`, `power on/off/reset group`, `add nodes`, `delete nodes`, `apply hardware group`, `apply boot-parameters`, `apply redfish-endpoint`, `gen-autocomplete`, … The full mapping is in §1.4 below | | **CLI flags** | `--hsm-group`, `--target-cluster`, `--parent-cluster`, `--create-hsm-group`, … | `--group`, `--target-group`, `--parent-group`, `--create-group`, … Old flag names retained as visible clap aliases | @@ -47,9 +47,10 @@ between-release deltas once you are on v2, see The most important conceptual change: **v1 dispatched directly to the backend from the CLI; v2 puts an HTTPS server (`manta-server`) between -the user and the backend.** Tokens, vault paths, k8s service-account +the user and the backend.** Vault paths, k8s service-account credentials, and TLS material all live on the server now, never on -the workstation. Every CLI call goes out as +the workstation; the workstation holds only the user's own CSCS +Keycloak token. Every CLI call goes out as `HTTPS → manta-server → CSM/OCHAMI`. --- @@ -85,11 +86,11 @@ the migration mapping when first run with no `cli.toml` present. The mapping is: ``` -copy these fields verbatim: log, site, auditor +copy these fields verbatim: log, site add CLI-only (now required): manta_server_url = "https://..." (CLI talks only to the manta server) -drop (no longer recognised): audit_file (audit emission is - server-side only) +drop (no longer recognised): audit_file, auditor (manta emits + no audit stream; see §5.14) do not copy (server-only fields): [sites.*] (every backend connection bundle lives in server.toml now), the [server] section, and the old @@ -118,19 +119,17 @@ manta_server_url = "https://manta-server.example.com:8443" ### 1.3 Re-authenticate -v1 cached a CSM token directly. v2 caches a token issued by -`manta-server` (which proxies the credential exchange to the -backend). Existing token files in `~/.cache/manta/` should be -cleared: +v1 cached one CSM token per site. v2 caches a single token issued by +the CSCS Keycloak, valid for every site (see §5.14). v1's per-site +`_auth` files in `~/.cache/manta/` are ignored; delete them if +you no longer need them: ```bash -manta config unset auth # interactive picker, removes one token file -# or -rm -rf ~/.cache/manta/ +rm ~/.cache/manta/*_auth ``` -The first command you run on v2 will re-prompt for Keycloak -credentials. +The first command you run on v2 opens a browser to log in to the +CSCS Keycloak (or prints a URL and a code when run over SSH). ### 1.4 Update muscle memory @@ -289,7 +288,6 @@ port = 8443 cert = "/etc/manta/server.crt" key = "/etc/manta/server.key" console_inactivity_timeout_secs = 1800 -auth_rate_limit_per_minute = 60 # per source IP for /auth/*; omit to disable [sites.alps] backend = "csm" # or "ochami" @@ -307,12 +305,11 @@ If you had a v1 `config.toml` with all the backend fields on the same host, the migration mapping is: ``` -copy these fields verbatim: log, auditor, sites +copy these fields verbatim: log, sites add new [server] section: listen_address, port, cert, key, console_inactivity_timeout_secs drop (CLI-only): site, hsm_group, manta_server_url -drop (no longer recognised): audit_file (audit emission is - Kafka-only via [auditor.kafka]) +drop (no longer recognised): audit_file, auditor (see §5.14) drop (no longer recognised): sites..manta_server_url ``` @@ -324,12 +321,11 @@ production should use a real certificate or a wildcard from your site's CA. Without `cert`/`key`, the server runs plain HTTP — fine for `localhost` smoke tests, never for a deployment. -### 2.4 Auth rate limiting +### 2.4 Auth rate limiting (removed) — see §5.14 -`[server].auth_rate_limit_per_minute` enforces a per-source-IP token -bucket on `/v2/auth/*`. Default is 60 req/min/IP; omit to -disable in-process limiting and rely on your reverse proxy. The -limiter is defence-in-depth — terminate at the proxy as well. +`manta-server` no longer has auth endpoints, so there is nothing to +rate-limit in process; brute-force protection is the CSCS Keycloak's. +`[server].auth_rate_limit_per_minute` is ignored. ### 2.5 Logging @@ -342,14 +338,14 @@ syntax: `log = "manta_server=debug,hyper=warn,info"`. The server prints its full effective configuration to stdout on startup so operators see exactly what got loaded — config file path, -listen address, TLS state, auth rate limit, audit file, per-site -backend URLs, k8s/vault URLs (no secrets ever logged). +listen address, TLS state, per-site backend URLs, k8s/vault URLs +(no secrets ever logged). -### 2.6 Audit +### 2.6 Audit (removed) — see §5.14 -`[auditor].kafka` if present streams every `/auth/*` outcome to a -Kafka topic. Same field shape as v1's audit block. If you don't run -Kafka, omit the section — auditing is silent. +The Kafka auditor only ever recorded `/auth/*` attempts and was +removed with those endpoints. Login attempts are recorded by the CSCS +Keycloak; `[auditor.kafka]` is ignored. ### 2.7 Read-only access (removed) — see §5.12 @@ -480,8 +476,10 @@ the headline: - Base URL: `https://:8443/v2` - Required headers: `X-Manta-Site: ` + `Authorization: Bearer ` -- Auth bootstrap: `POST /v2/auth/token` with - `{"username":"...","password":"..."}` → returns `{"token":"..."}` +- Auth: obtain a token from the CSCS Keycloak yourself (any OIDC + client), or reuse the one the `manta` CLI caches — see + [API.md → Get a token](API.md#get-a-token). The server has no auth + endpoints. - Error envelope: `{"error":"..."}` with conventional status codes - OpenAPI spec served at `/openapi.json`; Swagger UI at `/docs` @@ -510,8 +508,9 @@ For a typical site with N workstation users + one server host: 2. Install `manta-server` on the host (binary, distro package, or container). Confirm `manta-server --version`. 3. Author `~/.config/manta/server.toml` per [§2.2](#22-author-servertoml). - Set TLS cert/key paths, backend URLs, k8s/vault URLs, audit - destination. + Set TLS cert/key paths, backend URLs, k8s/vault URLs. Make sure + every site's CSM API, Vault `jwt-manta-` role and k8s trust + the CSCS Keycloak issuer (see §5.14). 4. Start the service (systemd, docker, …). Tail the logs and confirm you see the `[server] effective configuration` and one `[site] configured` line per site. @@ -525,7 +524,8 @@ For a typical site with N workstation users + one server host: 2. Move (or copy) `~/.config/manta/config.toml` to `~/.config/manta/cli.toml` and edit per [§1.2](#12-convert-your-config-file). Add `manta_server_url` pointing at the operator's URL. -3. `rm -rf ~/.cache/manta/` to flush v1 tokens. +3. Run any command once to log in to the CSCS Keycloak; v1 token + files are ignored. 4. Run `manta config show` — should print the loaded settings and the groups your token can access. 5. Run any command you used regularly in v1; if it warns about a @@ -803,6 +803,45 @@ Not Found` after the upgrade. return `200 OK`; `curl -k https://:8443/api/v2/health` should return `404`. +### 5.14. One CSCS-Keycloak token, obtained by the CLI (BREAKING) + +The CLI now logs in to the **CSCS Keycloak** itself and uses **one +token for every site**. `manta-server` no longer takes part in +authentication: it has no auth endpoints and forwards the bearer token +unchanged to each site's backend. + +**What changed.** + +| Before | After | +|---|---| +| CLI prompts for username + password, sends them to `POST /v2/auth/token` | CLI runs an OIDC login against the CSCS Keycloak: a browser (auth code + PKCE), or a device code when headless / over SSH | +| One cached token per site: `/_auth` | One cached token for all sites: `/token.json`, refreshed silently while the refresh token is valid | +| `MANTA_CSM_TOKEN` env var | `MANTA_TOKEN` env var (no alias) | +| Cached / env tokens checked with `POST /v2/auth/validate` | No up-front check; a rejected token surfaces as `HTTP 401` on the first request | +| `manta config unset auth` picks a per-site file to delete | Deletes `token.json` | +| `POST /v2/auth/token`, `POST /v2/auth/validate` | Removed — `404 Not Found` | +| `[server].auth_rate_limit_per_minute`, `[auditor.kafka]` | Removed — ignored if still present in `server.toml` | + +New optional `cli.toml` keys: `oidc_issuer_url` (default: the CSCS +realm), `oidc_client_id` (default: the manta client) and +`oidc_headless` (always use the device-code flow). + +**Migration.** + +1. **Operators.** Every site's CSM API gateway, Vault + `jwt-manta-` auth role and k8s must accept tokens from the + CSCS Keycloak, and the manta client's access token must carry + `preferred_username`, `name` and `realm_access.roles` (including + `pa_admin` for admins and the HSM-group roles). Drop + `auth_rate_limit_per_minute` and `[auditor.kafka]` from + `server.toml` at your convenience. +2. **CLI users.** Nothing to do — the next command logs you in. Old + `_auth` files are ignored and can be deleted by hand. +3. **Scripts.** Rename `MANTA_CSM_TOKEN` to `MANTA_TOKEN`. The token + must now come from the CSCS Keycloak, and it works for every site. +4. **HTTP clients.** Stop calling `/v2/auth/*`; obtain the token from + the CSCS Keycloak. + --- ## Reference diff --git a/README.md b/README.md index 101b43be..6cc8a2bc 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Another CLI tool for [Alps](https://www.cscs.ch/science/computer-science-hpc/202 A command-line + HTTP API frontend for HPC clusters running [CSM](https://github.com/Cray-HPE/cray-site-init) or [OpenCHAMI](https://www.openchami.org/). Two independent binaries from one Cargo workspace: -- **`manta`** — interactive CLI. Forwards every operation (including auth) to a `manta-server` over HTTPS; never calls the backend directly. +- **`manta`** — interactive CLI. Logs in to the CSCS Keycloak itself (one token for every site), then forwards every operation to a `manta-server` over HTTPS; never calls the backend directly. - **`manta-server`** — Axum HTTPS server. Holds the per-site backend credentials and exposes a Swagger-documented REST + WebSocket API at `https://:8443/v2` (default port). **Get something running locally:** @@ -181,7 +181,7 @@ The `manta-cli` image has `manta` as its ENTRYPOINT, so anything after the image ```bash docker run -it --network=host \ -v "$CONFIG_DIR":/root/.config/manta \ - -e MANTA_CSM_TOKEN \ + -e MANTA_TOKEN \ manta-cli get redfish-endpoints ``` @@ -200,7 +200,7 @@ docker run -p 8443:8443 \ > ```bash > docker run -it --rm --network=host \ > -v "$CONFIG_DIR":/root/.config/manta \ -> -e MANTA_CSM_TOKEN \ +> -e MANTA_TOKEN \ > manta-cli get sessions > ERROR | Get and filter sessions command not implemented for this backend > exit status 1 @@ -250,11 +250,11 @@ Manta reads two TOML files, one per binary: `cli.toml` for the CLI and `server.t Override the path with `MANTA_CLI_CONFIG` / `MANTA_SERVER_CONFIG`. -The two schemas are **disjoint**: the CLI's `cli.toml` carries only the CLI-side knobs (`site`, `hsm_group`, `manta_server_url`, optional `socks5_proxy`) — it has **no `[sites.*]` block** and no Kafka audit block (audit emission is server-side only). Every per-site backend connection detail (URLs, TLS certs, k8s, vault) lives in `server.toml`, alongside the `[server]` block (TLS, listen address, console timeout, auth rate limit) and the optional `[auditor.kafka]` for the server-side audit stream. The server has no per-site SOCKS5 proxy knob — it's expected to sit in a network position where backend URLs are directly reachable. +The two schemas are **disjoint**: the CLI's `cli.toml` carries only the CLI-side knobs (`site`, `hsm_group`, `manta_server_url`, optional `socks5_proxy`, the optional `oidc_*` login settings) — it has **no `[sites.*]` block**. Every per-site backend connection detail (URLs, TLS certs, k8s, vault) lives in `server.toml`, alongside the `[server]` block (TLS, listen address, console timeout, request timeout). The server has no per-site SOCKS5 proxy knob — it's expected to sit in a network position where backend URLs are directly reachable. **`cli.toml`** -`manta_server_url` is required: the CLI no longer talks to CSM/OCHAMI backends directly — every operation (including auth) is forwarded to the named manta server. Run `manta-server` on a reachable host first. +`manta_server_url` is required: the CLI no longer talks to CSM/OCHAMI backends directly — every operation is forwarded to the named manta server. Run `manta-server` on a reachable host first. Login is the one exception: the CLI gets its bearer token from the CSCS Keycloak itself (see **Authentication** below). ```toml log = "info" @@ -273,11 +273,22 @@ power_max_poll_attempts = 300 # 300 x 3 s = 15 min sat_file_poll_interval_secs = 10 sat_file_poll_budget_secs = 14400 # 4 hours sat_file_not_visible_budget_secs = 300 # 5 minutes + +# Optional login settings. Defaults: the CSCS realm and the manta client. +# oidc_issuer_url = "https://auth.cscs.ch/auth/realms/cscs" +# oidc_client_id = "PLACEHOLDER-manta-cli" +# oidc_headless = false # true: device-code login (URL + code) instead of a browser ``` `read_only = true` makes the CLI refuse every backend-mutating verb (`add`, `apply`, `backup`, `delete`, `migrate`, `power`, `restore`, `run`) before any request leaves the process; `--dry-run` invocations are still allowed. Toggle it with `manta config set read-only` / `manta config unset read-only`. It is a local foot-gun guard, **not** a security control — there is no server-side counterpart. -Audit emission is server-side only — every CLI command goes through HTTP to `manta-server`, which emits per-`/auth/*` events to its configured `[auditor.kafka]` stream. +**Authentication.** The CLI logs in to the CSCS Keycloak directly and uses one bearer token for every site; `manta-server` never sees credentials, it only forwards the token to the site backends (CSM API, Vault, k8s), which must trust the CSCS Keycloak. The CLI resolves the token in this order: + +1. `MANTA_TOKEN` environment variable, used as-is (for scripts and CI). +2. The cached token in manta's cache directory (`token.json`, mode `0600`). An expired access token is refreshed silently with the cached refresh token. +3. Interactive login: a browser window (authorization code + PKCE), or — with `oidc_headless = true`, inside an SSH session, or when no browser can be opened — a device code to enter on any device. The result is written to the cache. + +`manta config unset auth` deletes the cached token and forces a new login. When stdin is not a terminal the CLI never starts a login; it fails with a pointer to `MANTA_TOKEN` instead. The CLI has no `[sites]` section: it only knows about the one `manta-server` it talks to. Per-site backend connection details @@ -294,13 +305,8 @@ port = 8443 # optional; default 8443 if cert+k cert = "/etc/manta/tls/server.crt" key = "/etc/manta/tls/server.key" console_inactivity_timeout_secs = 1800 -auth_rate_limit_per_minute = 60 # per source IP for /v2/auth/*; omit to disable request_timeout_secs = 300 # global per-route timeout (returns 408); default 300 (5 min) -[auditor.kafka] -brokers = ["kafka.cscs.ch:9095"] -topic = "manta-server-audit" - [sites.alps] backend = "csm" shasta_base_url = "https://api.alps.cscs.ch" diff --git a/SECURITY.md b/SECURITY.md index 6eaa466b..07306f5b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -60,35 +60,29 @@ lands. ## Security model — at a glance -manta is a 3-tier system. The sequence below shows the bootstrap -auth call and a subsequent authenticated request: +manta is a 3-tier system. The sequence below shows the login and a +subsequent authenticated request: -*Sequence: bootstrap (auth) then a subsequent authenticated request.* +*Sequence: login against the CSCS Keycloak, then an authenticated request.* ```mermaid sequenceDiagram autonumber participant CLI as User CLI + participant K as CSCS Keycloak participant MS as manta-server - participant K as Keycloak / IdP - participant B as CSM / OCHAMI - participant Kf as Kafka (optional) + participant B as CSM / OCHAMI / Vault - Note over MS: chokepoint — every CSM/OCHAMI call proxies through here - - Note over MS: /v2/auth/* sub-router
rate_limit + body-redaction layers + Note over CLI,K: once per token lifetime — manta-server not involved - CLI->>MS: POST /v2/auth/token
{username, password} - MS->>K: exchange credentials - K-->>MS: bearer token - MS-->>CLI: { token } - MS->>Kf: audit { outcome, username, source_ip, site } + CLI->>K: OIDC login (auth code + PKCE in a browser,
or device code when headless) + K-->>CLI: access + refresh token (cached 0600) - Note over MS: /v2/* sub-router
TimeoutLayer + Note over MS: chokepoint — every CSM/OCHAMI call proxies through here - CLI->>MS: {method} /v2/{resource}
Authorization: Bearer … - MS->>B: proxied call with bearer - B-->>MS: backend response (signature verified upstream) + CLI->>MS: {method} /v2/{resource}
Authorization: Bearer …
X-Manta-Site: + MS->>B: proxied call with the same bearer + B-->>MS: backend response (signature verified upstream against the CSCS Keycloak) MS-->>CLI: 2xx / 4xx ``` @@ -98,20 +92,22 @@ For where the middleware layers sit in the request pipeline, see [ARCHITECTURE.md → Middleware layer stack](ARCHITECTURE.md#middleware-layer-stack). - **CLI side** (`manta` binary): a thin client that runs on operator - workstations. Holds the user's bearer token in a 0600 cache file - under the platform config dir. Never speaks to CSM/OCHAMI directly. -- **Server side** (`manta-server` binary): the - credential-handling chokepoint. Receives user/password on - `POST /v2/auth/token`, exchanges them with the per-site backend, - returns the bearer token to the CLI. Subsequent authenticated - endpoints proxy backend calls using the user's token. -- **Upstream** (CSM / OpenCHAMI / Keycloak): not part of this repo. - Signature verification of the bearer token happens here, at the - first backend round-trip. - -Because `manta-server` sees every authentication attempt and holds -per-site service-account tokens (for k8s, Vault, Gitea), **it is a -single point of compromise** for every site it serves. Operate it + workstations. Logs in to the CSCS Keycloak itself (OIDC) and holds + the resulting access + refresh token in a 0600 cache file + (`token.json`) under the platform cache dir. One token serves every + site. Never speaks to CSM/OCHAMI directly. +- **Server side** (`manta-server` binary): the request chokepoint. + Never sees credentials — it has no auth endpoints — and proxies + backend calls using the user's bearer token unchanged. +- **Upstream** (CSCS Keycloak / CSM / OpenCHAMI / Vault): not part of + this repo. Every site must trust the CSCS Keycloak issuer; signature + verification of the bearer token happens there, at the first backend + round-trip. + +Because every request through `manta-server` carries a bearer token +valid on every site that trusts the CSCS Keycloak, and the server +holds per-site service-account tokens (for k8s, Vault, Gitea), **it is +a single point of compromise** for every site it serves. Operate it accordingly. ## Controls — what is enforced where @@ -122,19 +118,18 @@ accordingly. |---|---|---| | TLS required by default | `manta-server` | Server fails closed without `cert` + `key`. Pass `--allow-http` only when TLS terminates upstream. | | HSTS on every response | `manta-server` | `Strict-Transport-Security: max-age=31536000; includeSubDomains`. No-op over plain HTTP per RFC 6797. | -| Per-source-IP rate limit on `/v2/auth/*` | `manta-server` | `[server].auth_rate_limit_per_minute` (default 60). Implementation in `server::auth_middleware::rate_limit`. | -| Generic 401 on every auth failure | `manta-server` | `server::handlers::auth_token` returns identical `"invalid credentials"` body regardless of whether the user was unknown or the password was wrong. Detail stays in server-side `tracing::warn!`. | -| Audit event per auth attempt | `manta-server` | When `[auditor.kafka]` is configured, `server::common::audit::send_auth_audit` emits `{ outcome, username, source_ip, site }`. Credentials are never logged. | -| Body redaction on `/auth/*` log spans | `manta-server` | `server::auth_middleware::strip_body_for_logs`. | +| No credentials on the server | `manta-server` | Login happens between the CLI and the CSCS Keycloak; the server has no auth endpoints. Brute-force protection and login auditing are the Keycloak's. | +| PKCE, `state`, nonce and `at_hash` checks on login | `manta-cli` | `common::oidc`: the browser flow uses S256 PKCE on a loopback redirect, rejects a mismatched `state`, and verifies the ID token's nonce and `at_hash` against the realm's JWKS. Redirects are never followed on Keycloak calls. | | CLI-side read-only guard | `manta-cli` | Refuses mutating verbs locally when `cli.toml` has `read_only = true`, before any HTTP request leaves the process. Client-side accident guard only — `manta-server` does not enforce this; see [GUIDE.md §13](GUIDE.md#13-read-only-access). | -| 0600 mode on cached tokens | `manta-cli` | Token cache file written with `0600` perms under the platform config dir; never logged. | +| 0600 mode on cached tokens | `manta-cli` | `token.json` written with `0600` perms in a `0700` platform cache dir; `TokenStore`'s `Debug` redacts every token, so tokens are never logged. | ### In ops (your deployment) | Control | Notes | |---|---| -| TLS termination, WAF, reverse-proxy rate limit | First line of defence; manta-server's in-process limiter is belt-and-braces. | -| Service-account scoping at CSM / Vault | Limit what the manta-server-issued tokens can do at the backend. | +| TLS termination, WAF, reverse-proxy rate limit | First line of defence. | +| Token lifetime and scope in the CSCS Keycloak | Short access-token lifetimes bound the replay window of a leaked token; the manta client's claims (`realm_access.roles`, audience) bound what it can do at each site. | +| Service-account scoping at CSM / Vault | Limit what the server's per-site service-account tokens can do at the backend. | | Network segmentation | Treat manta-server as a privileged host. | | `[server].migrate_backup_root` set explicitly | Confines `POST /migrate/{backup,restore}` paths. Server returns `400` for those endpoints when unset, even for admin callers. | @@ -159,16 +154,16 @@ path.) Consequences: verification automatically when it lands; no per-gate work needed. **Single point of compromise.** As noted above, owning `manta-server` -gives an attacker visibility into every auth attempt and access to -configured service-account tokens. Mitigations are split between -code (rate limit, generic 401, body redaction) and ops (TLS -termination upstream, service-account scoping, network segmentation). +gives an attacker every live bearer token that passes through it — +each valid on every site trusting the CSCS Keycloak until it expires — +and access to configured service-account tokens. Mitigations are +mostly ops: short token lifetimes in the Keycloak, TLS termination +upstream, service-account scoping, network segmentation. -**Deferred: forwarding the original client IP to Keycloak via -`X-Forwarded-For`** on the upstream auth call. Tracked as a follow-up -because the current `AuthenticationTrait::get_api_token` signature in -`manta-backend-dispatcher` does not take a header argument; landing -this requires a sibling-repo upgrade. +**One token for every site.** A leaked access token (e.g. from a +`MANTA_TOKEN` in a CI log) works against every site, not just one. +Keep `MANTA_TOKEN` out of logs and rely on the Keycloak's token +lifetime to bound the damage. ## Cryptography notes @@ -178,10 +173,11 @@ this requires a sibling-repo upgrade. - **JWT decode:** `base64::prelude::BASE64_URL_SAFE_NO_PAD` with a fall-back to `BASE64_STANDARD`. No signature verification (see Known limitations). -- **Password handling:** credentials submitted to `/v2/auth/token` - are deserialised into `serde` types, forwarded to the configured - backend, and dropped. They are not written to disk; they are not - emitted to any log span (see `strip_body_for_logs`). +- **Password handling:** manta never sees a password. The user + authenticates to the CSCS Keycloak in a browser (or on another + device, for the device-code flow); the CLI receives only tokens. +- **OIDC:** `openidconnect` for PKCE (S256) and ID-token verification + (signature against the realm's JWKS, nonce, `at_hash`). ## Out of scope for this document diff --git a/examples/cli.toml b/examples/cli.toml index 42f10ce1..97f8393d 100644 --- a/examples/cli.toml +++ b/examples/cli.toml @@ -27,6 +27,19 @@ manta_server_url = "https://manta-server.example.com:8443" # server-side counterpart. See MIGRATING.md §5.12. read_only = false +# Login to the CSCS Keycloak. The CLI obtains one bearer token for all +# sites directly from the Keycloak (manta-server is not involved) and +# caches it in manta's cache directory as `token.json`. +# +# Issuer and client id default to the CSCS realm and the manta client; +# override them only to point at another realm (e.g. TDS). +# oidc_issuer_url = "https://auth.cscs.ch/auth/realms/cscs" +# oidc_client_id = "PLACEHOLDER-manta-cli" +# +# Log in with the device-code flow (print a URL + code) instead of +# opening a browser. SSH sessions use it automatically. +# oidc_headless = false + # Timeout knobs. Values shown are the built-in defaults — delete a # line to fall back to the default, or change the value to override. diff --git a/examples/server.toml b/examples/server.toml index b073af95..b9d4f364 100644 --- a/examples/server.toml +++ b/examples/server.toml @@ -19,7 +19,6 @@ key = "/etc/manta/tls/server.key" # delete a line to fall back to the default, or change the value to # override. console_inactivity_timeout_secs = 1800 # idle WebSocket console drop -auth_rate_limit_per_minute = 60 # per source IP on /auth/*; omit to disable request_timeout_secs = 600 # global per-route timeout; returns 408 on expiry shutdown_grace_period_secs = 30 # drain window after SIGTERM / Ctrl+C @@ -88,17 +87,3 @@ base_url = "https://vault.example.com" # backend = "ochami" # shasta_base_url = "https://ochami.example.com:8443" # root_ca_cert_file = "ochami_root_cert.pem" - - -# --------------------------------------------------------------- -# Audit emission (optional) -# --------------------------------------------------------------- -# When the `[auditor.kafka]` block is present, the server publishes -# one event to the named topic per `/api/v1/auth/*` call. Absent = -# audit emission disabled. - -# [auditor.kafka] -# brokers = ["kafka.example.com:9092"] -# topic = "manta-audit" -# message_timeout_ms = 5000 # librdkafka per-message delivery deadline; default 5000 -# delivery_wait_secs = 0 # how long produce_message blocks; 0 = fire-and-forget (default)