Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion docs/rust-server-port.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
40 changes: 25 additions & 15 deletions rust/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,33 @@ fn auth_value(key: &str) -> Result<Option<String>, Box<dyn Error>> {
}
}

/// `server.auth.driver`: DW-primary, WORKFLOW-legacy, token by default.
fn anonymous_driver() -> Result<bool, Box<dyn Error>> {
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<bool, Box<dyn Error>> {
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<dyn Error>> {
// 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());
}
}
Expand All @@ -104,19 +125,6 @@ fn validate_auth_configuration() -> Result<(), Box<dyn Error>> {
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",
Expand Down Expand Up @@ -444,7 +452,9 @@ async fn main() -> Result<(), Box<dyn Error>> {
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 {}",
Expand Down
24 changes: 24 additions & 0 deletions rust/src/runtime/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ pub struct Runtime {
storage: Arc<Storage>,
pub(crate) token: Arc<str>,
pub(crate) role_tokens: [Option<Arc<str>>; 3],
/// `DW_AUTH_DRIVER=none` and `DW_RUNTIME_CREDENTIALS_ENABLED`.
pub(crate) auth: AuthOptions,
scheduler: Arc<TimerScheduler>,
signal_codec: Arc<Semaphore>,
// Codec work done while holding the transition lock takes this permit,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -305,6 +323,12 @@ impl Runtime {
delegate!(activity_timeout_pass(limit: i64, ids: Vec<String>) -> Result<(Value, bool)>);
delegate!(create_namespace(body: Value) -> Result<Value>);
delegate!(list_search_attributes() -> Result<Value>);
delegate!(runtime_credential_principal(token: &str) -> Result<Option<super::runtime_credentials::Principal>>);
delegate!(list_runtime_credentials() -> Result<Value>);
delegate!(show_runtime_credential(id: &str) -> Result<Value>);
delegate!(upsert_runtime_credential(id: &str, body: Value) -> Result<(axum::http::StatusCode, Value)>);
delegate!(rotate_runtime_credential(id: &str, body: Value) -> Result<Value>);
delegate!(revoke_runtime_credential(id: &str) -> Result<Value>);
delegate!(
storage_test_round_trip(
local: &crate::external_payload::LocalStorage,
Expand Down
Loading
Loading