From 0dc4c6be59eb3e68958c31073e03a800a17538ce Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 22 Oct 2026 00:18:27 +0000 Subject: [PATCH 1/4] Authenticate database runtime credentials and the none driver like PHP Native refused DW_RUNTIME_CREDENTIALS_ENABLED and DW_AUTH_DRIVER=none at startup, which the Sample App's children and namespaces scenarios use. - Serve PHP's admin-only /api/runtime-credentials list, show, upsert, rotate and revoke, with the controller's validation and conflict refusals. Only the token's SHA-256 and 12-byte prefix are stored. - With the setting on, authenticate an unknown bearer token by its hash as a principal with its subject, roles, tenant and claims, and confine a tenant principal to its namespace, as ConfiguredAuthProvider::tenantAllows does, including a namespace route's target from the path or the name query. - Admit every request as the anonymous administrator with the none driver. - Record the principal's identity in task repair contexts. Co-Authored-By: Claude Opus 5.5 --- .../2026-10-22-runtime-credentials.md | 76 +++ docs/rust-server-port.md | 7 +- rust/src/main.rs | 40 +- rust/src/runtime/execution.rs | 24 + rust/src/runtime/http.rs | 125 ++++- rust/src/runtime/laravel_rules.rs | 5 +- rust/src/runtime/mod.rs | 1 + rust/src/runtime/runtime_credentials.rs | 432 ++++++++++++++++++ rust/src/runtime/task_repair_http.rs | 22 +- rust/tests/execution.rs | 2 + rust/tests/execution/runtime_credentials.rs | 112 +++++ 11 files changed, 809 insertions(+), 37 deletions(-) create mode 100644 docs/rust-server-port-checkpoints/2026-10-22-runtime-credentials.md create mode 100644 rust/src/runtime/runtime_credentials.rs create mode 100644 rust/tests/execution/runtime_credentials.rs diff --git a/docs/rust-server-port-checkpoints/2026-10-22-runtime-credentials.md b/docs/rust-server-port-checkpoints/2026-10-22-runtime-credentials.md new file mode 100644 index 000000000..98a8985ac --- /dev/null +++ b/docs/rust-server-port-checkpoints/2026-10-22-runtime-credentials.md @@ -0,0 +1,76 @@ +# October 22, runtime credentials + +- **Found by.** The Sample App's children and namespaces scenarios on the + second disposable VM run (Server #325 comment 6328544797). Both set + `DW_RUNTIME_CREDENTIALS_ENABLED=true` and give each worker its own + namespace-scoped token. Native refused that setting at startup. The + namespaces scenario's anonymous phase also restarts the server with + `DW_AUTH_DRIVER=none`, which native refused too. +- **Source.** Native follows published PHP 2.5.14: + - **Management API.** `RuntimeCredentialController`, admin-only: + - `GET /api/runtime-credentials` lists every credential, oldest first; + - `GET /api/runtime-credentials/{id}` shows one; + - `PUT /api/runtime-credentials/{id}` creates one (201), confirms an + identical one (200, `created: false`), or refuses with + `409 runtime_credential_conflict` or, for a token another credential + uses, `409 runtime_credential_token_conflict`; + - `POST /api/runtime-credentials/{id}/rotate` replaces the token and + clears a revocation; + - `DELETE /api/runtime-credentials/{id}` revokes it. + - **Validation.** The controller's rules and Laravel's messages: the + credential id format, a token of 32 to 512 characters, roles from + `operator` and `worker`, the tenant format, claims as an object, and a + valid `expires_at`. Roles are deduplicated and the tenant is lowercased. + - **Storage.** The `runtime_credentials` table holds the SHA-256 of the + token and its 12-byte prefix, never the token. Responses carry the + prefix and PHP's six-digit `+00:00` timestamps. + - **Authentication.** `ConfiguredAuthProvider`: with the setting on, a + bearer token that matches no configured token is looked up by its hash. + An unexpired, unrevoked credential becomes a principal with its subject, + roles, tenant and claims. + - **Authorization.** `RouteAuthorizationResource` and `tenantAllows`. A + principal with a tenant reaches only the requested namespace equal to + its tenant (the `X-Namespace` header, else the `namespace` query, else + `default`). On a namespace route, the target is the path segment or else + the `name` query field, and it must match the tenant too. Then one of + the principal's roles must be allowed on the route. A refusal is PHP's + 403 with the principal's first role. + - **Settings.** `DW_RUNTIME_CREDENTIALS_ENABLED`, or its + `WORKFLOW_SERVER_` name, is read with PHP's `FILTER_VALIDATE_BOOLEAN`, so + an unrecognized value turns it off. `DW_AUTH_DRIVER=none` admits every + request as the anonymous administrator. + - **Task repair.** Repair records the principal's subject, roles, method, + tenant and claims instead of a fixed legacy token identity. +- **Differences from PHP.** + - Native still requires `DW_AUTH_TOKEN` at startup, with the `none` + driver or with database credentials. PHP needs no legacy token then. + - Principal tokens (`DW_PRINCIPAL_TOKENS`), custom auth providers and the + signature driver still refuse startup. Runtime credentials grant only + `operator` and `worker`, so no tenant-bound administrator exists natively. + For that reason native does not read a namespace target from a JSON body + `name`, which PHP does; only creating a namespace sends one, and that + needs an administrator. + - Native has no service endpoints, so it does not read `caller_namespace`. + - Commands and history do not yet record the authenticated principal. + That remains the existing command-context gap. +- **Verification.** + - Three integration tests pass on SQLite, PostgreSQL and MySQL: managing + credentials, authenticating within a tenant through rotation and + revocation, and the `none` driver. Two unit tests cover the tenant rule + and token hashing. The full SQLite suite passes (331 integration, 189 + library). The auth, role, token and namespace tests pass on PostgreSQL + and MySQL (60 each). + - In the Waterline harness, 14 management requests went to published PHP + 2.5.14 and to native: create, confirm, both conflicts, four validation + failures, show, missing, rotate refusals and revoke. Apart from + timestamps and JSON key order, the outputs were identical. + - With `DW_RUNTIME_CREDENTIALS_ENABLED=true` on both servers, 24 + authentication requests matched in status: worker, operator and + cross-tenant credentials on worker, operator, admin and namespace routes, + expired, unknown, malformed and revoked tokens. The 401 and 403 bodies + were identical. Nine more requests checked the namespace route target + from the path and from `name`, with case and whitespace; all matched. + - The remaining body differences were outside this change: native answers + an empty worker registration with `invalid_request` (the registration + manifest gap), advertises fewer worker capabilities, and PHP's list held + rows from its shared database. diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 0a7394960..050522f8a 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -77,7 +77,6 @@ CLI and Waterline use the first group. - Durable workflow stream appends and closes. - Service catalogs and endpoints. - Nexus operations. - - Runtime credentials. - Deployments. - Bridge adapters. ## Known gaps found in review @@ -203,6 +202,12 @@ contract fixture before its correction. this with `422 termination_propagation_not_available` until the parent-side wait, child summary and failure projections for `ChildRunTerminated` have a fixture. +- **Authentication beyond role tokens and runtime credentials.** Native + refuses principal tokens (`DW_PRINCIPAL_TOKENS`), custom providers and the + signature driver at startup. It still requires `DW_AUTH_TOKEN` with the + `none` driver or database runtime credentials, which published PHP does + not. Without a tenant-bound administrator, native does not read a namespace + route's target from a JSON body `name`. ## Contract and verification inventory diff --git a/rust/src/main.rs b/rust/src/main.rs index 22003f39f..e3f4bfe0b 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -81,12 +81,33 @@ fn auth_value(key: &str) -> Result, Box> { } } +/// `server.auth.driver`: DW-primary, WORKFLOW-legacy, token by default. +fn anonymous_driver() -> Result> { + let driver = match auth_value("DW_AUTH_DRIVER")? { + Some(value) => Some(value), + None => auth_value("WORKFLOW_SERVER_AUTH_DRIVER")?, + }; + Ok(driver.as_deref() == Some("none")) +} + +/// `server.auth.runtime_credentials.enabled`: PHP's `FILTER_VALIDATE_BOOLEAN` +/// with `FILTER_NULL_ON_FAILURE`, so an unrecognized value disables it. +fn runtime_credentials_enabled() -> Result> { + let value = match auth_value("DW_RUNTIME_CREDENTIALS_ENABLED")? { + Some(value) => Some(value), + None => auth_value("WORKFLOW_SERVER_RUNTIME_CREDENTIALS_ENABLED")?, + }; + Ok(value.is_some_and(|value| { + matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "on" | "yes") + })) +} + fn validate_auth_configuration() -> Result<(), Box> { // Unqualified credentials must not silently leave a full-access legacy // guard in place. These profiles require their own contract qualification. for key in ["DW_AUTH_DRIVER", "WORKFLOW_SERVER_AUTH_DRIVER"] { if let Some(value) = auth_value(key)? { - if value != "token" { + if value != "token" && value != "none" { return Err("unsupported development authentication configuration".into()); } } @@ -104,19 +125,6 @@ fn validate_auth_configuration() -> Result<(), Box> { return Err("unsupported development authentication configuration".into()); } } - for key in [ - "DW_RUNTIME_CREDENTIALS_ENABLED", - "WORKFLOW_SERVER_RUNTIME_CREDENTIALS_ENABLED", - ] { - if auth_value(key)?.is_some_and(|value| { - !matches!( - value.to_ascii_lowercase().as_str(), - "" | "0" | "false" | "off" | "no" - ) - }) { - return Err("unsupported development authentication configuration".into()); - } - } for key in [ "DW_AUTH_BACKWARD_COMPATIBLE", "WORKFLOW_SERVER_AUTH_BACKWARD_COMPATIBLE", @@ -444,7 +452,9 @@ async fn main() -> Result<(), Box> { return Err("unsupported development database".into()); } }; - let runtime = runtime.with_role_tokens(worker_token, operator_token, admin_token); + let runtime = runtime + .with_role_tokens(worker_token, operator_token, admin_token) + .with_auth_options(anonymous_driver()?, runtime_credentials_enabled()?); let listener = tokio::net::TcpListener::bind(address).await?; eprintln!( "Rust development runtime listening on {}", diff --git a/rust/src/runtime/execution.rs b/rust/src/runtime/execution.rs index 021692ed0..0557e2959 100644 --- a/rust/src/runtime/execution.rs +++ b/rust/src/runtime/execution.rs @@ -34,6 +34,8 @@ pub struct Runtime { storage: Arc, pub(crate) token: Arc, pub(crate) role_tokens: [Option>; 3], + /// `DW_AUTH_DRIVER=none` and `DW_RUNTIME_CREDENTIALS_ENABLED`. + pub(crate) auth: AuthOptions, scheduler: Arc, signal_codec: Arc, // Codec work done while holding the transition lock takes this permit, @@ -110,6 +112,15 @@ impl Drop for TimerScheduler { } } +/// How requests authenticate beyond the configured tokens. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct AuthOptions { + /// `DW_AUTH_DRIVER=none`: every request is the anonymous administrator. + pub(crate) anonymous: bool, + /// Database runtime credentials authenticate bearer tokens. + pub(crate) runtime_credentials: bool, +} + macro_rules! delegate { ($name:ident($($arg:ident: $ty:ty),* $(,)?) -> $output:ty) => { pub(crate) async fn $name(&self, $($arg:$ty),*) -> $output { @@ -144,6 +155,11 @@ impl Runtime { self } + pub fn with_auth_options(mut self, anonymous: bool, runtime_credentials: bool) -> Self { + self.auth = AuthOptions { anonymous, runtime_credentials }; + self + } + fn validate_token(token: &str) -> Result<()> { if token.trim().is_empty() { return Err(refuse( @@ -209,6 +225,7 @@ impl Runtime { storage: Arc::new(storage), token: token.into(), role_tokens: [None, None, None], + auth: AuthOptions::default(), scheduler: Arc::new(TimerScheduler::new()), signal_codec: Arc::new(Semaphore::new(1)), transition_codec: Arc::new(Semaphore::new(1)), @@ -278,6 +295,7 @@ impl Runtime { storage: Arc::new(storage), token: self.token.clone(), role_tokens: self.role_tokens.clone(), + auth: self.auth, scheduler: self.scheduler.clone(), signal_codec: self.signal_codec.clone(), transition_codec: self.transition_codec.clone(), @@ -305,6 +323,12 @@ impl Runtime { delegate!(activity_timeout_pass(limit: i64, ids: Vec) -> Result<(Value, bool)>); delegate!(create_namespace(body: Value) -> Result); delegate!(list_search_attributes() -> Result); + delegate!(runtime_credential_principal(token: &str) -> Result>); + delegate!(list_runtime_credentials() -> Result); + delegate!(show_runtime_credential(id: &str) -> Result); + delegate!(upsert_runtime_credential(id: &str, body: Value) -> Result<(axum::http::StatusCode, Value)>); + delegate!(rotate_runtime_credential(id: &str, body: Value) -> Result); + delegate!(revoke_runtime_credential(id: &str) -> Result); delegate!( storage_test_round_trip( local: &crate::external_payload::LocalStorage, diff --git a/rust/src/runtime/http.rs b/rust/src/runtime/http.rs index f307045bb..41f8554d3 100644 --- a/rust/src/runtime/http.rs +++ b/rust/src/runtime/http.rs @@ -12,13 +12,14 @@ use serde::Deserialize; use serde_json::{Value, json}; use subtle::ConstantTimeEq; +use super::runtime_credentials::Principal; use super::{Result, Runtime, cancellation::TerminalCommand, refuse, store::capabilities}; #[derive(Clone)] pub(super) struct AuthenticatedPrincipal { pub(super) role: &'static str, - pub(super) legacy_credential: bool, pub(super) legacy_full_access: bool, + pub(super) principal: super::runtime_credentials::Principal, } pub fn router(runtime: Runtime) -> Router { @@ -51,6 +52,17 @@ pub fn router(runtime: Runtime) -> Router { "/api/search-attributes", get(list_search_attributes).post(create_search_attribute), ) + .route("/api/runtime-credentials", get(list_runtime_credentials)) + .route( + "/api/runtime-credentials/{credential_id}", + get(show_runtime_credential) + .put(upsert_runtime_credential) + .delete(revoke_runtime_credential), + ) + .route( + "/api/runtime-credentials/{credential_id}/rotate", + post(rotate_runtime_credential), + ) .route( "/api/search-attributes/{name}", delete(delete_search_attribute), @@ -305,7 +317,7 @@ async fn guard(State(runtime): State, mut request: Request, next: Next) .and_then(|v| v.to_str().ok()); let provided = authorization.and_then(|v| v.strip_prefix("Bearer ")); let roles = ["worker", "operator", "admin"]; - let authenticated = provided.and_then(|provided| { + let configured = provided.and_then(|provided| { // Match configured roles before the legacy credential, matching PHP's // collision precedence. Equal-length secret contents use constant-time // comparison; secrets never enter response or diagnostic values. @@ -314,18 +326,42 @@ async fn guard(State(runtime): State, mut request: Request, next: Next) .as_ref() .is_some_and(|secret| bool::from(provided.as_bytes().ct_eq(secret.as_bytes()))) { - return Some((role, false, false)); + return Some((Principal::role(role, "token", false), false)); } } bool::from(provided.as_bytes().ct_eq(runtime.token.as_bytes())).then(|| { - ( + let mut principal = Principal::role( "admin", + "token", runtime.role_tokens.iter().all(Option::is_none), - true, - ) + ); + principal.subject = "legacy-token".into(); + principal.claims = json!({"legacy_credential": true}); + (principal, true) }) }); - let Some((role, legacy_full_access, legacy_credential)) = authenticated else { + // `ConfiguredAuthProvider::authenticate`: the none driver admits every + // request as the anonymous administrator; otherwise database runtime + // credentials follow the configured tokens. + let authenticated = if runtime.auth.anonymous { + let mut principal = Principal::role("admin", "none", true); + principal.subject = "anonymous".into(); + Some((principal, false)) + } else if configured.is_some() { + configured + } else if let (true, Some(provided)) = (runtime.auth.runtime_credentials, provided) { + match runtime.runtime_credential_principal(provided).await { + Ok(principal) => principal.map(|principal| (principal, false)), + Err(_) => { + return admission_error(request, worker, StatusCode::INTERNAL_SERVER_ERROR, + json!({"reason":"server_error", + "message":"Database runtime credential authentication is enabled but unavailable."})).await; + } + } + } else { + None + }; + let Some((principal, _legacy_credential)) = authenticated else { if payload { return admission_error( request, @@ -346,6 +382,12 @@ async fn guard(State(runtime): State, mut request: Request, next: Next) ) .await; }; + let legacy_full_access = principal.legacy_full_access; + let role = match principal.primary_role() { + Some("worker") => "worker", + Some("operator") => "operator", + _ => "admin", + }; let allowed_roles: &[&str] = if path == "/api/external-payloads/v1" && request.method() == Method::POST && request @@ -364,15 +406,39 @@ async fn guard(State(runtime): State, mut request: Request, next: Next) || path.starts_with("/api/namespaces/") && request.method() == Method::PUT // PHP's system routes are admin-only. || path.starts_with("/api/system/") + || path == "/api/runtime-credentials" + || path.starts_with("/api/runtime-credentials/") { &["admin"] } else { &["operator", "admin"] }; - if !legacy_full_access && !allowed_roles.contains(&role) { + // `RouteAuthorizationResource`: the requested namespace, and the target + // of a namespace route. + let requested_namespace = match request.headers().get("x-namespace") { + Some(header) => header.to_str().unwrap_or("").to_lowercase(), + None => Query::>::try_from_uri(request.uri()) + .ok() + .and_then(|Query(fields)| fields.get("namespace").cloned()) + .unwrap_or_else(|| "default".into()) + .to_lowercase(), + }; + let namespace_family = path == "/api/namespaces" || path.starts_with("/api/namespaces/"); + // A namespace route names its target in the path, or else in `name`. + // PHP also reads `name` from a JSON body; only creating a namespace sends + // one, and that needs an admin, which native never binds to a tenant. + let target_namespace = match path.strip_prefix("/api/namespaces/").and_then(|rest| rest.split('/').next()) { + Some(segment) if !segment.is_empty() => Some(segment.trim().to_lowercase()), + _ if namespace_family => Query::>::try_from_uri(request.uri()) + .ok() + .and_then(|Query(fields)| fields.get("name").map(|name| name.trim().to_lowercase())) + .filter(|name| !name.is_empty()), + _ => None, + }; + if !principal.allows(allowed_roles, &requested_namespace, target_namespace.as_deref(), namespace_family) { return admission_error(request, worker, StatusCode::FORBIDDEN, json!({ "reason":"forbidden", "message":"Authenticated role is not allowed to access this endpoint.", - "role":role, "allowed_roles":allowed_roles, + "role":principal.primary_role(), "allowed_roles":allowed_roles, })).await; } let header = if worker { @@ -476,8 +542,8 @@ async fn guard(State(runtime): State, mut request: Request, next: Next) } request.extensions_mut().insert(AuthenticatedPrincipal { role, - legacy_credential, legacy_full_access, + principal, }); let mut response = next.run(request).await; response @@ -487,6 +553,10 @@ async fn guard(State(runtime): State, mut request: Request, next: Next) } fn named_namespace_operation(method: &str, path: &str) -> bool { + // Runtime credentials are server-wide; the namespace only scopes a tenant. + if path == "/api/runtime-credentials" || path.starts_with("/api/runtime-credentials/") { + return true; + } if path == "/api/external-payloads/v1" { return method == "POST"; } @@ -641,6 +711,41 @@ async fn storage_test( Ok(Json(super::storage_test::storage_test(&runtime, body).await?)) } +async fn list_runtime_credentials(Extension(runtime): Extension) -> Result> { + Ok(Json(runtime.list_runtime_credentials().await?)) +} + +async fn show_runtime_credential( + Extension(runtime): Extension, + Path(credential_id): Path, +) -> Result> { + Ok(Json(runtime.show_runtime_credential(&credential_id).await?)) +} + +async fn upsert_runtime_credential( + Extension(runtime): Extension, + Path(credential_id): Path, + Json(body): Json, +) -> Result<(StatusCode, Json)> { + let (status, body) = runtime.upsert_runtime_credential(&credential_id, body).await?; + Ok((status, Json(body))) +} + +async fn rotate_runtime_credential( + Extension(runtime): Extension, + Path(credential_id): Path, + Json(body): Json, +) -> Result> { + Ok(Json(runtime.rotate_runtime_credential(&credential_id, body).await?)) +} + +async fn revoke_runtime_credential( + Extension(runtime): Extension, + Path(credential_id): Path, +) -> Result> { + Ok(Json(runtime.revoke_runtime_credential(&credential_id).await?)) +} + async fn list_search_attributes(Extension(runtime): Extension) -> Result> { Ok(Json(runtime.list_search_attributes().await?)) } diff --git a/rust/src/runtime/laravel_rules.rs b/rust/src/runtime/laravel_rules.rs index cd068fef3..66cc2a192 100644 --- a/rust/src/runtime/laravel_rules.rs +++ b/rust/src/runtime/laravel_rules.rs @@ -32,6 +32,8 @@ pub(crate) enum Rule { In(&'static [&'static str]), /// `date`, for the ISO-8601 shapes `schedule_backfill::php_date` reads. Date, + /// `regex:...`, as a predicate on the string. + Pattern(fn(&str) -> bool), } pub(crate) type Rules = [(&'static str, &'static [Rule])]; @@ -188,7 +190,7 @@ fn message(rule: Rule, attribute: &str, rules: &[Rule], value: Option<&Value>) - "array" => format!("The {attribute} field must not have more than {limit} items."), _ => format!("The {attribute} field must not be greater than {limit} characters."), }, - Rule::NotBlank => format!("The {attribute} field format is invalid."), + Rule::NotBlank | Rule::Pattern(_) => format!("The {attribute} field format is invalid."), Rule::Date => format!("The {attribute} field must be a valid date."), Rule::Nullable | Rule::Sometimes => String::new(), } @@ -223,6 +225,7 @@ fn passes(rule: Rule, data: &Value, value: Option<&Value>, rules: &[Rule]) -> bo Rule::Nullable | Rule::Sometimes => true, Rule::NotBlank => value.and_then(Value::as_str).is_some_and(|text| text.chars().any(|character| !character.is_whitespace())), Rule::String => value.is_some_and(Value::is_string), + Rule::Pattern(matches) => value.and_then(Value::as_str).is_some_and(matches), Rule::Date => value .and_then(Value::as_str) .is_some_and(|text| super::schedule_backfill::php_date(text).is_some()), diff --git a/rust/src/runtime/mod.rs b/rust/src/runtime/mod.rs index d0a7938e8..0a31d6e82 100644 --- a/rust/src/runtime/mod.rs +++ b/rust/src/runtime/mod.rs @@ -46,6 +46,7 @@ mod previews; mod queries; mod query_authority; mod retention; +mod runtime_credentials; mod run_observation; mod run_observation_store; mod run_observation_waits; diff --git a/rust/src/runtime/runtime_credentials.rs b/rust/src/runtime/runtime_credentials.rs new file mode 100644 index 000000000..1257fed65 --- /dev/null +++ b/rust/src/runtime/runtime_credentials.rs @@ -0,0 +1,432 @@ +//! Database runtime credentials, as published PHP Server stores, manages +//! and authenticates them (`RuntimeCredential`, `RuntimeCredentialController` +//! and `ConfiguredAuthProvider`). A credential is a hashed bearer token for a +//! principal with worker or operator roles, scoped to one namespace (its +//! tenant). +use axum::http::StatusCode; +use chrono::{DateTime, Utc}; +use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; +use sqlx::{ColumnIndex, Decode, Encode, Executor, IntoArguments, Type}; + +use super::{ + Result, RuntimeError, + backend::Backend, + laravel_rules::{Rule, Rules, failure, normalize, validate}, + store::{Store, now}, +}; + +/// An authenticated caller: PHP's `App\Auth\Principal`. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Principal { + pub(crate) subject: String, + pub(crate) roles: Vec, + pub(crate) method: &'static str, + pub(crate) tenant: Option, + pub(crate) claims: Value, + pub(crate) legacy_full_access: bool, +} + +impl Principal { + /// `Principal::role`. + pub(crate) fn role(role: &str, method: &'static str, legacy_full_access: bool) -> Self { + Self { + subject: format!("role:{role}"), + roles: vec![role.to_owned()], + method, + tenant: None, + claims: json!({}), + legacy_full_access, + } + } + + pub(crate) fn primary_role(&self) -> Option<&str> { + self.roles.first().map(String::as_str) + } + + /// `ConfiguredAuthProvider::authorize`: a role the route allows, within + /// the principal's tenant. + pub(crate) fn allows(&self, allowed: &[&str], requested_namespace: &str, target_namespace: Option<&str>, namespace_family: bool) -> bool { + self.legacy_full_access + || (self.tenant_allows(requested_namespace, target_namespace, namespace_family) + && self.roles.iter().any(|role| allowed.contains(&role.as_str()))) + } + + /// `ConfiguredAuthProvider::tenantAllows`. + fn tenant_allows(&self, requested: &str, target: Option<&str>, namespace_family: bool) -> bool { + let Some(tenant) = self.tenant.as_deref().map(str::trim).filter(|tenant| !tenant.is_empty()) else { + return true; + }; + let tenant = tenant.to_lowercase(); + let requested = requested.trim().to_lowercase(); + if requested.is_empty() || requested != tenant { + return false; + } + let target = target.map(|target| target.trim().to_lowercase()).filter(|target| !target.is_empty()); + if target.as_ref().is_some_and(|target| *target != tenant) { + return false; + } + !namespace_family || target.is_some() + } +} + +/// `RuntimeCredential::hashToken`. +fn hash_token(token: &str) -> String { + Sha256::digest(token.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +/// `RuntimeCredential::prefixFor`: the first 12 bytes. +fn prefix(token: &str) -> String { + let mut end = token.len().min(12); + while !token.is_char_boundary(end) { + end -= 1; + } + token[..end].to_owned() +} + +fn tenant_format(text: &str) -> bool { + !text.is_empty() && text.bytes().all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) +} + +const CREDENTIAL_RULES: &Rules = &[ + ("token", &[Rule::Required, Rule::String, Rule::Min(32), Rule::Max(512)]), + ("name", &[Rule::Nullable, Rule::String, Rule::Max(255)]), + ("subject", &[Rule::Required, Rule::String, Rule::Max(255)]), + ("roles", &[Rule::Required, Rule::Array, Rule::Min(1)]), + ("roles.*", &[Rule::Required, Rule::String, Rule::In(&["operator", "worker"])]), + ("tenant", &[Rule::Required, Rule::String, Rule::Max(128), Rule::Pattern(tenant_format)]), + ("claims", &[Rule::Sometimes, Rule::Array]), + ("expires_at", &[Rule::Sometimes, Rule::Nullable, Rule::Date]), +]; + +const ROTATE_RULES: &Rules = &[ + ("token", &[Rule::Required, Rule::String, Rule::Min(32), Rule::Max(512)]), + ("expires_at", &[Rule::Sometimes, Rule::Nullable, Rule::Date]), +]; + +fn invalid(field: &str, message: &str) -> RuntimeError { + failure(vec![(field.to_owned(), vec![message.to_owned()])]).expect("one message") +} + +/// `validateCredentialId`. +fn credential_id(id: &str) -> Result<()> { + let mut bytes = id.bytes(); + let valid = id.len() <= 64 + && bytes.next().is_some_and(|first| first.is_ascii_alphanumeric()) + && bytes.all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)); + if valid { Ok(()) } else { Err(invalid("credential_id", "The credential id format is invalid.")) } +} + +/// `CarbonImmutable::parse(...)->utc()` for a validated date. +fn date(value: &Value) -> Option> { + value.as_str().and_then(super::schedule_backfill::php_date) +} + +/// Carbon's `toIso8601String` in UTC. +fn iso(time: Option>) -> Value { + time.map_or(Value::Null, |time| json!(time.format("%Y-%m-%dT%H:%M:%S+00:00").to_string())) +} + +fn not_found(id: &str) -> RuntimeError { + RuntimeError::Protocol { + status: StatusCode::NOT_FOUND, + response: json!({"message":"Runtime credential not found.","reason":"runtime_credential_not_found","credential_id":id}), + } +} + +fn token_conflict(error: sqlx::Error) -> RuntimeError { + match error { + sqlx::Error::Database(error) if error.is_unique_violation() => RuntimeError::Protocol { + status: StatusCode::CONFLICT, + response: json!({"message":"The supplied runtime credential token is already assigned.", + "reason":"runtime_credential_token_conflict"}), + }, + error => error.into(), + } +} + +/// The attributes an upsert stores, as PHP's `attributes` derives them. +struct Attributes { + name: Option, + subject: String, + roles: Vec, + tenant: String, + claims: Value, + token_prefix: String, + token_hash: String, + expires_at: Option>, +} + +fn attributes(body: &Value) -> Result { + let claims = match &body["claims"] { + Value::Array(items) if !items.is_empty() => { + return Err(invalid("claims", "The claims field must be a JSON object, not a list.")); + } + Value::Object(map) => Value::Object(map.clone()), + _ => json!({}), + }; + let mut roles: Vec = body["roles"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(); + roles.sort(); + roles.dedup(); + let token = body["token"].as_str().unwrap_or_default(); + Ok(Attributes { + name: body["name"].as_str().map(str::to_owned), + subject: body["subject"].as_str().unwrap_or_default().trim().to_owned(), + roles, + tenant: body["tenant"].as_str().unwrap_or_default().to_lowercase(), + claims, + token_prefix: prefix(token), + token_hash: hash_token(token), + expires_at: date(&body["expires_at"]), + }) +} + +impl Store +where + for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, + DB::Arguments: IntoArguments, + for<'q> &'q str: Encode<'q, DB> + Type, + for<'q> Option<&'q str>: Encode<'q, DB>, + for<'q> Option: Encode<'q, DB>, + for<'q> String: Encode<'q, DB> + Type, + for<'q> i64: Encode<'q, DB> + Type, + for<'r> i64: Decode<'r, DB>, + usize: ColumnIndex, +{ + fn serialize_credential(row: &DB::Row) -> Result { + let claims = DB::optional_document_row(row, "claims")? + .filter(|claims| claims.as_object().is_some_and(|map| !map.is_empty())) + .unwrap_or_else(|| json!([])); + Ok(json!({"id":DB::string(row,"id")?,"name":DB::optional_string(row,"name")?,"subject":DB::string(row,"subject")?, + "roles":DB::document_row(row,"roles")?,"tenant":DB::string(row,"tenant")?,"claims":claims, + "token_prefix":DB::string(row,"token_prefix")?, + "expires_at":iso(DB::optional_instant(row,"expires_at")?),"revoked_at":iso(DB::optional_instant(row,"revoked_at")?), + "rotated_at":iso(DB::optional_instant(row,"rotated_at")?),"created_at":iso(DB::optional_instant(row,"created_at")?), + "updated_at":iso(DB::optional_instant(row,"updated_at")?)})) + } + + /// `RuntimeCredential::activeForToken` and its `principal`. + pub(crate) async fn runtime_credential_principal(&self, token: &str) -> Result> { + let Some(row) = Self::query("SELECT * FROM runtime_credentials WHERE token_hash=$1") + .bind(hash_token(token)) + .fetch_optional(&self.pool) + .await? + else { + return Ok(None); + }; + if DB::optional_instant(&row, "revoked_at")?.is_some() + || DB::optional_instant(&row, "expires_at")?.is_some_and(|expires| expires <= now()) + { + return Ok(None); + } + let roles = DB::document_row(&row, "roles")? + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(|role| role.trim().to_owned()) + .filter(|role| !role.is_empty()) + .fold(Vec::new(), |mut roles, role| { + if !roles.contains(&role) { + roles.push(role); + } + roles + }); + Ok(Some(Principal { + subject: DB::string(&row, "subject")?, + roles, + method: "runtime-token", + tenant: Some(DB::string(&row, "tenant")?), + claims: DB::optional_document_row(&row, "claims")?.unwrap_or_else(|| json!({})), + legacy_full_access: false, + })) + } + + pub(crate) async fn list_runtime_credentials(&self) -> Result { + let rows = Self::query("SELECT * FROM runtime_credentials ORDER BY created_at,id") + .fetch_all(&self.pool) + .await?; + let credentials = rows.iter().map(Self::serialize_credential).collect::>>()?; + Ok(json!({"credentials":credentials})) + } + + pub(crate) async fn show_runtime_credential(&self, id: &str) -> Result { + Self::query("SELECT * FROM runtime_credentials WHERE id=$1") + .bind(id) + .fetch_optional(&self.pool) + .await? + .map(|row| Self::serialize_credential(&row)) + .transpose()? + .ok_or_else(|| not_found(id)) + } + + /// `RuntimeCredentialController::upsert`: create, or confirm an + /// identical credential; different attributes conflict. + pub(crate) async fn upsert_runtime_credential(&self, id: &str, body: Value) -> Result<(StatusCode, Value)> { + credential_id(id)?; + let body = normalize(&body); + validate(&body, CREDENTIAL_RULES)?; + let attributes = attributes(&body)?; + let mut tx = self.begin().await?; + if let Some(row) = Self::query("SELECT * FROM runtime_credentials WHERE id=$1") + .bind(id) + .fetch_optional(&mut *tx) + .await? + { + let same_claims = DB::optional_document_row(&row, "claims")?.unwrap_or_else(|| json!({})); + let same_claims = if same_claims.as_object().is_some_and(Map::is_empty) || same_claims == json!([]) { + json!({}) + } else { + same_claims + }; + let matches = DB::optional_string(&row, "name")? == attributes.name + && DB::string(&row, "subject")? == attributes.subject + && DB::document_row(&row, "roles")? == json!(attributes.roles) + && DB::string(&row, "tenant")? == attributes.tenant + && same_claims == attributes.claims + && DB::string(&row, "token_hash")? == attributes.token_hash + && DB::optional_instant(&row, "expires_at")? == attributes.expires_at + && DB::optional_instant(&row, "revoked_at")?.is_none(); + tx.commit().await?; + if !matches { + return Err(RuntimeError::Protocol { + status: StatusCode::CONFLICT, + response: json!({"message":"Runtime credential already exists with different attributes.", + "reason":"runtime_credential_conflict","credential_id":id}), + }); + } + let mut credential = Self::serialize_credential(&row)?; + credential["created"] = json!(false); + return Ok((StatusCode::OK, credential)); + } + let at = now(); + Self::query("INSERT INTO runtime_credentials(id,name,subject,roles,tenant,claims,token_prefix,token_hash,expires_at,revoked_at,rotated_at,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,NULL,NULL,$10,$11)") + .bind(id) + .bind(attributes.name.as_deref()) + .bind(attributes.subject.as_str()) + .bind(Some(DB::document(&json!(attributes.roles)))) + .bind(attributes.tenant.as_str()) + .bind(Some(DB::document(&attributes.claims))) + .bind(attributes.token_prefix.as_str()) + .bind(attributes.token_hash.as_str()) + .bind(DB::optional_time(attributes.expires_at)) + .bind(DB::bind_time(at)) + .bind(DB::bind_time(at)) + .execute(&mut *tx) + .await + .map_err(token_conflict)?; + let row = Self::query("SELECT * FROM runtime_credentials WHERE id=$1").bind(id).fetch_one(&mut *tx).await?; + tx.commit().await?; + let mut credential = Self::serialize_credential(&row)?; + credential["created"] = json!(true); + Ok((StatusCode::CREATED, credential)) + } + + /// `RuntimeCredentialController::rotate`: a new token, which also + /// restores a revoked credential. + pub(crate) async fn rotate_runtime_credential(&self, id: &str, body: Value) -> Result { + credential_id(id)?; + let body = normalize(&body); + validate(&body, ROTATE_RULES)?; + let token = body["token"].as_str().unwrap_or_default(); + let mut tx = self.begin().await?; + let row = Self::query("SELECT * FROM runtime_credentials WHERE id=$1") + .bind(id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| not_found(id))?; + let hash = hash_token(token); + let expires_at = if body.get("expires_at").is_some() { + date(&body["expires_at"]) + } else { + DB::optional_instant(&row, "expires_at")? + }; + let changed = DB::string(&row, "token_hash")? != hash + || DB::optional_instant(&row, "revoked_at")?.is_some() + || DB::optional_instant(&row, "expires_at")? != expires_at; + if changed { + let at = now(); + Self::query("UPDATE runtime_credentials SET token_hash=$1,token_prefix=$2,expires_at=$3,revoked_at=NULL,rotated_at=$4,updated_at=$5 WHERE id=$6") + .bind(hash.as_str()) + .bind(prefix(token)) + .bind(DB::optional_time(expires_at)) + .bind(DB::bind_time(at)) + .bind(DB::bind_time(at)) + .bind(id) + .execute(&mut *tx) + .await + .map_err(token_conflict)?; + } + let row = Self::query("SELECT * FROM runtime_credentials WHERE id=$1").bind(id).fetch_one(&mut *tx).await?; + tx.commit().await?; + Self::serialize_credential(&row) + } + + /// `RuntimeCredentialController::revoke`. + pub(crate) async fn revoke_runtime_credential(&self, id: &str) -> Result { + credential_id(id)?; + let mut tx = self.begin().await?; + let at = now(); + Self::query("UPDATE runtime_credentials SET revoked_at=$1,updated_at=$2 WHERE id=$3 AND revoked_at IS NULL") + .bind(DB::bind_time(at)) + .bind(DB::bind_time(at)) + .bind(id) + .execute(&mut *tx) + .await?; + let row = Self::query("SELECT * FROM runtime_credentials WHERE id=$1") + .bind(id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| not_found(id))?; + tx.commit().await?; + Self::serialize_credential(&row) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tenant_principal(tenant: &str, roles: &[&str]) -> Principal { + Principal { + subject: "s".into(), + roles: roles.iter().map(|role| (*role).to_owned()).collect(), + method: "runtime-token", + tenant: Some(tenant.into()), + claims: json!({}), + legacy_full_access: false, + } + } + + #[test] + fn tenants_reach_only_their_namespace() { + let worker = tenant_principal("Team-A", &["worker"]); + assert!(worker.allows(&["worker"], "team-a", None, false)); + assert!(!worker.allows(&["worker"], "default", None, false)); + assert!(!worker.allows(&["operator", "admin"], "team-a", None, false)); + // Namespace routes need a target, and it must be the tenant. + assert!(!worker.allows(&["worker"], "team-a", None, true)); + assert!(worker.allows(&["worker"], "team-a", Some("TEAM-A"), true)); + assert!(!worker.allows(&["worker"], "team-a", Some("team-b"), true)); + let legacy = Principal::role("admin", "token", true); + assert!(legacy.allows(&["worker"], "anything", None, true)); + } + + #[test] + fn tokens_hash_and_prefix_like_php() { + assert_eq!(hash_token("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + assert_eq!(prefix("dwr_fixture_worker_0123456789"), "dwr_fixture_"); + assert!(credential_id("cred-1.a_b").is_ok()); + assert!(credential_id("-bad").is_err()); + assert!(credential_id(&"a".repeat(65)).is_err()); + } +} diff --git a/rust/src/runtime/task_repair_http.rs b/rust/src/runtime/task_repair_http.rs index ba5af0c37..53b5b98fd 100644 --- a/rust/src/runtime/task_repair_http.rs +++ b/rust/src/runtime/task_repair_http.rs @@ -94,13 +94,10 @@ pub(super) async fn request(request: Request, workflow_id: &str) -> Result<(Valu if !headers.is_array() { request["headers"] = headers; } - let subject = if principal.legacy_credential { - "legacy-token".into() - } else { - format!("role:{}", principal.role) - }; - let claims = if principal.legacy_credential { - json!({"legacy_credential":true}) + // The authenticated principal, as `Principal::toAuditContext` records it. + let subject = principal.principal.subject.clone(); + let claims = if principal.principal.claims.as_object().is_some_and(|claims| !claims.is_empty()) { + principal.principal.claims.clone() } else { json!([]) }; @@ -109,14 +106,19 @@ pub(super) async fn request(request: Request, workflow_id: &str) -> Result<(Valu "operator" => "Operator", _ => "Worker", }; + let mut audit = json!({"subject":subject,"roles":principal.principal.roles,"claims":claims, + "legacy_full_access":principal.legacy_full_access}); + if let Some(tenant) = &principal.principal.tenant { + audit["tenant"] = json!(tenant); + } let metadata = payload["request_id"] .as_str() .map_or(json!([]), |value| json!({"request_id":value})); let context = json!({ "caller":{"type":"server","label":"Standalone Server"}, - "auth":{"status":"authorized","method":"token","role":principal.role,"roles":[principal.role], - "principal":{"subject":subject,"roles":[principal.role],"claims":claims,"legacy_full_access":principal.legacy_full_access}}, - "principal":{"type":"auth:token","id":subject,"label":label}, + "auth":{"status":"authorized","method":principal.principal.method,"role":principal.role,"roles":principal.principal.roles, + "principal":audit}, + "principal":{"type":format!("auth:{}",principal.principal.method),"id":subject,"label":label}, "request":request,"server":{"operation_id":id(),"namespace":"default","workflow_id":workflow_id, "command":"repair","metadata":metadata}}); Ok((payload, context)) diff --git a/rust/tests/execution.rs b/rust/tests/execution.rs index 89fa3a9cd..927c13209 100644 --- a/rust/tests/execution.rs +++ b/rust/tests/execution.rs @@ -53,6 +53,8 @@ mod named_timers; #[path = "execution/named_signal_timeouts.rs"] mod named_signal_timeouts; +#[path = "execution/runtime_credentials.rs"] +mod runtime_credentials; #[path = "execution/selected_run_signals.rs"] mod selected_run_signals; diff --git a/rust/tests/execution/runtime_credentials.rs b/rust/tests/execution/runtime_credentials.rs new file mode 100644 index 000000000..88b5b627e --- /dev/null +++ b/rust/tests/execution/runtime_credentials.rs @@ -0,0 +1,112 @@ +//! Database runtime credentials, as published PHP Server manages them +//! (`RuntimeCredentialController`) and authenticates their tokens for one +//! tenant namespace (`ConfiguredAuthProvider`). +use super::*; +use admission::admission_http; + +const WORKER_TOKEN: &str = "dwr_fixture_worker_default_0123456789"; +const OPERATOR_TOKEN: &str = "dwr_fixture_operator_team_a_0123456789"; + +async fn admin(app: &Router, method: &str, path: &str, body: Value) -> (StatusCode, Value) { + let (status, _, body) = admission_http(app, method, path, false, Some("test-token"), Some("2"), None, body).await; + (status, body) +} + +async fn as_token(app: &Router, token: &str, method: &str, path: &str, worker: bool, namespace: Option<&str>) -> StatusCode { + let version = if worker { "1.20" } else { "2" }; + admission_http(app, method, path, worker, Some(token), Some(version), namespace, json!({})).await.0 +} + +fn credential(token: &str, roles: Value, tenant: &str) -> Value { + json!({"token":token,"subject":"svc:sample","roles":roles,"tenant":tenant,"claims":{"team":"core"}}) +} + +#[tokio::test] +async fn runtime_credentials_are_managed_like_php() { + let database = TestDatabase::new().await; + let runtime = database.open().await.unwrap(); + let app = router(runtime.clone().with_auth_options(false, true)); + let (status, created) = admin(&app, "PUT", "/api/runtime-credentials/worker-a", + credential(WORKER_TOKEN, json!(["worker", "worker"]), "Default")).await; + assert_eq!(status, StatusCode::CREATED, "{created}"); + assert_eq!((created["created"].clone(), created["roles"].clone(), created["tenant"].clone(), created["token_prefix"].clone()), + (json!(true), json!(["worker"]), json!("default"), json!("dwr_fixture_"))); + assert!(created.get("token_hash").is_none() && created.get("token").is_none(), "{created}"); + // The same attributes confirm the credential; different ones conflict. + let (status, again) = admin(&app, "PUT", "/api/runtime-credentials/worker-a", + credential(WORKER_TOKEN, json!(["worker"]), "default")).await; + assert_eq!((status, again["created"].clone()), (StatusCode::OK, json!(false)), "{again}"); + let (status, conflict) = admin(&app, "PUT", "/api/runtime-credentials/worker-a", + credential(WORKER_TOKEN, json!(["operator"]), "default")).await; + assert_eq!((status, conflict["reason"].clone()), (StatusCode::CONFLICT, json!("runtime_credential_conflict"))); + let (status, reused) = admin(&app, "PUT", "/api/runtime-credentials/worker-b", + credential(WORKER_TOKEN, json!(["worker"]), "default")).await; + assert_eq!((status, reused["reason"].clone()), (StatusCode::CONFLICT, json!("runtime_credential_token_conflict"))); + // Validation follows the controller's rules and messages. + let (status, invalid) = admin(&app, "PUT", "/api/runtime-credentials/-bad", credential(WORKER_TOKEN, json!(["worker"]), "default")).await; + assert_eq!((status, invalid["errors"]["credential_id"].clone()), (StatusCode::UNPROCESSABLE_ENTITY, json!(["The credential id format is invalid."]))); + let (status, invalid) = admin(&app, "PUT", "/api/runtime-credentials/short", + json!({"token":"short","subject":"s","roles":["admin"],"tenant":"bad tenant","claims":["x"]})).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{invalid}"); + assert_eq!(invalid["errors"]["token"], json!(["The token field must be at least 32 characters."])); + assert_eq!(invalid["errors"]["tenant"], json!(["The tenant field format is invalid."])); + assert_eq!(invalid["errors"]["roles.0"], json!(["The selected roles.0 is invalid."])); + let (status, listed) = admin(&app, "GET", "/api/runtime-credentials", Value::Null).await; + assert_eq!(status, StatusCode::OK, "{listed}"); + assert_eq!(listed["credentials"].as_array().unwrap().len(), 1); + assert_eq!(listed["credentials"][0]["claims"], json!({"team":"core"})); + let (status, missing) = admin(&app, "GET", "/api/runtime-credentials/nobody", Value::Null).await; + assert_eq!((status, missing["reason"].clone()), (StatusCode::NOT_FOUND, json!("runtime_credential_not_found"))); + // Only an administrator manages credentials. + assert_eq!(as_token(&app, WORKER_TOKEN, "GET", "/api/runtime-credentials", false, None).await, StatusCode::FORBIDDEN); + runtime.close().await; + database.remove().await; +} + +#[tokio::test] +async fn runtime_credentials_authenticate_within_their_tenant() { + let database = TestDatabase::new().await; + let runtime = database.open().await.unwrap(); + let app = router(runtime.clone().with_auth_options(false, true)); + assert_eq!(admin(&app, "PUT", "/api/runtime-credentials/worker-a", credential(WORKER_TOKEN, json!(["worker"]), "default")).await.0, StatusCode::CREATED); + assert_eq!(admin(&app, "PUT", "/api/runtime-credentials/operator-a", credential(OPERATOR_TOKEN, json!(["operator"]), "team-a")).await.0, StatusCode::CREATED); + // A worker credential reaches worker routes of its own namespace only. + assert_eq!(as_token(&app, WORKER_TOKEN, "GET", "/api/cluster/info", false, None).await, StatusCode::OK); + assert_eq!(as_token(&app, WORKER_TOKEN, "GET", "/api/workflows", false, None).await, StatusCode::FORBIDDEN); + // An operator credential for another tenant cannot read the default namespace. + assert_eq!(as_token(&app, OPERATOR_TOKEN, "GET", "/api/workflows", false, None).await, StatusCode::FORBIDDEN); + assert_eq!(as_token(&app, OPERATOR_TOKEN, "GET", "/api/namespaces", false, Some("team-a")).await, StatusCode::FORBIDDEN); + // A namespace route without a path target takes it from `name`. + assert_eq!(as_token(&app, OPERATOR_TOKEN, "GET", "/api/namespaces?name=Team-A", false, Some("team-a")).await, StatusCode::OK); + assert_eq!(as_token(&app, OPERATOR_TOKEN, "GET", "/api/namespaces?name=default", false, Some("team-a")).await, StatusCode::FORBIDDEN); + assert_eq!(as_token(&app, "dwr_unknown_token_0123456789_0123456789", "GET", "/api/cluster/info", false, None).await, StatusCode::UNAUTHORIZED); + // Rotation replaces the token; revocation ends it; rotation restores it. + let rotated = "dwr_fixture_worker_default_0123456789_rotated"; + let (status, body) = admin(&app, "POST", "/api/runtime-credentials/worker-a/rotate", json!({"token":rotated})).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert!(body["rotated_at"].is_string(), "{body}"); + assert_eq!(as_token(&app, WORKER_TOKEN, "GET", "/api/cluster/info", false, None).await, StatusCode::UNAUTHORIZED); + assert_eq!(as_token(&app, rotated, "GET", "/api/cluster/info", false, None).await, StatusCode::OK); + let (status, body) = admin(&app, "DELETE", "/api/runtime-credentials/worker-a", Value::Null).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert!(body["revoked_at"].is_string(), "{body}"); + assert_eq!(as_token(&app, rotated, "GET", "/api/cluster/info", false, None).await, StatusCode::UNAUTHORIZED); + assert_eq!(admin(&app, "POST", "/api/runtime-credentials/worker-a/rotate", json!({"token":rotated})).await.0, StatusCode::OK); + assert_eq!(as_token(&app, rotated, "GET", "/api/cluster/info", false, None).await, StatusCode::OK); + // With database credentials disabled, the token is unknown. + let disabled = router(runtime.clone()); + assert_eq!(as_token(&disabled, rotated, "GET", "/api/cluster/info", false, None).await, StatusCode::UNAUTHORIZED); + runtime.close().await; + database.remove().await; +} + +#[tokio::test] +async fn the_none_driver_admits_every_request() { + let database = TestDatabase::new().await; + let runtime = database.open().await.unwrap(); + let app = router(runtime.clone().with_auth_options(true, false)); + let (status, _, body) = admission_http(&app, "GET", "/api/workflows", false, None, Some("2"), None, json!({})).await; + assert_eq!(status, StatusCode::OK, "{body}"); + runtime.close().await; + database.remove().await; +} From a3faf631147de2ecbfa5d6f1931ac30819283ec4 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 22 Oct 2026 00:51:44 +0000 Subject: [PATCH 2/4] Compare stored credential claims and expiry through typed locals SonarCloud's Rust analyzer read the inline comparisons as unit comparisons (S7423) and failed the reliability gate. Clippy reports no unit comparison; reading the stored values into typed locals first is clearer either way. --- rust/src/runtime/runtime_credentials.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/rust/src/runtime/runtime_credentials.rs b/rust/src/runtime/runtime_credentials.rs index 1257fed65..8034ee416 100644 --- a/rust/src/runtime/runtime_credentials.rs +++ b/rust/src/runtime/runtime_credentials.rs @@ -5,7 +5,7 @@ //! tenant). use axum::http::StatusCode; use chrono::{DateTime, Utc}; -use serde_json::{Map, Value, json}; +use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use sqlx::{ColumnIndex, Decode, Encode, Executor, IntoArguments, Type}; @@ -282,19 +282,20 @@ where .fetch_optional(&mut *tx) .await? { - let same_claims = DB::optional_document_row(&row, "claims")?.unwrap_or_else(|| json!({})); - let same_claims = if same_claims.as_object().is_some_and(Map::is_empty) || same_claims == json!([]) { - json!({}) - } else { - same_claims + // PHP stores empty claims as an empty array. + let stored_claims: Value = match DB::optional_document_row(&row, "claims")? { + Some(Value::Array(items)) if items.is_empty() => json!({}), + Some(claims) => claims, + None => json!({}), }; + let stored_expiry: Option> = DB::optional_instant(&row, "expires_at")?; let matches = DB::optional_string(&row, "name")? == attributes.name && DB::string(&row, "subject")? == attributes.subject && DB::document_row(&row, "roles")? == json!(attributes.roles) && DB::string(&row, "tenant")? == attributes.tenant - && same_claims == attributes.claims + && stored_claims == attributes.claims && DB::string(&row, "token_hash")? == attributes.token_hash - && DB::optional_instant(&row, "expires_at")? == attributes.expires_at + && stored_expiry == attributes.expires_at && DB::optional_instant(&row, "revoked_at")?.is_none(); tx.commit().await?; if !matches { From 09c0a128009a3b14e9b4540a51ba651f54b1d6e1 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 22 Oct 2026 01:02:26 +0000 Subject: [PATCH 3/4] Expect native to accept the none driver and runtime credentials in the role profile The role profile asserted that native refuses DW_AUTH_DRIVER=none and DW_RUNTIME_CREDENTIALS_ENABLED=true at startup. Both are now qualified, so the refusal cases use the signature driver instead and drop runtime credentials. --- scripts/conformance/server-parity/role-profile.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/conformance/server-parity/role-profile.sh b/scripts/conformance/server-parity/role-profile.sh index 44c2e993e..43119caaf 100644 --- a/scripts/conformance/server-parity/role-profile.sh +++ b/scripts/conformance/server-parity/role-profile.sh @@ -43,7 +43,8 @@ database() { if test "$backend" = sqlite; then printf '/state/%s.sqlite' "$1"; else printf '%s' "$1"; fi } configuration_case=0 -for setting in DW_AUTH_DRIVER=none DW_AUTH_DRIVER= DW_PRINCIPAL_TOKENS=[] DW_AUTH_BACKWARD_COMPATIBLE=false DW_AUTH_BACKWARD_COMPATIBLE= DW_RUNTIME_CREDENTIALS_ENABLED=true; do +# The none driver and runtime credentials are qualified; these settings are not. +for setting in DW_AUTH_DRIVER=signature DW_AUTH_DRIVER= DW_PRINCIPAL_TOKENS=[] DW_AUTH_BACKWARD_COMPATIBLE=false DW_AUTH_BACKWARD_COMPATIBLE=; do configuration_case=$((configuration_case + 1)) key="${setting%%=*}" if docker run --rm --user=1000:1000 --network=none --entrypoint /target/debug/durable-workflow-server \ From faac0d4533bf2df37a8da2a932efd2f5e94f9de3 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 22 Oct 2026 01:45:46 +0000 Subject: [PATCH 4/4] Prove a failed credential lookup refuses without falling back Security's review of #627 asked for a fault-injected test of the database lookup. With the credentials table gone, an unknown bearer token gets the generic 500 server_error without table or token text, configured tokens still authenticate, and a request without a token is still refused. --- rust/tests/execution/runtime_credentials.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/rust/tests/execution/runtime_credentials.rs b/rust/tests/execution/runtime_credentials.rs index 88b5b627e..54e41f7b2 100644 --- a/rust/tests/execution/runtime_credentials.rs +++ b/rust/tests/execution/runtime_credentials.rs @@ -110,3 +110,23 @@ async fn the_none_driver_admits_every_request() { runtime.close().await; database.remove().await; } + +#[tokio::test] +async fn a_failed_credential_lookup_refuses_without_falling_back() { + let database = TestDatabase::new().await; + let runtime = database.open().await.unwrap(); + let app = router(runtime.clone().with_auth_options(false, true)); + assert_eq!(admin(&app, "PUT", "/api/runtime-credentials/worker-a", credential(WORKER_TOKEN, json!(["worker"]), "default")).await.0, StatusCode::CREATED); + // The lookup's table is gone, so the database refuses every lookup. + database.execute("ALTER TABLE runtime_credentials RENAME TO runtime_credentials_moved").await; + let (status, _, body) = admission_http(&app, "GET", "/api/cluster/info", false, Some(WORKER_TOKEN), Some("2"), None, json!({})).await; + assert_eq!((status, body["reason"].clone()), (StatusCode::INTERNAL_SERVER_ERROR, json!("server_error")), "{body}"); + assert!(!body.to_string().contains("runtime_credentials") && !body.to_string().contains(WORKER_TOKEN), "{body}"); + // Configured tokens are matched first and still authenticate; a request + // without a token is still refused. + assert_eq!(admin(&app, "GET", "/api/cluster/info", Value::Null).await.0, StatusCode::OK); + let (status, _, _) = admission_http(&app, "GET", "/api/cluster/info", false, None, Some("2"), None, json!({})).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + runtime.close().await; + database.remove().await; +}