From 05525988a6e12967ad810a9d5262baa4079b927b Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 2 Jun 2026 06:58:07 -0400 Subject: [PATCH 1/2] feat: prove auth admin config apply --- ...-06-02-auth-provider-admin-apply-design.md | 181 ++++++++++ .../2026-06-02-auth-provider-admin-apply.md | 77 +++++ scenarios/90-admin-tailnet-demo/README.md | 17 + .../90-admin-tailnet-demo/config/app.yaml | 325 +++++++++++++++++- .../90-admin-tailnet-demo/docker-compose.yml | 16 + scenarios/90-admin-tailnet-demo/seed/seed.sh | 38 +- .../test/playwright/admin-surfaces.spec.cjs | 12 + scenarios/90-admin-tailnet-demo/test/run.sh | 77 +++++ 8 files changed, 738 insertions(+), 5 deletions(-) create mode 100644 docs/plans/2026-06-02-auth-provider-admin-apply-design.md create mode 100644 docs/plans/2026-06-02-auth-provider-admin-apply.md 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..5703385 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": true'); + await expect(page.locator('#contribution-list')).toContainText('"auth0_client_secret": "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" From 8a3b2c48fef5e48854373a7b5adf6365d1c73b3f Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 2 Jun 2026 07:04:35 -0400 Subject: [PATCH 2/2] fix: make admin apply UI assertion robust --- .../test/playwright/admin-surfaces.spec.cjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 5703385..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 @@ -41,8 +41,8 @@ test('Scenario 90 admin surfaces are usable and protected', async ({ page, conte 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": true'); - await expect(page.locator('#contribution-list')).toContainText('"auth0_client_secret": "secret://scenario90/auth0_client_secret"'); + 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()));