diff --git a/Cargo.lock b/Cargo.lock index 5beaa66a83..425b445e6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1493,6 +1493,7 @@ dependencies = [ "carbide-sqlx-testing", "carbide-test-harness", "carbide-test-support", + "carbide-utils", "carbide-uuid", "carbide-version", "chrono", @@ -1507,6 +1508,7 @@ dependencies = [ "lazy_static", "mac_address", "oauth2", + "rand 0.10.1", "regex", "reqwest 0.13.4", "serde", diff --git a/book/src/configuration/configurability.md b/book/src/configuration/configurability.md index 999ab5e3a6..00c013f368 100644 --- a/book/src/configuration/configurability.md +++ b/book/src/configuration/configurability.md @@ -399,8 +399,40 @@ oauth2_client_id = "nico-api" allowed_access_groups = ["nico-operators", "nico-admins"] ``` -The chart supports overriding any `[auth.web]` field via -`nico-api.extraEnv` (see [`helm/README.md` → OAuth2 / SSO Setup](../../../helm/README.md#oauth2--sso-setup)). +The deployed WebUI authentication mode is configured with +`nico-api.webAuth.mode` (`basic`, `oauth2`, or `none`) and defaults to Basic +Auth with a generated password Secret. When `webAuth.mode: oauth2` is selected, +provide the endpoints, client credentials, and allowed groups through +`nico-api.extraEnv`: + +```yaml +nico-api: + webAuth: + mode: oauth2 + extraEnv: + - name: CARBIDE_WEB_OAUTH2_AUTH_ENDPOINT + value: "https://keycloak.example.com/realms/nico/protocol/openid-connect/auth" + - name: CARBIDE_WEB_OAUTH2_TOKEN_ENDPOINT + value: "https://keycloak.example.com/realms/nico/protocol/openid-connect/token" + - name: CARBIDE_WEB_OAUTH2_CLIENT_ID + value: "nico-api" + - name: CARBIDE_WEB_OAUTH2_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: nico-web-oauth2-client + key: client_secret + - name: CARBIDE_WEB_ALLOWED_ACCESS_GROUPS + value: "nico-operators,nico-admins" + - name: CARBIDE_WEB_ALLOWED_ACCESS_GROUPS_ID_LIST + value: "," +``` + +`extraEnv` accepts normal Kubernetes `env` entries, including `valueFrom` +references. An existing `CARBIDE_WEB_AUTH_TYPE` entry there takes precedence +over `webAuth.mode` for backward compatibility, but new configurations should +use `webAuth.mode` to select the mode. See +[`helm/README.md` → OAuth2 / SSO Setup](../../../helm/README.md#oauth2--sso-setup) +for the complete Helm guidance. `[auth.acls]` defines per-principal HTTP method+path allow/deny rules (used by `nico-bmc-proxy` and other authenticating proxies). The example diff --git a/crates/api-web/Cargo.toml b/crates/api-web/Cargo.toml index 8c7011a5cf..3f74d0bcf9 100644 --- a/crates/api-web/Cargo.toml +++ b/crates/api-web/Cargo.toml @@ -35,6 +35,7 @@ carbide-health-report = { path = "../health-report" } carbide-measured-boot = { path = "../measured-boot", features = ["sqlx"] } carbide-rpc = { path = "../rpc", features = ["sqlx", "model"] } carbide-rpc-utils = { path = "../rpc-utils" } +carbide-utils = { path = "../utils" } carbide-uuid = { path = "../uuid", features = ["sqlx"] } carbide-version = { path = "../version" } config-version = { path = "../config-version", features = ["sqlx"] } @@ -61,6 +62,7 @@ itertools = { workspace = true } lazy_static = { workspace = true } mac_address = { workspace = true } oauth2 = { workspace = true, default-features = false } +rand = { workspace = true } regex = { workspace = true } reqwest = { workspace = true, default-features = false, features = [ "rustls", diff --git a/crates/api-web/src/auth.rs b/crates/api-web/src/auth.rs index 446814af6d..51d292063b 100644 --- a/crates/api-web/src/auth.rs +++ b/crates/api-web/src/auth.rs @@ -58,7 +58,7 @@ pub async fn callback( AxumState(_state): AxumState>, request_headers: HeaderMap, Query(query): Query, - Extension(oauth2_layer): Extension>, + Extension(oauth2_layer): Extension>>, ) -> AuthCallbackResponse { use AuthCallbackError::*; let Some(oauth2_layer) = oauth2_layer else { @@ -85,6 +85,7 @@ pub async fn callback( // which includes an access token. let _ = match oauth2_layer .client + .clone() .set_client_secret(ClientSecret::new(client_secret.to_string())) .exchange_client_credentials() .add_scope(Scope::new(format!("{client_id}/.default"))) diff --git a/crates/api-web/src/lib.rs b/crates/api-web/src/lib.rs index a902f69b6c..40ba4f8887 100644 --- a/crates/api-web/src/lib.rs +++ b/crates/api-web/src/lib.rs @@ -17,6 +17,7 @@ use std::collections::HashMap; use std::env; +use std::str::FromStr; use std::sync::Arc; use std::sync::atomic::Ordering; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -28,10 +29,12 @@ use axum::middleware::Next; use axum::response::{Html, IntoResponse, Redirect, Response}; use axum::routing::{Router, get, post}; use axum_extra::extract::cookie::{Cookie, Key, PrivateCookieJar}; +use base64::Engine as _; +use base64::prelude::BASE64_STANDARD; use carbide_api_core::cfg::file::ToolLink; use carbide_api_core::{Api, AuthContext, CarbideError, DefaultCredential}; use carbide_authn::middleware::Principal; -use http::header::CONTENT_TYPE; +use http::header::{AUTHORIZATION, CONTENT_TYPE, WWW_AUTHENTICATE}; use http::{HeaderMap, Request, StatusCode}; use itertools::Itertools; use oauth2::basic::{ @@ -42,6 +45,7 @@ use oauth2::{ AuthUrl, Client, ClientId, ClientSecret, CsrfToken, EndpointNotSet, EndpointSet, PkceCodeChallenge, RedirectUrl, Scope, StandardRevocableToken, TokenUrl, }; +use rand::RngExt as _; use rpc::forge::forge_server::Forge; use rpc::forge::{self as forgerpc}; use tonic::service::AxumBody; @@ -226,6 +230,8 @@ fn format_state_sla(sla: Option<&forgerpc::StateSla>) -> String { // `fixtures(...)`; use explicit setup helpers instead. #[cfg(test)] pub(crate) use carbide_macros::sqlx_test; +use carbide_utils::none_if_empty::NoneIfEmpty; + #[cfg(test)] mod tests; @@ -282,6 +288,11 @@ mod ufm_browser; mod vpc; const AUTH_TYPE_ENV: &str = "CARBIDE_WEB_AUTH_TYPE"; +const BASIC_AUTH_PASSWORD_ENV: &str = "CARBIDE_WEB_BASIC_AUTH_PASSWORD"; +const BASIC_AUTH_USERNAME: &str = "admin"; +const BASIC_AUTH_REALM: &str = "Basic realm=\"NICo\""; +const TEMPORARY_BASIC_AUTH_REALM: &str = + "Basic realm=\"NICo - check service logs for the random admin password\""; const AUTH_CALLBACK_ROOT: &str = "auth-callback"; // Details https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Overview/appId/5ae5fa35-be8e-44cc-be7b-01ff76af5315/isMSAApp~/false @@ -320,7 +331,6 @@ pub(crate) type Oauth2ClientWithPropertiesSet = Client< EndpointSet, >; -#[derive(Clone)] pub(crate) struct Oauth2Layer { client: Oauth2ClientWithPropertiesSet, http_client: reqwest::Client, @@ -329,15 +339,78 @@ pub(crate) struct Oauth2Layer { allowed_access_groups_ids_to_name: HashMap, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum WebAuthMode { + #[default] + Basic, + OAuth2, + None, +} + +impl FromStr for WebAuthMode { + type Err = eyre::Error; + + fn from_str(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "basic" => Ok(Self::Basic), + "oauth2" => Ok(Self::OAuth2), + "none" => Ok(Self::None), + other => Err(eyre::eyre!( + "unknown {AUTH_TYPE_ENV}={other:?}: expected \"basic\", \"oauth2\", or \"none\"" + )), + } + } +} + +enum WebAuth { + Basic { + password: String, + challenge: &'static str, + }, + OAuth2(Arc), + None, +} + +impl WebAuth { + fn basic(configured_password: Option) -> Self { + match configured_password { + Some(password) => WebAuth::Basic { + password, + challenge: BASIC_AUTH_REALM, + }, + None => { + let password: String = rand::rng() + .sample_iter(&rand::distr::Alphanumeric) + .take(32) + .map(char::from) + .collect(); + tracing::warn!( + username = BASIC_AUTH_USERNAME, + password = %password, + "{BASIC_AUTH_PASSWORD_ENV} is not configured; generated a temporary WebUI Basic Auth password for this process. This password is not persisted and changes on every launch. Configure a permanent password by setting {BASIC_AUTH_PASSWORD_ENV} to a non-empty value." + ); + WebAuth::Basic { + password, + challenge: TEMPORARY_BASIC_AUTH_REALM, + } + } + } + } +} + /// All the URLs in the admin interface. Nested under /admin in api.rs. pub fn routes(api: Arc) -> eyre::Result> { - // `CARBIDE_WEB_AUTH_TYPE`: `none` (default) = no in-process auth — protect the admin UI with - // network policy, or a reverse proxy (OAuth2 Proxy, etc.). `oauth2` = Entra / OIDC via env. - let auth_type = env::var(AUTH_TYPE_ENV) - .unwrap_or_else(|_| "none".to_string()) - .to_lowercase(); - let oauth_extension_layer = match auth_type.as_str() { - "oauth2" => { + let auth_mode = + env::var(AUTH_TYPE_ENV).map_or(Ok(WebAuthMode::default()), |value| value.parse())?; + routes_with_auth_mode(api, auth_mode) +} + +fn routes_with_auth_mode( + api: Arc, + auth_mode: WebAuthMode, +) -> eyre::Result> { + let web_auth = match auth_mode { + WebAuthMode::OAuth2 => { // Get our cookiejar key so we can add it as an extension. let private_cookiejar_key = Key::try_from( env::var(CARBIDE_WEB_PRIVATE_COOKIEJAR_KEY_ENV) @@ -398,33 +471,31 @@ pub fn routes(api: Arc) -> eyre::Result> { builder.build()? }; - Some(Oauth2Layer { + WebAuth::OAuth2(Arc::new(Oauth2Layer { client, private_cookiejar_key, allowed_access_groups_filter, allowed_access_groups_ids_to_name, http_client, - }) + })) } - "none" | "" => { + WebAuthMode::None => { tracing::warn!( - "{}: admin web UI has no in-process authentication; restrict access with network policy, a private network, or an authenticating reverse proxy (for example OAuth2 Proxy)", - AUTH_TYPE_ENV + auth_type_env = AUTH_TYPE_ENV, + "admin web UI has no in-process authentication; restrict access with network policy, a private network, or an authenticating reverse proxy (for example OAuth2 Proxy)" ); - None + WebAuth::None } - "basic" => { - return Err(eyre::eyre!( - "{AUTH_TYPE_ENV}=basic is not supported. Use \"none\" (default; secure the UI with network controls or an auth proxy) or \"oauth2\" (SSO via Entra)." - )); - } - other => { - return Err(eyre::eyre!( - "unknown {AUTH_TYPE_ENV}={other:?}: expected \"none\" or \"oauth2\"" - )); + WebAuthMode::Basic => { + WebAuth::basic(env::var(BASIC_AUTH_PASSWORD_ENV).ok().none_if_empty()) } }; + let oauth_extension_layer = match &web_auth { + WebAuth::OAuth2(layer) => Some(Arc::clone(layer)), + WebAuth::Basic { .. } | WebAuth::None => None, + }; + Ok(NormalizePath::trim_trailing_slash( Router::new() .route("/", get(root)) @@ -820,28 +891,34 @@ pub fn routes(api: Arc) -> eyre::Result> { .route("/logs", get(logs::page)) .route("/logs/{source}/stream", get(logs::stream)) .route("/logs/{source}/history", get(logs::history)) - .layer(axum::middleware::from_fn(auth_oauth2)) + .layer(axum::middleware::from_fn(web_auth_middleware_fn)) + .layer(Extension(Arc::new(web_auth))) .layer(Extension(oauth_extension_layer)) .with_state(api), )) } -pub async fn auth_oauth2( +pub async fn web_auth_middleware_fn( headers: HeaderMap, mut req: Request, next: Next, ) -> Result { - let oauth_extension_layer = match req.extensions().get::>() { + let oauth_extension_layer = match req.extensions().get::>().map(AsRef::as_ref) { None => { - tracing::error!("failed to find oauth2 extension layer"); + tracing::error!("failed to find web authentication extension layer"); return Err(StatusCode::INTERNAL_SERVER_ERROR); } - Some(o) => match o { - None => { + Some(WebAuth::None) => return Ok(next.run(req).await), + Some(WebAuth::Basic { + password, + challenge, + }) => { + if basic_credentials_are_valid(&headers, password) { return Ok(next.run(req).await); } - Some(oa) => oa.to_owned(), - }, + return Ok((StatusCode::UNAUTHORIZED, [(WWW_AUTHENTICATE, *challenge)]).into_response()); + } + Some(WebAuth::OAuth2(layer)) => Arc::clone(layer), }; // /auth-callback should pass through because that's @@ -944,6 +1021,196 @@ pub async fn auth_oauth2( .into_response()) } +fn basic_credentials_are_valid(headers: &HeaderMap, configured_password: &str) -> bool { + let Some((scheme, encoded)) = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split_once(' ')) + else { + return false; + }; + if !scheme.eq_ignore_ascii_case("basic") || encoded.is_empty() { + return false; + } + let Ok(credentials) = BASE64_STANDARD.decode(encoded) else { + return false; + }; + let Ok(credentials) = String::from_utf8(credentials) else { + return false; + }; + let Some((username, password)) = credentials.split_once(':') else { + return false; + }; + + username == BASIC_AUTH_USERNAME && password == configured_password +} + +#[cfg(test)] +mod web_auth_tests { + use axum::body::Body; + use carbide_test_support::{Case, Outcome, check_cases, value_scenarios}; + use http::Request; + use tower::ServiceExt as _; + + use super::*; + + #[test] + fn auth_mode_parsing() { + check_cases( + [ + Case { + scenario: "basic", + input: "basic", + expect: Outcome::Yields(WebAuthMode::Basic), + }, + Case { + scenario: "oauth2", + input: "oauth2", + expect: Outcome::Yields(WebAuthMode::OAuth2), + }, + Case { + scenario: "none", + input: "none", + expect: Outcome::Yields(WebAuthMode::None), + }, + Case { + scenario: "case insensitive", + input: "BASIC", + expect: Outcome::Yields(WebAuthMode::Basic), + }, + Case { + scenario: "empty", + input: "", + expect: Outcome::Fails, + }, + Case { + scenario: "unknown", + input: "saml", + expect: Outcome::Fails, + }, + ], + |value| value.parse::().map_err(drop), + ); + assert_eq!(WebAuthMode::default(), WebAuthMode::Basic); + } + + #[test] + fn basic_credentials() { + value_scenarios!(run = |header: Option<&str>| { + let mut headers = HeaderMap::new(); + if let Some(header) = header { + headers.insert(AUTHORIZATION, header.parse().unwrap()); + } + basic_credentials_are_valid(&headers, "correct-password") + }; + "accepted" { + Some("Basic YWRtaW46Y29ycmVjdC1wYXNzd29yZA==") => true, + Some("basic YWRtaW46Y29ycmVjdC1wYXNzd29yZA==") => true, + } + "rejected" { + Some("Basic dXNlcjpjb3JyZWN0LXBhc3N3b3Jk") => false, + Some("Basic YWRtaW46d3Jvbmc=") => false, + None => false, + Some("Bearer token") => false, + Some("Basic not-base64") => false, + Some("Basic YWRtaW4=") => false, + } + ); + assert!(!basic_credentials_are_valid(&HeaderMap::new(), "")); + } + + fn auth_test_router(auth: WebAuth) -> Router { + Router::new() + .route("/", get(|| async { StatusCode::NO_CONTENT })) + .layer(axum::middleware::from_fn(web_auth_middleware_fn)) + .layer(Extension(Arc::new(auth))) + } + + #[tokio::test] + async fn valid_basic_credentials_reach_the_ui() { + let response = auth_test_router(WebAuth::basic(Some("secret".to_string()))) + .oneshot( + Request::builder() + .uri("/") + .header(AUTHORIZATION, "Basic YWRtaW46c2VjcmV0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } + + #[tokio::test] + async fn invalid_basic_credentials_receive_a_challenge() { + let response = auth_test_router(WebAuth::basic(Some("secret".to_string()))) + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.headers()[WWW_AUTHENTICATE], BASIC_AUTH_REALM); + } + + #[tokio::test] + async fn empty_basic_configuration_generates_a_usable_temporary_password() { + let auth = WebAuth::basic(None); + let WebAuth::Basic { + password, + challenge, + } = &auth + else { + panic!("basic_auth must return Basic authentication"); + }; + assert_eq!(password.len(), 32); + assert!( + password + .chars() + .all(|character| character.is_ascii_alphanumeric()) + ); + assert_eq!(*challenge, TEMPORARY_BASIC_AUTH_REALM); + + let credentials = BASE64_STANDARD.encode(format!("{BASIC_AUTH_USERNAME}:{password}")); + let response = auth_test_router(auth) + .oneshot( + Request::builder() + .uri("/") + .header(AUTHORIZATION, format!("Basic {credentials}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } + + #[tokio::test] + async fn temporary_password_challenge_points_to_the_logs() { + let response = auth_test_router(WebAuth::basic(None)) + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + response.headers()[WWW_AUTHENTICATE], + TEMPORARY_BASIC_AUTH_REALM + ); + } + + #[tokio::test] + async fn explicit_none_reaches_the_ui() { + let response = auth_test_router(WebAuth::None) + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } +} + #[derive(Template)] #[template(path = "index.html")] struct Index { diff --git a/crates/api-web/src/redfish_actions.rs b/crates/api-web/src/redfish_actions.rs index 240e688d8c..dbdaa34869 100644 --- a/crates/api-web/src/redfish_actions.rs +++ b/crates/api-web/src/redfish_actions.rs @@ -51,11 +51,12 @@ pub struct RedfishActionsTable { /// and displays the result pub async fn query( AxumState(state): AxumState>, - Extension(oauth2_layer): Extension>, + Extension(oauth2_layer): Extension>>, request_headers: HeaderMap, ) -> Response { - let cookiejar = oauth2_layer - .map(|layer| PrivateCookieJar::from_headers(&request_headers, layer.private_cookiejar_key)); + let cookiejar = oauth2_layer.map(|layer| { + PrivateCookieJar::from_headers(&request_headers, layer.private_cookiejar_key.clone()) + }); let mut browser = RedfishBrowser { actions: RedfishActionsTable { diff --git a/crates/api-web/src/redfish_browser.rs b/crates/api-web/src/redfish_browser.rs index 68c4a62bf2..b01a74237a 100644 --- a/crates/api-web/src/redfish_browser.rs +++ b/crates/api-web/src/redfish_browser.rs @@ -62,11 +62,12 @@ pub struct QueryParams { pub async fn query( AxumState(state): AxumState>, AxumQuery(query): AxumQuery, - Extension(oauth2_layer): Extension>, + Extension(oauth2_layer): Extension>>, request_headers: HeaderMap, ) -> Response { - let cookiejar = oauth2_layer - .map(|layer| PrivateCookieJar::from_headers(&request_headers, layer.private_cookiejar_key)); + let cookiejar = oauth2_layer.map(|layer| { + PrivateCookieJar::from_headers(&request_headers, layer.private_cookiejar_key.clone()) + }); let mut browser = RedfishBrowser { url: query.url.clone().unwrap_or_default(), diff --git a/crates/api-web/src/tests/mod.rs b/crates/api-web/src/tests/mod.rs index 07f91d6137..0755c2225e 100644 --- a/crates/api-web/src/tests/mod.rs +++ b/crates/api-web/src/tests/mod.rs @@ -19,7 +19,7 @@ use carbide_test_harness::prelude::TestHarness; use hyper::http::Request; use hyper::http::request::Builder; -use crate::routes; +use crate::{WebAuthMode, routes_with_auth_mode}; mod env; mod explored_endpoint; @@ -29,11 +29,11 @@ mod managed_host; mod vpc; fn make_test_app(test_harness: &TestHarness) -> Router { - let r = routes(test_harness.api_arc()).unwrap(); + let r = routes_with_auth_mode(test_harness.api_arc(), WebAuthMode::None).unwrap(); Router::new().nest_service("/admin", r) } -/// Builder for admin UI requests (in-process auth defaults to none in tests). +/// Builder for admin UI requests (tests explicitly disable in-process auth). fn web_request_builder() -> Builder { Request::builder().header("Host", "with.the.most") } diff --git a/deploy/nico-base/api/deployment.yaml b/deploy/nico-base/api/deployment.yaml index 0980876858..9a8f16a314 100644 --- a/deploy/nico-base/api/deployment.yaml +++ b/deploy/nico-base/api/deployment.yaml @@ -122,9 +122,10 @@ spec: # configMapKeyRef: # name: nico-web-api-hostname # key: hostname - # Admin UI: omit or NICO_WEB_AUTH_TYPE=none (no in-process auth; use network/proxy). - # For Entra SSO: NICO_WEB_AUTH_TYPE=oauth2 plus OAuth env vars below. - # - name: NICO_WEB_AUTH_TYPE + # Admin UI defaults to Basic Auth. Set CARBIDE_WEB_BASIC_AUTH_PASSWORD + # from a Secret, or select Entra SSO with the OAuth env vars below. + # Without one, nico-api generates a temporary password and logs it at startup. + # - name: CARBIDE_WEB_AUTH_TYPE # value: oauth2 - name: BITNAMI_DEBUG value: "false" diff --git a/dev/deployment/devspace/values.base.yaml b/dev/deployment/devspace/values.base.yaml index cba862e532..f37f6e50ea 100644 --- a/dev/deployment/devspace/values.base.yaml +++ b/dev/deployment/devspace/values.base.yaml @@ -6,6 +6,10 @@ nico-api: requests: cpu: "1" memory: "1Gi" + legacyAlias: + enabled: false + webAuth: + mode: "none" siteConfig: enabled: true nicoApiSiteConfig: | diff --git a/dev/mac-local-dev/README.md b/dev/mac-local-dev/README.md index 93f87c41fc..b9c3998acb 100644 --- a/dev/mac-local-dev/README.md +++ b/dev/mac-local-dev/README.md @@ -237,7 +237,7 @@ this ensures Vault and Postgres are initialised and the token file exists. Retrieve the environment variables for the run configuration: ```bash -echo "NICO_WEB_AUTH_TYPE=basic" +echo "CARBIDE_WEB_AUTH_TYPE=none # local development only" echo "DATABASE_URL=postgresql://postgres:admin@localhost" echo "VAULT_ADDR=http://localhost:8201" echo "VAULT_KV_MOUNT_LOCATION=secrets" diff --git a/dev/mac-local-dev/run-nico-api.sh b/dev/mac-local-dev/run-nico-api.sh index d8835fa716..ebea9262c0 100755 --- a/dev/mac-local-dev/run-nico-api.sh +++ b/dev/mac-local-dev/run-nico-api.sh @@ -136,6 +136,7 @@ fi # ----------------------------------------------------------------------------- # Environment # ----------------------------------------------------------------------------- +# Local development explicitly disables auth; production/Helm defaults to Basic. # api-web currently reads CARBIDE_WEB_AUTH_TYPE; keep both vars in sync during rename. WEB_AUTH_TYPE="${CARBIDE_WEB_AUTH_TYPE:-${NICO_WEB_AUTH_TYPE:-none}}" export NICO_WEB_AUTH_TYPE="$WEB_AUTH_TYPE" diff --git a/dev/webdev-env/run-env.sh b/dev/webdev-env/run-env.sh index a6c36c5d38..1dee5e5018 100755 --- a/dev/webdev-env/run-env.sh +++ b/dev/webdev-env/run-env.sh @@ -81,6 +81,7 @@ export VAULT_KV_MOUNT_LOCATION="secrets" export VAULT_PKI_MOUNT_LOCATION="certs" export VAULT_PKI_ROLE_NAME="role" export CARBIDE_WEB_AUTH_TYPE="none" +# This local-only environment explicitly disables auth. Deployed WebUIs default to Basic. # Run SQL migrations echo "Running database migrations..." @@ -92,8 +93,8 @@ menu() { ┌─────────────────────────────────────────┐ │ http://localhost:1079/admin/ │ - │ No in-process auth │ - │ (recommended to set oauth2) │ + │ No auth (local development only) │ + │ Deployed WebUIs default to Basic │ │ │ │ Templates: crates/api/templates/ │ │ │ diff --git a/docs/operations/debug_webui.md b/docs/operations/debug_webui.md index b9116f8c17..8782de109b 100644 --- a/docs/operations/debug_webui.md +++ b/docs/operations/debug_webui.md @@ -8,29 +8,47 @@ description: "Overview of the NICo administrative web interface, authentication NICo includes a built-in administrative web interface intended for operational debugging and inspection. It is served at the `/admin` path of the NICo API server and provides read-oriented views of infrastructure state alongside a limited set of administrative actions. -**Hardcoded Basic Auth has been removed as of NICo v0.7.0.** - -Previously, if SSO was not configured, the WebUI defaulted to basic authentication with a hardcoded credential. This fallback has been removed to resolve a P0 security vulnerability. - -- **Production / secure deployments**: Enable OIDC/SSO (`CARBIDE_WEB_AUTH_TYPE=oauth2`). See [Azure OIDC for Infra Controller Web UI](../playbooks/nico_web_oauth2.md) for setup instructions. -- **Development / lab environments**: The WebUI operates with no authentication by default. Bind the service to localhost or restrict access using a network ACL or auth proxy (for example, `mod_proxy`). - -If your current workflows rely on the default basic auth credentials, transition to an OIDC provider or proxy-based authentication before updating to v0.7.0 or later. +The WebUI defaults to Basic authentication. Helm installations generate and +persist a password in `nico-api-web-basic-auth`. As a last-resort safeguard for +non-Helm or older deployment manifests, if `CARBIDE_WEB_BASIC_AUTH_PASSWORD` is +unset or empty, NICo generates a temporary 32-character password for that +process and logs it at warning level. That fallback password is not persisted +and changes on every process launch. Use the WebUI only through the +TLS-protected endpoint. ## Authentication -Authentication mode is controlled by the `CARBIDE_WEB_AUTH_TYPE` environment variable. +For Helm installations, configure `nico-api.webAuth.mode`. The default is +`basic`; `oauth2` and `none` are explicit alternatives. A +`CARBIDE_WEB_AUTH_TYPE` entry in `nico-api.extraEnv` takes precedence as a +backward-compatibility contract. | Value | Behavior | |-------|----------| -| *(unset)* or `none` | No authentication. A warning is logged at startup. Restrict access using network controls or a reverse proxy. | +| *(unset)* or `basic` | HTTP Basic Auth with fixed username `admin`. Uses `CARBIDE_WEB_BASIC_AUTH_PASSWORD`, or a temporary password reported in the service logs when unset or empty. | | `oauth2` | Microsoft Entra (Azure AD) OIDC via PKCE flow. Group-based access enforcement via MS Graph API. | -| `basic` | **Not supported.** The service returns an error on startup if this value is set. | +| `none` | No in-process authentication. A warning is logged at startup; restrict access using network controls or an authenticating proxy. | + +For a default Helm installation, retrieve the generated password with the +`kubectl` command printed in the release notes. To use an operator-managed +credential instead: + +```yaml +nico-api: + webAuth: + mode: basic + basic: + existingSecret: + name: nico-web-password + key: password +``` ### OAuth2 (Entra) Configuration -When `CARBIDE_WEB_AUTH_TYPE=oauth2`, the following environment variables are required: +When Helm's `nico-api.webAuth.mode` is `oauth2` (or the legacy +`CARBIDE_WEB_AUTH_TYPE=oauth2` override is used), provide the following +provider settings through `nico-api.extraEnv`: | Variable | Description | |----------|-------------| diff --git a/helm/README.md b/helm/README.md index 57166a7758..d677105fed 100644 --- a/helm/README.md +++ b/helm/README.md @@ -154,15 +154,29 @@ The `global.image.repository` and `global.image.tag` values **must** be set -- t | `unbound` | `unbound.image.repository` / `.tag` | `""` (must be set) | | `unbound` (exporter) | `unbound.exporterImage.repository` / `.tag` | `""` (must be set) | +### WebUI Authentication + +The `/admin` WebUI defaults to HTTP Basic Auth with username `admin`. By +default, Helm creates `nico-api-web-basic-auth` with a generated password and +preserves that password across direct Helm upgrades. Release notes show a +`kubectl` command for retrieving it without printing it during installation. + +For an operator-managed password, set +`nico-api.webAuth.basic.existingSecret.name` and `.key`. Set +`nico-api.webAuth.mode` to `oauth2` or `none` to select another mode; those +modes do not create or reference the Basic password Secret. If a non-Helm or +older deployment does not supply `CARBIDE_WEB_BASIC_AUTH_PASSWORD`, nico-api +falls back to a temporary per-process password reported in its startup logs. + ### OAuth2 / SSO Setup To enable OAuth2 authentication (for example, Azure AD or Okta), configure the `nico-api.extraEnv` values: ```yaml nico-api: + webAuth: + mode: oauth2 extraEnv: - - name: CARBIDE_WEB_AUTH_TYPE - value: "oauth2" - name: CARBIDE_WEB_OAUTH2_AUTH_ENDPOINT value: "https://your-idp/authorize" - name: CARBIDE_WEB_OAUTH2_TOKEN_ENDPOINT @@ -171,6 +185,8 @@ nico-api: value: "your-client-id" - name: CARBIDE_WEB_ALLOWED_ACCESS_GROUPS value: "group1,group2" + - name: CARBIDE_WEB_ALLOWED_ACCESS_GROUPS_ID_LIST + value: "," - name: CARBIDE_WEB_OAUTH2_CLIENT_SECRET valueFrom: secretKeyRef: @@ -178,7 +194,12 @@ nico-api: key: client_secret ``` -The `extraEnv` array supports any Kubernetes `env` spec, including `valueFrom` references to Secrets and ConfigMaps. +The `extraEnv` array supports any Kubernetes `env` spec, including `valueFrom` +references to Secrets and ConfigMaps. For backward compatibility, a +`CARBIDE_WEB_AUTH_TYPE` entry in `extraEnv` takes precedence over +`webAuth.mode`, and the chart does not emit a duplicate mode variable. +Password variables in `extraEnv` remain supported, but +`webAuth.basic.existingSecret` is preferred. ### External LoadBalancer Services diff --git a/helm/charts/nico-api/templates/NOTES.txt b/helm/charts/nico-api/templates/NOTES.txt new file mode 100644 index 0000000000..99f527d058 --- /dev/null +++ b/helm/charts/nico-api/templates/NOTES.txt @@ -0,0 +1,7 @@ +{{- if and (eq (include "nico-api.webAuth.renderBasicSecret" .) "true") (eq (include "nico-api.webAuth.hasExistingBasicSecret" .) "false") }} +The NICo WebUI is configured for HTTP Basic authentication. + +Retrieve the generated password (username: admin) with: + + kubectl -n {{ include "nico-api.namespace" . }} get secret {{ include "nico-api.webAuth.basicSecretName" . }} -o jsonpath="{.data['{{ include "nico-api.webAuth.basicSecretKey" . }}']}" | base64 -d +{{- end }} diff --git a/helm/charts/nico-api/templates/_helpers.tpl b/helm/charts/nico-api/templates/_helpers.tpl index c4d9d111b1..e6ea47c930 100644 --- a/helm/charts/nico-api/templates/_helpers.tpl +++ b/helm/charts/nico-api/templates/_helpers.tpl @@ -45,6 +45,84 @@ Global image reference {{ .Values.global.image.repository }}:{{ .Values.global.image.tag }} {{- end }} +{{/* Validate and return the configured WebUI authentication mode. */}} +{{- define "nico-api.webAuth.configuredMode" -}} +{{- $mode := default "basic" .Values.webAuth.mode -}} +{{- if not (has $mode (list "basic" "oauth2" "none")) -}} +{{- fail (printf "nico-api.webAuth.mode must be one of basic, oauth2, or none; got %q" $mode) -}} +{{- end -}} +{{- $mode -}} +{{- end -}} + +{{/* Whether extraEnv contains the legacy mode variable, including valueFrom entries. */}} +{{- define "nico-api.webAuth.hasModeOverride" -}} +{{- $found := false -}} +{{- range .Values.extraEnv -}} + {{- if eq .name "CARBIDE_WEB_AUTH_TYPE" -}} + {{- $found = true -}} + {{- end -}} +{{- end -}} +{{- $found -}} +{{- end -}} + +{{/* A literal legacy override, or an empty string when absent/valueFrom. */}} +{{- define "nico-api.webAuth.literalModeOverride" -}} +{{- range .Values.extraEnv -}} + {{- if and (eq .name "CARBIDE_WEB_AUTH_TYPE") (hasKey . "value") -}} + {{- .value -}} + {{- end -}} +{{- end -}} +{{- end -}} + +{{- define "nico-api.webAuth.hasLiteralModeOverride" -}} +{{- $found := false -}} +{{- range .Values.extraEnv -}} + {{- if and (eq .name "CARBIDE_WEB_AUTH_TYPE") (hasKey . "value") -}} + {{- $found = true -}} + {{- end -}} +{{- end -}} +{{- $found -}} +{{- end -}} + +{{/* Statically known effective mode. "unknown" means extraEnv uses valueFrom. */}} +{{- define "nico-api.webAuth.effectiveMode" -}} +{{- $override := include "nico-api.webAuth.literalModeOverride" . -}} +{{- if eq (include "nico-api.webAuth.hasLiteralModeOverride" .) "true" -}} + {{- if not (has $override (list "basic" "oauth2" "none")) -}} + {{- fail (printf "literal CARBIDE_WEB_AUTH_TYPE in nico-api.extraEnv must be one of basic, oauth2, or none; got %q" $override) -}} + {{- end -}} + {{- $override -}} +{{- else if eq (include "nico-api.webAuth.hasModeOverride" .) "true" -}} +unknown +{{- else -}} +{{- include "nico-api.webAuth.configuredMode" . -}} +{{- end -}} +{{- end -}} + +{{/* Render a Basic password Secret for known basic mode, or conservative valueFrom mode. */}} +{{- define "nico-api.webAuth.renderBasicSecret" -}} +{{- $effective := include "nico-api.webAuth.effectiveMode" . -}} +{{- if eq $effective "basic" -}} +true +{{- else if eq $effective "unknown" -}} + {{- if eq (include "nico-api.webAuth.configuredMode" .) "basic" -}}true{{- else -}}false{{- end -}} +{{- else -}} +false +{{- end -}} +{{- end -}} + +{{- define "nico-api.webAuth.basicSecretName" -}} +{{- default "nico-api-web-basic-auth" .Values.webAuth.basic.existingSecret.name -}} +{{- end -}} + +{{- define "nico-api.webAuth.basicSecretKey" -}} +{{- default "password" .Values.webAuth.basic.existingSecret.key -}} +{{- end -}} + +{{- define "nico-api.webAuth.hasExistingBasicSecret" -}} +{{- if .Values.webAuth.basic.existingSecret.name -}}true{{- else -}}false{{- end -}} +{{- end -}} + {{/* Certificate spec Usage: {{ include "nico-api.certificateSpec" (dict "name" "{{ include "nico-api.name" . }}-certificate" "cert" .Values.certificate "global" .Values.global "namespace" (include "nico-api.namespace" .)) }} diff --git a/helm/charts/nico-api/templates/deployment.yaml b/helm/charts/nico-api/templates/deployment.yaml index a091306a21..1d66f4e57d 100644 --- a/helm/charts/nico-api/templates/deployment.yaml +++ b/helm/charts/nico-api/templates/deployment.yaml @@ -140,6 +140,17 @@ spec: configMapKeyRef: name: {{ .Values.envFrom.nicoWebHostname.configMapName }} key: hostname + {{- if ne (include "nico-api.webAuth.hasModeOverride" .) "true" }} + - name: CARBIDE_WEB_AUTH_TYPE + value: {{ include "nico-api.webAuth.configuredMode" . | quote }} + {{- end }} + {{- if eq (include "nico-api.webAuth.renderBasicSecret" .) "true" }} + - name: CARBIDE_WEB_BASIC_AUTH_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "nico-api.webAuth.basicSecretName" . }} + key: {{ include "nico-api.webAuth.basicSecretKey" . }} + {{- end }} - name: RUST_BACKTRACE value: {{ .Values.env.RUST_BACKTRACE | quote }} - name: RUST_LIB_BACKTRACE diff --git a/helm/charts/nico-api/templates/web-basic-auth-secret.yaml b/helm/charts/nico-api/templates/web-basic-auth-secret.yaml new file mode 100644 index 0000000000..fac4d0eb48 --- /dev/null +++ b/helm/charts/nico-api/templates/web-basic-auth-secret.yaml @@ -0,0 +1,19 @@ +{{- if and (eq (include "nico-api.webAuth.renderBasicSecret" .) "true") (eq (include "nico-api.webAuth.hasExistingBasicSecret" .) "false") }} +{{- $secretName := include "nico-api.webAuth.basicSecretName" . }} +{{- $secretKey := include "nico-api.webAuth.basicSecretKey" . }} +{{- $password := randAlphaNum 32 | b64enc }} +{{- $existing := lookup "v1" "Secret" (include "nico-api.namespace" .) $secretName }} +{{- if and $existing $existing.data (hasKey $existing.data $secretKey) }} +{{- $password = index $existing.data $secretKey }} +{{- end }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $secretName }} + namespace: {{ include "nico-api.namespace" . }} + labels: + {{- include "nico-api.labels" . | nindent 4 }} +type: Opaque +data: + {{ $secretKey }}: {{ $password | quote }} +{{- end }} diff --git a/helm/charts/nico-api/tests/web_auth_deployment_test.yaml b/helm/charts/nico-api/tests/web_auth_deployment_test.yaml new file mode 100644 index 0000000000..8ae70a1d87 --- /dev/null +++ b/helm/charts/nico-api/tests/web_auth_deployment_test.yaml @@ -0,0 +1,149 @@ +suite: WebUI authentication deployment configuration +templates: + - deployment.yaml +tests: + - it: defaults to Basic and injects the generated password Secret + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: CARBIDE_WEB_AUTH_TYPE + value: basic + - contains: + path: spec.template.spec.containers[0].env + content: + name: CARBIDE_WEB_BASIC_AUTH_PASSWORD + valueFrom: + secretKeyRef: + name: nico-api-web-basic-auth + key: password + + - it: injects an existing Basic password Secret + set: + webAuth.basic.existingSecret.name: operator-web-password + webAuth.basic.existingSecret.key: credential + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: CARBIDE_WEB_BASIC_AUTH_PASSWORD + valueFrom: + secretKeyRef: + name: operator-web-password + key: credential + + - it: generates the default Secret with a custom key + set: + webAuth.basic.existingSecret.key: credential + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: CARBIDE_WEB_BASIC_AUTH_PASSWORD + valueFrom: + secretKeyRef: + name: nico-api-web-basic-auth + key: credential + + - it: oauth2 mode does not inject a Basic password + set: + webAuth.mode: oauth2 + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: CARBIDE_WEB_AUTH_TYPE + value: oauth2 + - notContains: + path: spec.template.spec.containers[0].env + content: + name: CARBIDE_WEB_BASIC_AUTH_PASSWORD + + - it: none mode does not inject a Basic password + set: + webAuth.mode: none + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: CARBIDE_WEB_AUTH_TYPE + value: none + - notContains: + path: spec.template.spec.containers[0].env + content: + name: CARBIDE_WEB_BASIC_AUTH_PASSWORD + + - it: literal legacy mode takes precedence without a duplicate built-in mode + set: + extraEnv: + - name: CARBIDE_WEB_AUTH_TYPE + value: oauth2 + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: CARBIDE_WEB_AUTH_TYPE + value: oauth2 + - notContains: + path: spec.template.spec.containers[0].env + content: + name: CARBIDE_WEB_BASIC_AUTH_PASSWORD + + - it: literal legacy mode takes precedence over an invalid configured mode + set: + webAuth.mode: saml + extraEnv: + - name: CARBIDE_WEB_AUTH_TYPE + value: oauth2 + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: CARBIDE_WEB_AUTH_TYPE + value: oauth2 + - notContains: + path: spec.template.spec.containers[0].env + content: + name: CARBIDE_WEB_BASIC_AUTH_PASSWORD + + - it: preserves a valueFrom legacy mode and conservatively injects a password + set: + extraEnv: + - name: CARBIDE_WEB_AUTH_TYPE + valueFrom: + configMapKeyRef: + name: web-auth-mode + key: mode + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: CARBIDE_WEB_AUTH_TYPE + valueFrom: + configMapKeyRef: + name: web-auth-mode + key: mode + - contains: + path: spec.template.spec.containers[0].env + content: + name: CARBIDE_WEB_BASIC_AUTH_PASSWORD + valueFrom: + secretKeyRef: + name: nico-api-web-basic-auth + key: password + + - it: rejects an invalid configured mode + set: + webAuth.mode: saml + asserts: + - failedTemplate: + errorMessage: 'nico-api.webAuth.mode must be one of basic, oauth2, or none; got "saml"' + + - it: rejects an invalid literal legacy mode + set: + extraEnv: + - name: CARBIDE_WEB_AUTH_TYPE + value: saml + asserts: + - failedTemplate: + errorMessage: 'literal CARBIDE_WEB_AUTH_TYPE in nico-api.extraEnv must be one of basic, oauth2, or none; got "saml"' diff --git a/helm/charts/nico-api/tests/web_auth_secret_test.yaml b/helm/charts/nico-api/tests/web_auth_secret_test.yaml new file mode 100644 index 0000000000..4c540da87b --- /dev/null +++ b/helm/charts/nico-api/tests/web_auth_secret_test.yaml @@ -0,0 +1,79 @@ +suite: WebUI Basic authentication Secret +templates: + - web-basic-auth-secret.yaml +tests: + - it: generates a 32-character password in the default mode + asserts: + - isKind: + of: Secret + - equal: + path: metadata.name + value: nico-api-web-basic-auth + - matchRegex: + path: data.password + pattern: '^[A-Za-z0-9+/]{42}[A-Za-z0-9+/=]{2}$' + + - it: generates the default Secret with a custom key + set: + webAuth.basic.existingSecret.key: credential + asserts: + - equal: + path: metadata.name + value: nico-api-web-basic-auth + - matchRegex: + path: data.credential + pattern: '^[A-Za-z0-9+/]{42}[A-Za-z0-9+/=]{2}$' + - notExists: + path: data.password + + - it: does not generate a Secret when an existing one is configured + set: + webAuth.basic.existingSecret.name: operator-web-password + asserts: + - hasDocuments: + count: 0 + + - it: does not generate a Secret for oauth2 + set: + webAuth.mode: oauth2 + asserts: + - hasDocuments: + count: 0 + + - it: does not generate a Secret for none + set: + webAuth.mode: none + asserts: + - hasDocuments: + count: 0 + + - it: does not generate a Secret for a literal legacy oauth2 override + set: + extraEnv: + - name: CARBIDE_WEB_AUTH_TYPE + value: oauth2 + asserts: + - hasDocuments: + count: 0 + + - it: literal legacy mode takes precedence over an invalid configured mode + set: + webAuth.mode: saml + extraEnv: + - name: CARBIDE_WEB_AUTH_TYPE + value: oauth2 + asserts: + - hasDocuments: + count: 0 + + - it: conservatively generates a Secret for a valueFrom mode override + set: + extraEnv: + - name: CARBIDE_WEB_AUTH_TYPE + valueFrom: + configMapKeyRef: + name: web-auth-mode + key: mode + asserts: + - isKind: + of: Secret diff --git a/helm/charts/nico-api/values.yaml b/helm/charts/nico-api/values.yaml index cd7cdbdea8..be62359e91 100644 --- a/helm/charts/nico-api/values.yaml +++ b/helm/charts/nico-api/values.yaml @@ -150,6 +150,18 @@ env: extraEnv: [] +## Authentication for the /admin WebUI. CARBIDE_WEB_AUTH_TYPE in extraEnv +## takes precedence for compatibility with existing installations. +webAuth: + ## Allowed values: basic, oauth2, none. + mode: basic + basic: + ## Use an operator-managed password Secret instead of generating one. + ## Leave name empty to create nico-api-web-basic-auth with a generated password. + existingSecret: + name: "" + key: password + ## External secret/configmap references mounted into the pod envFrom: vaultApproleTokens: diff --git a/helm/examples/values-full.yaml b/helm/examples/values-full.yaml index 08537d741b..8e7dfb9f8b 100644 --- a/helm/examples/values-full.yaml +++ b/helm/examples/values-full.yaml @@ -73,24 +73,28 @@ nico-api: RUST_BACKTRACE: "full" RUST_LIB_BACKTRACE: "0" - ## OAuth2/SSO authentication — configure via extraEnv - ## These are not built into the chart to keep it auth-provider agnostic. + ## WebUI authentication defaults to Basic with a generated password Secret. + ## Select OAuth2 here; provider-specific settings remain in extraEnv. + webAuth: + mode: oauth2 + extraEnv: - - name: NICO_WEB_AUTH_TYPE - value: "oauth2" - - name: NICO_WEB_OAUTH2_AUTH_ENDPOINT + - name: CARBIDE_WEB_OAUTH2_AUTH_ENDPOINT value: "https://login.example.com/oauth2/v2.0/authorize" - - name: NICO_WEB_OAUTH2_TOKEN_ENDPOINT + - name: CARBIDE_WEB_OAUTH2_TOKEN_ENDPOINT value: "https://login.example.com/oauth2/v2.0/token" - - name: NICO_WEB_OAUTH2_CLIENT_ID + - name: CARBIDE_WEB_OAUTH2_CLIENT_ID value: "your-oauth2-client-id" - - name: NICO_WEB_OAUTH2_CLIENT_SECRET + - name: CARBIDE_WEB_OAUTH2_CLIENT_SECRET valueFrom: secretKeyRef: name: azure-sso-nico-web-client-secret key: client_secret - - name: NICO_WEB_ALLOWED_ACCESS_GROUPS + - name: CARBIDE_WEB_ALLOWED_ACCESS_GROUPS value: "nico-admins,nico-operators" + ## Fail-closed examples: replace with the corresponding group object IDs. + - name: CARBIDE_WEB_ALLOWED_ACCESS_GROUPS_ID_LIST + value: "00000000-0000-0000-0000-000000000001,00000000-0000-0000-0000-000000000002" ## NICo Web UI hostname hostname: "nico.example.com"