diff --git a/docs/plans/2026-06-02-auth-provider-admin-apply-design.md b/docs/plans/2026-06-02-auth-provider-admin-apply-design.md
new file mode 100644
index 0000000..e488f87
--- /dev/null
+++ b/docs/plans/2026-06-02-auth-provider-admin-apply-design.md
@@ -0,0 +1,181 @@
+# Auth Provider Admin Apply Design
+
+Date: 2026-06-02
+
+## Goal
+
+Close the gap between the existing admin/auth/authz proof and a usable auth
+administration path:
+
+- A protected admin operator can validate and apply auth configuration changes.
+- Secret values submitted through admin are written through a configured
+ Workflow secrets module, not persisted or echoed by the auth plugin.
+- Applied non-secret config state is persisted by the host application.
+- Scenario 90 proves the contract with the real Workflow server, real admin
+ shell, real auth/authz gates, and real Workflow pipeline steps.
+
+## Current State
+
+Workflow already provides the reload/config mechanisms this work must not
+duplicate:
+
+- `POST /api/workflow/reload` reloads the current config.
+- The server supports file watch reload through `config.NewConfigWatcher`.
+- The server has database config polling when a DB config store is configured.
+
+Workflow also already provides secret storage primitives:
+
+- `secrets.Provider` supports `Get`, `Set`, `Delete`, and `List`.
+- `step.secret_set` writes submitted values through a named secrets module.
+- Secret modules expose providers through the service registry.
+
+Auth/provider plugins already expose management descriptors or concrete remote
+CRUD steps:
+
+- `workflow-plugin-auth` exposes auth admin contribution, config describe,
+ config validate, and provider catalog steps.
+- Provider plugins own provider-specific management operations:
+ Auth0 clients/users/roles, Okta apps/users/groups/policies, Entra users/groups
+ and applications, Ory Kratos identities, Ory Hydra OAuth clients/JWKs/trusted
+ issuers, Ory Polis SSO/directory resources, and Scalekit SSO/directory
+ resources.
+
+`workflow-plugin-infra` and Workflow IaC own infrastructure resource planning
+and lifecycle. Auth provider tenant/client/user provisioning is not infra unless
+it is explicitly modeled as an IaC resource later.
+
+Scenario 90 already proves:
+
+- Primary app and admin app are served by Workflow.
+- Admin is protected by authentication and authorization.
+- Admin nav is contribution-driven.
+- Auth config describe/validate and provider catalog are live.
+- Authz UI is functional through the admin nav.
+
+## Design
+
+### Ownership Boundary
+
+Auth plugin:
+
+- Defines descriptor and validation contracts.
+- Redacts secret-bearing fields from describe/validate output.
+- Does not persist host config.
+- Does not own Workflow reload.
+- Does not own secret storage.
+- Does not own generic remote provisioning orchestration.
+
+Admin plugin:
+
+- Renders generic contribution UI.
+- Adds an optional apply action when contribution metadata includes
+ `apply_path`.
+- Sends the same `desired_config` draft used for validation.
+- Does not assume auth-specific fields or semantics.
+
+Workflow host/scenario:
+
+- Owns authenticated and authorized apply endpoints.
+- Runs validation before persistence.
+- Writes submitted secret values through `step.secret_set`.
+- Persists accepted non-secret config state plus secret refs/status.
+- Can trigger Workflow reload or rely on existing hot reload/config-store
+ behavior in production deployments.
+
+Provider plugins:
+
+- Own provider-specific remote CRUD/provisioning calls.
+- Surface actions through provider-specific Workflow steps.
+- Are invoked by host endpoints with explicit scopes, audit, idempotency, and
+ rollback behavior.
+
+Infra plugin:
+
+- Owns infrastructure resources, DNS/compute/storage/network provider bindings,
+ and wfctl/IaC flows.
+- Does not become the default place for Auth0/Okta/Entra/Hydra client CRUD.
+
+### Scenario 90 Extension
+
+Scenario 90 will add:
+
+- `apply_path: /api/admin/auth/config/apply` to the auth contribution metadata.
+- A Vault dev sidecar and `secrets.vault` module used only for scenario proof.
+- A persisted auth admin state table.
+- `POST /api/admin/auth/config/apply`:
+ - authenticates the bearer token with `auth.jwt`;
+ - enforces `admin:auth.config:update` from persisted admin role grants;
+ - validates `desired_config` with `step.auth_admin_config_validate`;
+ - rejects invalid config without persistence;
+ - writes `auth0_client_secret`, when provided, through `step.secret_set`;
+ - persists only sanitized accepted config JSON, secret refs, actor, and time;
+ - returns `applied`, `valid`, `accepted_config`, `secret_fields`,
+ `secret_refs`, `warnings`, and no submitted secret values.
+- `GET /api/admin/auth/config/applied`:
+ - authenticates and enforces `admin:auth.config:read`;
+ - returns the last applied state for verification and UI observability.
+
+The scenario intentionally does not provision a real Auth0/Okta/Entra tenant in
+public CI. Live remote provisioning requires provider credentials, external API
+side effects, quotas, and cleanup; that belongs in provider-specific tests or a
+credentialed/private scenario. Scenario 90 proves the cross-component admin
+apply path and secret-provider wiring. Provider plugins continue to prove their
+SDK-backed CRUD steps directly.
+
+### Adversarial Review Adjustment
+
+The initial sketch proposed a file-backed secrets module. That was wrong for
+runtime Workflow: `secrets.file` exists in wfctl secret-store resolution, but
+the Workflow server module registry currently exposes `secrets.vault`,
+`secrets.aws`, and `secrets.keychain`. Scenario 90 therefore uses a Vault dev
+container and configures a real `secrets.vault` runtime module. This keeps the
+proof honest: `step.secret_set` crosses the real module/provider boundary.
+
+### Use Cases
+
+Solo developer:
+
+- Boots Scenario 101/bootstrap auth or local auth.
+- Uses admin to configure passkey/local auth settings.
+- Stores local provider secrets through file/keychain/env-backed Workflow
+ secrets modules.
+- Relies on Workflow hot reload or an explicit reload endpoint after config
+ persistence.
+
+Development team:
+
+- Configures staging identity providers from the admin UI.
+- Validates config before applying.
+- Stores client secrets through the environment's configured secrets provider.
+- Uses provider-plugin CRUD routes for staging OIDC clients or SSO connections,
+ guarded by `admin:auth.providers.write`.
+
+SRE/platform team:
+
+- Manages approved identity-provider clients/connections with audit and
+ explicit scopes.
+- Uses Workflow/wfctl infra for infrastructure and auth provider plugins for
+ identity-provider resources.
+- Requires idempotent provider operations, rollback/delete paths, and no secret
+ material in logs or persisted config state.
+
+## Security Requirements
+
+- Apply endpoints must be server-side authenticated and authorized.
+- Client-supplied role/scope claims are not trusted.
+- Invalid config must not be persisted.
+- Submitted secret values must not appear in API responses, stored config JSON,
+ logs, or test artifacts.
+- Secret writes must target a configured Workflow secrets module.
+- Direct iframe/plugin UI access remains protected by server-side endpoints; UI
+ hiding is not an authorization control.
+
+## Rollback
+
+- Admin UI apply button is feature-detected by `apply_path`; removing
+ `apply_path` hides it without breaking validate-only contributions.
+- Scenario state is local file/SQLite data and can be reset with Docker compose
+ teardown.
+- Production hosts should retain prior accepted config, apply new config only
+ after validation, and use Workflow's existing reload/config-store rollback
+ path rather than auth-plugin-owned reload code.
diff --git a/docs/plans/2026-06-02-auth-provider-admin-apply.md b/docs/plans/2026-06-02-auth-provider-admin-apply.md
new file mode 100644
index 0000000..766731a
--- /dev/null
+++ b/docs/plans/2026-06-02-auth-provider-admin-apply.md
@@ -0,0 +1,77 @@
+# Auth Provider Admin Apply Plan
+
+Date: 2026-06-02
+
+## Scope
+
+Implement the smallest real slice that proves auth admin save/apply without
+duplicating Workflow reload, Workflow secrets, provider CRUD, or infra.
+
+PRs:
+
+- `workflow-plugin-admin`: generic config-form apply button.
+- `workflow-scenarios`: Scenario 90 apply endpoint, secrets wiring, and tests.
+
+No `workflow-plugin-auth` PR is required unless implementation proves the
+current descriptor/validation contract cannot support the scenario.
+
+## Tasks
+
+1. Admin UI: Add optional apply action
+ - In `internal/ui_dist/index.html`, render an `Apply changes` button only
+ when contribution metadata has `apply_path`.
+ - POST `{desired_config: draft}` to `apply_path` with the same auth headers
+ used by validation.
+ - Render the JSON response in the existing config result area.
+ - Verify with `go test ./...`.
+
+2. Scenario 90: Wire auth contribution metadata
+ - Configure `step.auth_admin_contribution_describe` with `describe_path`,
+ `validate_path`, and `apply_path`.
+ - Verify `/api/admin/contributions` exposes `apply_path` only to authorized
+ users who can see the auth contribution.
+
+3. Scenario 90: Add secrets module and persisted apply state
+ - Add a Vault dev sidecar and `secrets.vault` module suitable for local
+ Docker proof.
+ - Add/create an auth admin applied-state table.
+ - Store accepted non-secret config JSON, secret refs/status, actor, and
+ timestamp.
+
+4. Scenario 90: Add protected apply endpoint
+ - Authenticate with `auth.jwt`.
+ - Enforce `admin:auth.config:update` from `scenario90_role_assignments`.
+ - Validate with `step.auth_admin_config_validate`.
+ - Route invalid validation to a non-persisting response.
+ - Write submitted secret fields through `step.secret_set`.
+ - Persist sanitized accepted config and refs only.
+ - Return `applied:true` only after secret write and persistence.
+
+5. Scenario 90: Add applied-state read endpoint
+ - Authenticate with `auth.jwt`.
+ - Enforce `admin:auth.config:read`.
+ - Return persisted state without secret values.
+
+6. Scenario 90: Extend verification
+ - Shell tests cover contribution metadata, valid apply, invalid apply, secret
+ redaction, state persistence, delegated read-only denial, and support-user
+ denial.
+ - Playwright tests click Authentication, validate, apply, navigate to
+ Authorization, and confirm no console errors.
+ - Scenario test must run the real Workflow server through Docker.
+
+## Non-Goals
+
+- No new Workflow reload implementation.
+- No new secret storage provider.
+- No new infra/IaC resource implementation.
+- No public-CI live Auth0/Okta/Entra provisioning.
+- No auth-specific logic in the generic admin shell.
+
+## Verification
+
+- `workflow-plugin-admin`: `go test ./...`.
+- `workflow-scenarios`: `go test ./...`.
+- `workflow-scenarios/scenarios/90-admin-tailnet-demo/test/run.sh`.
+- `workflow-scenarios/scenarios/90-admin-tailnet-demo/test/run-playwright.sh`.
+- Manual/adversarial review of diffs before PR/merge.
diff --git a/scenarios/90-admin-tailnet-demo/README.md b/scenarios/90-admin-tailnet-demo/README.md
index 4b466e0..3cffda0 100644
--- a/scenarios/90-admin-tailnet-demo/README.md
+++ b/scenarios/90-admin-tailnet-demo/README.md
@@ -9,6 +9,8 @@ does not contain or run an application-specific Python/Node/Ruby web harness.
- Authz admin contribution:
- Admin contribution API:
- Auth provider catalog API:
+- Auth config apply API:
+- Auth config applied-state API:
The scenario creates an admin user during tests:
@@ -43,6 +45,21 @@ through the admin shell's generic config-form mode; authz proof endpoints use
`workflow-plugin-authz-ui` steps. The app demonstrates frontend scope checks for
order read/update and admin scope checks for auth/authz management pages.
+Auth config apply is host-owned Workflow YAML: the endpoint authenticates with
+`auth.jwt`, enforces `admin:auth.config:update`, validates through
+`workflow-plugin-auth`, writes the submitted Auth0 provider secret through a
+real `secrets.vault` module backed by the scenario Vault sidecar, and persists
+only accepted non-secret config plus `secret://` refs in SQLite. The
+applied-state endpoint exposes the persisted non-secret config and secret refs
+for scenario verification and admin observability, without returning secret
+values.
+
+Provider catalog and provider descriptor routes are public-CI safe. Live
+remote Auth0/Okta/Entra/Ory/Scalekit provisioning remains provider-plugin-owned
+and should run in credentialed/provider-specific scenarios, not through
+`workflow-plugin-infra` unless an identity resource is explicitly modeled as
+IaC.
+
## Run
```sh
diff --git a/scenarios/90-admin-tailnet-demo/config/app.yaml b/scenarios/90-admin-tailnet-demo/config/app.yaml
index c0f13a3..d20c14d 100644
--- a/scenarios/90-admin-tailnet-demo/config/app.yaml
+++ b/scenarios/90-admin-tailnet-demo/config/app.yaml
@@ -17,6 +17,19 @@ modules:
allowRegistration: true
dependsOn: [router]
+ - name: admin-db
+ type: database.workflow
+ config:
+ driver: sqlite
+ dsn: /data/data/scenario90-admin.db
+
+ - name: scenario90-secrets
+ type: secrets.vault
+ config:
+ address: http://vault:8200
+ token: scenario90-root-token
+ mountPath: secret
+
- name: admin
type: admin.dashboard
config:
@@ -691,7 +704,11 @@ pipelines:
mode: single
- name: describe-auth-contribution
type: step.auth_admin_contribution_describe
- config: {}
+ config:
+ metadata:
+ describe_path: /api/admin/auth/config
+ validate_path: /api/admin/auth/config/validate
+ apply_path: /api/admin/auth/config/apply
- name: register-auth
type: step.admin_register_contribution
config:
@@ -1155,6 +1172,312 @@ pipelines:
allowed: false
reason: missing admin:auth.config:update
+ auth-admin-config-apply:
+ trigger:
+ type: http
+ config:
+ path: /api/admin/auth/config/apply
+ method: POST
+ steps:
+ - name: parse_request
+ type: step.request_parse
+ config:
+ parse_headers: [Authorization]
+ parse_body: true
+ - name: authenticate
+ type: step.auth_validate
+ config:
+ auth_module: auth
+ token_source: steps.parse_request.headers.Authorization
+ subject_field: subject
+ - *scenario90_create_role_table_step
+ - name: create-auth-config-state-table
+ type: step.db_exec
+ config:
+ database: admin-db
+ query: |
+ CREATE TABLE IF NOT EXISTS scenario90_auth_config_applied (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ accepted_config_json TEXT NOT NULL,
+ secret_refs_json TEXT NOT NULL,
+ updated_by TEXT NOT NULL,
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ ignore_error: true
+ - name: find-auth-config-update
+ type: step.db_query
+ config:
+ database: admin-db
+ query: |
+ SELECT user, role, context
+ FROM scenario90_role_assignments
+ WHERE user = ? AND context = ?
+ AND EXISTS (
+ SELECT 1
+ FROM json_each(scenario90_role_assignments.scopes_json)
+ WHERE value = ?
+ )
+ LIMIT 1
+ params:
+ - '{{ index .steps "authenticate" "email" }}'
+ - admin
+ - admin:auth.config:update
+ mode: single
+ - name: route-by-auth-config-update
+ type: step.conditional
+ config:
+ field: steps.find-auth-config-update.found
+ routes:
+ 'true': validate
+ default: deny-response
+ - name: validate
+ type: step.auth_admin_config_validate
+ config:
+ require_primary_method: true
+ input:
+ desired_config: { _from: "steps.parse_request.body.desired_config" }
+ providers: *auth_provider_descriptors
+ - name: route-by-validation
+ type: step.conditional
+ config:
+ field: steps.validate.valid
+ routes:
+ 'true': fetch-current-secret-refs
+ default: invalid-response
+ - name: fetch-current-secret-refs
+ type: step.db_query
+ config:
+ database: admin-db
+ query: |
+ SELECT secret_refs_json,
+ CAST(secret_refs_json AS BLOB) AS secret_refs
+ FROM scenario90_auth_config_applied
+ WHERE id = 1
+ mode: single
+ - name: find-submitted-auth0-secret
+ type: step.db_query
+ config:
+ database: admin-db
+ query: |
+ SELECT 1 AS has_secret
+ WHERE ? != '' AND ? != ''
+ params:
+ - '{{ index .steps "parse_request" "body" "desired_config" "auth0_client_secret" }}'
+ - '{{ index .steps "parse_request" "body" "desired_config" "auth0_client_secret" }}'
+ mode: single
+ - name: route-by-submitted-auth0-secret
+ type: step.conditional
+ config:
+ field: steps.find-submitted-auth0-secret.found
+ routes:
+ 'true': write-auth0-client-secret
+ default: route-by-existing-secret-refs
+ - name: route-by-existing-secret-refs
+ type: step.conditional
+ config:
+ field: steps.fetch-current-secret-refs.found
+ routes:
+ 'true': persist-with-existing-secret-refs
+ default: persist-without-secret
+ - name: write-auth0-client-secret
+ type: step.secret_set
+ config:
+ module: scenario90-secrets
+ secrets:
+ scenario90/auth0_client_secret: '{{ index .steps "parse_request" "body" "desired_config" "auth0_client_secret" }}'
+ - name: persist-with-secret
+ type: step.db_exec
+ config:
+ database: admin-db
+ query: |
+ INSERT INTO scenario90_auth_config_applied (id, accepted_config_json, secret_refs_json, updated_by, updated_at)
+ VALUES (1, ?, ?, ?, datetime('now'))
+ ON CONFLICT(id) DO UPDATE SET
+ accepted_config_json = excluded.accepted_config_json,
+ secret_refs_json = excluded.secret_refs_json,
+ updated_by = excluded.updated_by,
+ updated_at = datetime('now')
+ params:
+ - '{{ json (index .steps "validate" "accepted_config") }}'
+ - '{"auth0_client_secret":"secret://scenario90/auth0_client_secret"}'
+ - '{{ index .steps "authenticate" "email" }}'
+ - name: apply-with-secret-response
+ type: step.json_response
+ config:
+ status: 200
+ body:
+ applied: true
+ valid: { _from: "steps.validate.valid" }
+ accepted_config: { _from: "steps.validate.accepted_config" }
+ methods_policy: { _from: "steps.validate.methods_policy" }
+ warnings: { _from: "steps.validate.warnings" }
+ secret_fields: { _from: "steps.validate.secret_fields" }
+ secret_refs:
+ auth0_client_secret: secret://scenario90/auth0_client_secret
+ secret_write: { _from: "steps.write-auth0-client-secret.set_keys" }
+ updated_by: { _from: "steps.authenticate.email" }
+ - name: persist-without-secret
+ type: step.db_exec
+ config:
+ database: admin-db
+ query: |
+ INSERT INTO scenario90_auth_config_applied (id, accepted_config_json, secret_refs_json, updated_by, updated_at)
+ VALUES (1, ?, ?, ?, datetime('now'))
+ ON CONFLICT(id) DO UPDATE SET
+ accepted_config_json = excluded.accepted_config_json,
+ secret_refs_json = excluded.secret_refs_json,
+ updated_by = excluded.updated_by,
+ updated_at = datetime('now')
+ params:
+ - '{{ json (index .steps "validate" "accepted_config") }}'
+ - '{}'
+ - '{{ index .steps "authenticate" "email" }}'
+ - name: apply-without-secret-response
+ type: step.json_response
+ config:
+ status: 200
+ body:
+ applied: true
+ valid: { _from: "steps.validate.valid" }
+ accepted_config: { _from: "steps.validate.accepted_config" }
+ methods_policy: { _from: "steps.validate.methods_policy" }
+ warnings: { _from: "steps.validate.warnings" }
+ secret_fields: { _from: "steps.validate.secret_fields" }
+ secret_refs: {}
+ updated_by: { _from: "steps.authenticate.email" }
+ - name: persist-with-existing-secret-refs
+ type: step.db_exec
+ config:
+ database: admin-db
+ query: |
+ INSERT INTO scenario90_auth_config_applied (id, accepted_config_json, secret_refs_json, updated_by, updated_at)
+ VALUES (1, ?, ?, ?, datetime('now'))
+ ON CONFLICT(id) DO UPDATE SET
+ accepted_config_json = excluded.accepted_config_json,
+ secret_refs_json = excluded.secret_refs_json,
+ updated_by = excluded.updated_by,
+ updated_at = datetime('now')
+ params:
+ - '{{ json (index .steps "validate" "accepted_config") }}'
+ - '{{ index .steps "fetch-current-secret-refs" "row" "secret_refs_json" }}'
+ - '{{ index .steps "authenticate" "email" }}'
+ - name: apply-with-existing-secret-refs-response
+ type: step.json_response
+ config:
+ status: 200
+ body:
+ applied: true
+ valid: { _from: "steps.validate.valid" }
+ accepted_config: { _from: "steps.validate.accepted_config" }
+ methods_policy: { _from: "steps.validate.methods_policy" }
+ warnings: { _from: "steps.validate.warnings" }
+ secret_fields: { _from: "steps.validate.secret_fields" }
+ secret_refs: { _from: "steps.fetch-current-secret-refs.row.secret_refs" }
+ updated_by: { _from: "steps.authenticate.email" }
+ - name: invalid-response
+ type: step.json_response
+ config:
+ status: 422
+ body:
+ applied: false
+ valid: { _from: "steps.validate.valid" }
+ accepted_config: { _from: "steps.validate.accepted_config" }
+ methods_policy: { _from: "steps.validate.methods_policy" }
+ errors: { _from: "steps.validate.errors" }
+ warnings: { _from: "steps.validate.warnings" }
+ secret_fields: { _from: "steps.validate.secret_fields" }
+ - name: deny-response
+ type: step.json_response
+ config:
+ status: 403
+ body:
+ allowed: false
+ reason: missing admin:auth.config:update
+
+ auth-admin-config-applied:
+ trigger:
+ type: http
+ config:
+ path: /api/admin/auth/config/applied
+ method: GET
+ steps:
+ - name: parse_auth
+ type: step.request_parse
+ config:
+ parse_headers: [Authorization]
+ - name: authenticate
+ type: step.auth_validate
+ config:
+ auth_module: auth
+ token_source: steps.parse_auth.headers.Authorization
+ subject_field: subject
+ - *scenario90_create_role_table_step
+ - name: create-auth-config-state-table
+ type: step.db_exec
+ config:
+ database: admin-db
+ query: |
+ CREATE TABLE IF NOT EXISTS scenario90_auth_config_applied (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ accepted_config_json TEXT NOT NULL,
+ secret_refs_json TEXT NOT NULL,
+ updated_by TEXT NOT NULL,
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ ignore_error: true
+ - name: find-auth-config-read
+ type: step.db_query
+ config:
+ database: admin-db
+ query: |
+ SELECT user, role, context
+ FROM scenario90_role_assignments
+ WHERE user = ? AND context = ?
+ AND EXISTS (
+ SELECT 1
+ FROM json_each(scenario90_role_assignments.scopes_json)
+ WHERE value = ?
+ )
+ LIMIT 1
+ params:
+ - '{{ index .steps "authenticate" "email" }}'
+ - admin
+ - admin:auth.config:read
+ mode: single
+ - name: route-by-auth-config-read
+ type: step.conditional
+ config:
+ field: steps.find-auth-config-read.found
+ routes:
+ 'true': fetch-applied-state
+ default: deny-read-response
+ - name: fetch-applied-state
+ type: step.db_query
+ config:
+ database: admin-db
+ query: |
+ SELECT CAST(accepted_config_json AS BLOB) AS accepted_config,
+ CAST(secret_refs_json AS BLOB) AS secret_refs,
+ updated_by,
+ updated_at
+ FROM scenario90_auth_config_applied
+ WHERE id = 1
+ mode: single
+ - name: applied-state-response
+ type: step.json_response
+ config:
+ status: 200
+ body:
+ applied: { _from: "steps.fetch-applied-state.found" }
+ state: { _from: "steps.fetch-applied-state.row" }
+ - name: deny-read-response
+ type: step.json_response
+ config:
+ status: 403
+ body:
+ allowed: false
+ reason: missing admin:auth.config:read
+
describe-auth0-provider:
trigger:
type: http
diff --git a/scenarios/90-admin-tailnet-demo/docker-compose.yml b/scenarios/90-admin-tailnet-demo/docker-compose.yml
index 6d5e540..4f18eee 100644
--- a/scenarios/90-admin-tailnet-demo/docker-compose.yml
+++ b/scenarios/90-admin-tailnet-demo/docker-compose.yml
@@ -1,4 +1,17 @@
services:
+ vault:
+ image: hashicorp/vault:1.15
+ cap_add:
+ - IPC_LOCK
+ environment:
+ VAULT_DEV_ROOT_TOKEN_ID: scenario90-root-token
+ VAULT_DEV_LISTEN_ADDRESS: 0.0.0.0:8200
+ healthcheck:
+ test: ["CMD", "vault", "status", "-address=http://127.0.0.1:8200"]
+ interval: 2s
+ timeout: 2s
+ retries: 30
+
app:
image: ${IMAGE_TAG:-workflow-admin:scenario-90}
container_name: workflow-admin-tailnet-demo
@@ -8,6 +21,9 @@ services:
JWT_SECRET: scenario-90-local-demo-secret-32-bytes-minimum
ports:
- "127.0.0.1:18080:8080"
+ depends_on:
+ vault:
+ condition: service_healthy
tailscale:
image: tailscale/tailscale:stable
diff --git a/scenarios/90-admin-tailnet-demo/seed/seed.sh b/scenarios/90-admin-tailnet-demo/seed/seed.sh
index 4894285..4da5b12 100755
--- a/scenarios/90-admin-tailnet-demo/seed/seed.sh
+++ b/scenarios/90-admin-tailnet-demo/seed/seed.sh
@@ -34,6 +34,8 @@ admin_ui_dir_has_auth_gate() {
admin_ui_dir_has_contribution_renderers() {
local ui_dir="$1"
grep -R 'renderConfigForm' "$ui_dir" >/dev/null 2>&1 &&
+ grep -R 'apply_path' "$ui_dir" >/dev/null 2>&1 &&
+ grep -R 'Apply changes' "$ui_dir" >/dev/null 2>&1 &&
grep -R 'workflow.admin.auth.request' "$ui_dir" >/dev/null 2>&1 &&
grep -R 'workflow.admin.auth.response' "$ui_dir" >/dev/null 2>&1
}
@@ -81,10 +83,38 @@ find_admin_repo() {
return 1
}
+authz_ui_repo_is_usable() {
+ local repo="$1"
+ [ -f "$repo/go.mod" ] &&
+ [ -f "$repo/plugin.json" ] &&
+ grep 'step.authz_admin_contribution' "$repo/internal/plugin.go" >/dev/null 2>&1
+}
+
+find_authz_ui_repo() {
+ if [ -n "${PLUGIN_AUTHZ_UI_REPO:-}" ]; then
+ echo "$PLUGIN_AUTHZ_UI_REPO"
+ return 0
+ fi
+
+ local root="$WORKSPACE_ROOT/workflow-plugin-authz-ui"
+ local candidate
+ for candidate in "$root" "$root"/.worktrees/*; do
+ [ -d "$candidate" ] || continue
+ if authz_ui_repo_is_usable "$candidate"; then
+ echo "$candidate"
+ return 0
+ fi
+ done
+
+ echo "ERROR: no workflow-plugin-authz-ui checkout with step.authz_admin_contribution found under $root" >&2
+ echo " Use workflow-plugin-authz-ui with the admin contribution step, or set PLUGIN_AUTHZ_UI_REPO explicitly." >&2
+ return 1
+}
+
WORKFLOW_REPO="${WORKFLOW_REPO:-$WORKSPACE_ROOT/workflow}"
PLUGIN_ADMIN_REPO="$(find_admin_repo)"
-PLUGIN_AUTH_REPO="${PLUGIN_AUTH_REPO:-$WORKSPACE_ROOT/workflow-plugin-auth/.worktrees/auth-admin-contribution}"
-PLUGIN_AUTHZ_UI_REPO="${PLUGIN_AUTHZ_UI_REPO:-$WORKSPACE_ROOT/workflow-plugin-authz-ui/.worktrees/authz-ui-admin-bridge}"
+PLUGIN_AUTH_REPO="${PLUGIN_AUTH_REPO:-$WORKSPACE_ROOT/workflow-plugin-auth}"
+PLUGIN_AUTHZ_UI_REPO="$(find_authz_ui_repo)"
IMAGE_TAG="${IMAGE_TAG:-workflow-admin:scenario-90}"
provider_repos=(
@@ -176,8 +206,8 @@ if ! admin_ui_dir_has_auth_gate "$BUILD_DIR/admin-ui"; then
exit 1
fi
if ! admin_ui_dir_has_contribution_renderers "$BUILD_DIR/admin-ui"; then
- echo "ERROR: selected workflow-plugin-admin UI does not render config-form surfaces or iframe auth bridge requests" >&2
- echo " Use workflow-plugin-admin with contribution renderer assets, or set PLUGIN_ADMIN_REPO explicitly." >&2
+ echo "ERROR: selected workflow-plugin-admin UI does not render config-form apply surfaces or iframe auth bridge requests" >&2
+ echo " Use workflow-plugin-admin with contribution renderer/apply assets, or set PLUGIN_ADMIN_REPO explicitly." >&2
exit 1
fi
if [ -d "$PLUGIN_AUTHZ_UI_REPO/ui/dist" ]; then
diff --git a/scenarios/90-admin-tailnet-demo/test/playwright/admin-surfaces.spec.cjs b/scenarios/90-admin-tailnet-demo/test/playwright/admin-surfaces.spec.cjs
index 705bbaf..98b79c4 100644
--- a/scenarios/90-admin-tailnet-demo/test/playwright/admin-surfaces.spec.cjs
+++ b/scenarios/90-admin-tailnet-demo/test/playwright/admin-surfaces.spec.cjs
@@ -33,6 +33,18 @@ test('Scenario 90 admin surfaces are usable and protected', async ({ page, conte
await expect(page.locator('#contribution-list')).toContainText('OAuth providers');
await page.getByRole('button', { name: 'Validate changes' }).click();
await expect(page.locator('#contribution-list')).toContainText(/valid/i);
+ await expect(page.getByRole('button', { name: 'Apply changes' })).toBeVisible();
+ await page.getByLabel('Passkey relying party ID').fill('127.0.0.1');
+ await page.getByLabel('Passkey origin').fill('http://127.0.0.1:18080');
+ await page.getByLabel('Auth routes').check();
+ await page.getByLabel('Auth0 domain').fill('demo.us.auth0.com');
+ await page.getByLabel('M2M client ID').fill('scenario90-browser-client');
+ await page.getByLabel('M2M client secret').fill('scenario90-browser-secret-value');
+ await page.getByRole('button', { name: 'Apply changes' }).click();
+ await expect(page.locator('#contribution-list')).toContainText(/"applied"\s*:\s*true/);
+ await expect(page.locator('#contribution-list')).toContainText(/"auth0_client_secret"\s*:\s*"secret:\/\/scenario90\/auth0_client_secret"/);
+ await expect(page.locator('#contribution-list')).not.toContainText('scenario90-browser-secret-value');
+ await expect(page.getByLabel('M2M client secret')).toHaveValue('');
let activeLabels = await page.locator('button.nav-item.active').evaluateAll((nodes) => nodes.map((node) => node.textContent.trim()));
expect(activeLabels).toEqual(['Authentication']);
diff --git a/scenarios/90-admin-tailnet-demo/test/run.sh b/scenarios/90-admin-tailnet-demo/test/run.sh
index b3f44d9..cda8668 100755
--- a/scenarios/90-admin-tailnet-demo/test/run.sh
+++ b/scenarios/90-admin-tailnet-demo/test/run.sh
@@ -133,6 +133,7 @@ contribs="$(curl -fsS -H "$AUTH_HEADER" "$BASE/api/admin/contributions")"
contains "$contribs" '"id":"authz-roles"' "Admin plugin registered authz contribution"
contains "$contribs" '"id":"auth-config"' "Admin plugin registered auth contribution from auth plugin"
contains "$contribs" '"render_mode":"config-form"' "Auth contribution uses generic config form render mode"
+contains "$contribs" '"apply_path":"/api/admin/auth/config/apply"' "Auth contribution advertises apply endpoint"
contains "$contribs" '"render_mode":"iframe"' "Admin contribution uses pluggable iframe render mode"
contains "$contribs" '"admin:authz.roles:update"' "Admin contribution grant bridge includes authz update scope"
@@ -175,12 +176,82 @@ contains "$delegated_auth_config" '"groups"' "Auth config read is authorized by
validate_config="$(curl -fsS -H "$AUTH_HEADER" -H 'content-type: application/json' -d '{"desired_config":{"environment":"production","password_auth_enabled":true}}' "$BASE/api/admin/auth/config/validate")"
contains "$validate_config" '"valid":false' "Auth config validation rejects unsafe production password login"
contains "$validate_config" 'password auth cannot be enabled in production' "Auth config validation returns plugin diagnostic"
+anonymous_apply_code="$(curl -s -o /tmp/scenario90-anonymous-apply.json -w "%{http_code}" -H 'content-type: application/json' -d '{"desired_config":{"environment":"development"}}' "$BASE/api/admin/auth/config/apply")"
+if [ "$anonymous_apply_code" = "401" ]; then
+ pass "Anonymous user cannot apply admin auth config"
+else
+ fail "Anonymous auth config apply expected 401, got $anonymous_apply_code"
+fi
+initial_no_secret_payload='{"desired_config":{"environment":"development","auth_routes_enabled":false,"password_auth_enabled":false,"webauthn_rp_id":"127.0.0.1","webauthn_origin":"http://127.0.0.1:18080"}}'
+initial_no_secret_apply="$(curl -fsS -H "$AUTH_HEADER" -H 'content-type: application/json' -d "$initial_no_secret_payload" "$BASE/api/admin/auth/config/apply")"
+contains "$initial_no_secret_apply" '"applied":true' "Auth config apply accepts first-time no-secret configuration"
+contains "$initial_no_secret_apply" '"secret_refs":{}' "First-time no-secret auth apply returns empty secret refs"
+initial_no_secret_state="$(curl -fsS -H "$AUTH_HEADER" "$BASE/api/admin/auth/config/applied")"
+contains "$initial_no_secret_state" '"secret_refs":{}' "First-time no-secret auth apply persists empty secret refs"
+apply_payload='{"desired_config":{"environment":"development","auth_routes_enabled":true,"password_auth_enabled":false,"webauthn_rp_id":"127.0.0.1","webauthn_origin":"http://127.0.0.1:18080","auth0_domain":"demo.us.auth0.com","auth0_client_id":"scenario90-client","auth0_client_secret":"scenario90-client-secret-value"}}'
+apply_config="$(curl -fsS -H "$AUTH_HEADER" -H 'content-type: application/json' -d "$apply_payload" "$BASE/api/admin/auth/config/apply")"
+contains "$apply_config" '"applied":true' "Auth config apply persists valid configuration"
+contains "$apply_config" '"valid":true' "Auth config apply runs plugin validation before persistence"
+contains "$apply_config" '"auth0_client_secret":"secret://scenario90/auth0_client_secret"' "Auth config apply returns secret ref instead of secret value"
+contains "$apply_config" '"scenario90/auth0_client_secret"' "Auth config apply reports written secret key"
+if grep -q 'scenario90-client-secret-value' <<<"$apply_config"; then
+ fail "Auth config apply response must not echo provider secret values"
+else
+ pass "Auth config apply response does not echo provider secrets"
+fi
+vault_secret="$(docker compose -f "$SCENARIO_DIR/docker-compose.yml" exec -T vault sh -c 'VAULT_ADDR=http://127.0.0.1:8200 VAULT_TOKEN=scenario90-root-token vault kv get -field=value secret/scenario90/auth0_client_secret' 2>/dev/null || true)"
+if [ "$vault_secret" = "scenario90-client-secret-value" ]; then
+ pass "Auth config apply writes provider secret through Workflow Vault provider"
+else
+ fail "Auth config apply did not write provider secret to Vault sidecar"
+fi
+applied_state="$(curl -fsS -H "$AUTH_HEADER" "$BASE/api/admin/auth/config/applied")"
+contains "$applied_state" '"applied":true' "Auth applied-state endpoint reports persisted state"
+contains "$applied_state" '"auth0_client_secret":"secret://scenario90/auth0_client_secret"' "Auth applied-state endpoint returns persisted secret ref"
+contains "$applied_state" '"auth0_client_id":"scenario90-client"' "Auth applied-state endpoint returns non-secret accepted config"
+if grep -q 'scenario90-client-secret-value' <<<"$applied_state"; then
+ fail "Auth applied-state endpoint must not echo provider secret values"
+else
+ pass "Auth applied-state endpoint does not echo provider secrets"
+fi
+apply_without_secret_payload='{"desired_config":{"environment":"development","auth_routes_enabled":true,"password_auth_enabled":false,"webauthn_rp_id":"127.0.0.1","webauthn_origin":"http://127.0.0.1:18080","auth0_domain":"demo.us.auth0.com","auth0_client_id":"scenario90-client-rotated"}}'
+apply_without_secret="$(curl -fsS -H "$AUTH_HEADER" -H 'content-type: application/json' -d "$apply_without_secret_payload" "$BASE/api/admin/auth/config/apply")"
+contains "$apply_without_secret" '"applied":true' "Auth config apply accepts later non-secret updates"
+contains "$apply_without_secret" '"auth0_client_secret":"secret://scenario90/auth0_client_secret"' "Auth config apply preserves existing secret ref when secret is omitted"
+if grep -q 'scenario90-client-secret-value' <<<"$apply_without_secret"; then
+ fail "Auth config no-secret apply response must not echo provider secret values"
+else
+ pass "Auth config no-secret apply response does not echo provider secrets"
+fi
+applied_state_after_no_secret="$(curl -fsS -H "$AUTH_HEADER" "$BASE/api/admin/auth/config/applied")"
+contains "$applied_state_after_no_secret" '"auth0_client_id":"scenario90-client-rotated"' "Auth applied-state endpoint reflects later non-secret update"
+contains "$applied_state_after_no_secret" '"auth0_client_secret":"secret://scenario90/auth0_client_secret"' "Auth applied-state endpoint keeps secret ref after no-secret update"
+state_before_invalid="$(jq -c '.state' <<<"$applied_state_after_no_secret")"
+invalid_apply_code="$(curl -s -o /tmp/scenario90-invalid-apply.json -w "%{http_code}" -H "$AUTH_HEADER" -H 'content-type: application/json' -d '{"desired_config":{"environment":"production","password_auth_enabled":true}}' "$BASE/api/admin/auth/config/apply")"
+if [ "$invalid_apply_code" = "422" ]; then
+ pass "Auth config apply rejects invalid config before persistence"
+else
+ fail "Auth config invalid apply expected 422, got $invalid_apply_code"
+fi
+contains "$(cat /tmp/scenario90-invalid-apply.json)" '"applied":false' "Invalid auth apply reports unapplied state"
+state_after_invalid="$(curl -fsS -H "$AUTH_HEADER" "$BASE/api/admin/auth/config/applied" | jq -c '.state')"
+if [ "$state_after_invalid" = "$state_before_invalid" ]; then
+ pass "Invalid auth apply leaves persisted state unchanged"
+else
+ fail "Invalid auth apply changed persisted state"
+fi
delegated_validate_code="$(curl -s -o /tmp/scenario90-delegated-validate.json -w "%{http_code}" -H "$DELEGATED_AUTH_HEADER" -H 'content-type: application/json' -d '{"desired_config":{"environment":"development"}}' "$BASE/api/admin/auth/config/validate")"
if [ "$delegated_validate_code" = "403" ]; then
pass "Delegated read-only auth admin cannot validate config without update scope"
else
fail "Delegated auth config validate expected 403, got $delegated_validate_code"
fi
+delegated_apply_code="$(curl -s -o /tmp/scenario90-delegated-apply.json -w "%{http_code}" -H "$DELEGATED_AUTH_HEADER" -H 'content-type: application/json' -d '{"desired_config":{"environment":"development"}}' "$BASE/api/admin/auth/config/apply")"
+if [ "$delegated_apply_code" = "403" ]; then
+ pass "Delegated read-only auth admin cannot apply config without update scope"
+else
+ fail "Delegated auth config apply expected 403, got $delegated_apply_code"
+fi
delegated_scopes_code="$(curl -s -o /tmp/scenario90-delegated-authz-scopes.json -w "%{http_code}" -H "$DELEGATED_AUTH_HEADER" "$BASE/api/authz/scopes")"
if [ "$delegated_scopes_code" = "403" ]; then
pass "Delegated auth admin cannot read authz metadata without authz read scope"
@@ -293,6 +364,12 @@ if [ "$support_validate_code" = "403" ]; then
else
fail "Support auth config validate expected 403, got $support_validate_code"
fi
+support_apply_code="$(curl -s -o /tmp/scenario90-support-auth-apply.json -w "%{http_code}" -H "$SUPPORT_AUTH_HEADER" -H 'content-type: application/json' -d '{"desired_config":{"environment":"development"}}' "$BASE/api/admin/auth/config/apply")"
+if [ "$support_apply_code" = "403" ]; then
+ pass "Support cannot apply admin auth config without admin auth update scope"
+else
+ fail "Support auth config apply expected 403, got $support_apply_code"
+fi
viewer_roles_code="$(curl -s -o /tmp/scenario90-viewer-roles.json -w "%{http_code}" -H "$VIEWER_AUTH_HEADER" "$BASE/api/authz/roles")"
if [ "$viewer_roles_code" = "403" ]; then
pass "Viewer cannot read authz role assignments without admin authz read scope"