Supabase Dashboard — Playwright Secret Broker Playbook

Issue: #5911 · Design: docs/adr/003-sops-secret-access-broker.md · Sibling flow: gitlab-token-playwright-broker.md · Cross-repo Return-To: radieu/et-operational-platform#1621

This is the UI-only flow for Supabase-dashboard actions that no longer have a working API path. Legacy Supabase API keys were disabled project-wide on 2026-06-21 and the new sb_secret_* key returns 401 from /auth/v1/admin/*, so anything touching auth.users (create user, reset password) or reading the current service_role key must go through the authenticated supabase.com/dashboard UI. It reuses the ADR 003 Playwright carve-out: the login credential reaches a live browser, but never re-enters any tool output Claude reads.

Ownership (do not skip — role matrix)

Authoring the wrapper extension / JS / this playbook is dev-coder work. Running it against the live dashboard is a secret-manager + sys-admin operation on the Windows developer workstation — the only host that has a browser, an authenticated supabase.com/dashboard session, and the age key together. A headless bms-4 queue worker cannot run it (this is exactly why #5911 was mis-dispatched three times). Task A’s SOPS write is a secret-manager operation.

What it does (three flag-gated actions, one login)

FlagTaskOutput
VERIFY_SERVICE_KEY=1A — read the current service_role key from Project Settings → APISUPABASE_SERVICE_ROLE_KEY_CURRENT → out-file only (never stdout). Compare/write is secret-manager.
CREATE_TECH_USERS=1B — create user-test-tech-{w,z,m} accounts, strong password, auto-confirm emailTEST_TECH_{W,Z,M}_UUID (non-secret, stdout ok) + _EMAIL + _PASSWORD (out-file only)
RESET_PLAC=1C — reset the user-test-plac passwordplac_new_password → out-file only

Set any subset. All three run inside one authenticated session (cheaper, and one login is friendlier to bot-detection than three).

Components

PiecePathRole
Broker wrapperscripts/secret-broker/Invoke-PlaywrightWithSecret.ps1Lints the JS, delivers the login secret only as a child-process env var, scrubs stdout + trace. -SecretsOutFile redacts every value in a dotenv out-file (#5911).
Automation JSscripts/rotate/supabase-dashboard-admin.jsLogs in once, runs the flag-gated actions, writes all generated secrets to SECRETS_OUT_FILE, prints only non-secret confirmations.
Broker corescripts/lib/sops-common.psm1 → Invoke-WithSopsSecret, Test-PlaywrightSecretUsage, Protect-SecretInText, Test-SopsSecretRedactionDecrypt-in-process, lint, redact-all-encodings.

⚠️ Login-method decision gate (architect go/no-go — settle this FIRST)

The broker’s password carve-out only fits if the dashboard login is email + password. Check the live sign-in screen on the workstation before running:

  • Email + password → the carve-out fits directly. Store the dashboard login password as a SOPS key (name only, e.g. SUPABASE_DASHBOARD_PASSWORD) via a secret-manager add — see secret-manager-request.md. Run via the broker as below; Test-PlaywrightSecretUsage passes because the JS references process.env.SUPABASE_PASS exactly once, as .fill() on a password locator.
  • Google OAuth / SSO / MFA → no password can be typed. Do not force the password path. Instead capture a pre-authenticated Playwright storageState JSON in a logged-in browser and run the JS directly with SUPABASE_STORAGE_STATE=<path> (the password lint is not engaged on that path; the -SecretsOutFile scrub still applies). If Supabase’s dashboard also has strong bot-detection that defeats automation, the correct disposition is to re-add human-action and do the three actions manually per the original issue body — that is a legitimate design outcome, not a wrong call to flag.

Prerequisites (on the host with the age key — Windows workstation)

  • Node + Playwright + Chromium installed (same as scripts/rotate/*.js flows).
  • SOPS_AGE_KEY_FILE pointing at the developer age key.
  • Password path: SUPABASE_DASHBOARD_PASSWORD present as a SOPS key (secret-manager add if missing — key NAME only, never paste the value).
  • storageState path: a fresh storageState JSON exported from a logged-in dashboard browser.

Run — password-login path (secret-manager / sys-admin session)

$env:SUPABASE_URL         = "https://supabase.com/dashboard"
$env:SUPABASE_PROJECT_REF = "mwkqmgadqnkkihjdeqsi"
$env:SUPABASE_EMAIL       = "<dashboard login email>"       # not secret
$env:SECRETS_OUT_FILE     = Join-Path $env:TEMP "supabase-admin-out.env"
$env:TRACE_DIR            = Join-Path $env:TEMP "supabase-admin-trace"
$env:VERIFY_SERVICE_KEY   = "1"
$env:CREATE_TECH_USERS    = "1"
$env:RESET_PLAC           = "1"
 
.\scripts\secret-broker\Invoke-PlaywrightWithSecret.ps1 `
  -SopsFile      secrets\et-operational-platform.env.sops `
  -SopsKey       SUPABASE_DASHBOARD_PASSWORD `
  -EnvVarName    SUPABASE_PASS `
  -ScriptPath    scripts\rotate\supabase-dashboard-admin.js `
  -TraceDir      $env:TRACE_DIR `
  -SecretsOutFile $env:SECRETS_OUT_FILE

The wrapper prints only redacted output + [broker] automation exit code: N. On success $env:SECRETS_OUT_FILE holds the dotenv staging block (mode 0600), with every password/service_role value already scrubbed from stdout and the trace.

Run — storageState path (OAuth/MFA login)

$env:SUPABASE_STORAGE_STATE = Join-Path $env:TEMP "supabase-storage-state.json"
$env:SECRETS_OUT_FILE       = Join-Path $env:TEMP "supabase-admin-out.env"
$env:VERIFY_SERVICE_KEY = "1"; $env:CREATE_TECH_USERS = "1"; $env:RESET_PLAC = "1"
node scripts\rotate\supabase-dashboard-admin.js

The password lint does not apply here (no SUPABASE_PASS). Because there is no broker post-run scrub on this bare invocation, do not print or cat the out-file — hand it back exactly as staged.

Hand back the results (do NOT post values anywhere)

The out-file already uses the exact .env.local staging keys the issue specifies:

TEST_TECH_W_UUID / _EMAIL / _PASSWORD
TEST_TECH_Z_UUID / _EMAIL / _PASSWORD
TEST_TECH_M_UUID / _EMAIL / _PASSWORD
plac_new_password
SUPABASE_SERVICE_ROLE_KEY_CURRENT   (Task A read-back — for the secret-manager compare only)

Copy it to C:\code_2026\p24-infra\.env.local (LF-only, never print the contents), then comment on the issue / et-operational-platform#1621 confirming outcome with key NAMES only — same as every other credential hand-back.

Task A — read-in-browser, write-elsewhere

The JS only reads SUPABASE_SERVICE_ROLE_KEY_CURRENT. Comparing it against SUPABASE_SERVICE_ROLE_KEY in secrets/et-operational-platform.env.sops and any update is a secret-manager SOPS write — out of scope for the browser script:

# secret-manager session — value never printed. Canonical home is
# et-operational-platform.env.sops; Vercel is refreshed by secrets-sync.yml on
# merge, so no separate Vercel step is needed.
$env:NEW_VALUE = (Get-Content $env:SECRETS_OUT_FILE | Select-String '^SUPABASE_SERVICE_ROLE_KEY_CURRENT=').ToString().Split('=',2)[1]
.\scripts\sops-set.ps1 -SopsFile secrets\et-operational-platform.env.sops -Key SUPABASE_SERVICE_ROLE_KEY
$env:NEW_VALUE = ""

The user_roles insert for the 3 technicians (role_type='technician', resource_type='service_queues', resource_id='W'/'Z'/'M') is not part of this flow — a separate session does it via Supabase MCP once the UUIDs exist, per the issue body.

If the lint fails

Test-PlaywrightSecretUsage reports total/allowed. total > allowed means SUPABASE_PASS is referenced somewhere other than a .fill() on a password locator. The JS is written so the env var appears exactly once — on the password .fill() in the login step. Keep it that way (or pass a matching -PasswordLocatorPattern); do not add a second reference.

If Supabase’s dashboard UI changed

supabase-dashboard-admin.js targets supabase.com/dashboard’s current selectors with fallbacks (Project Settings → API-keys ↔ API; Authentication → Users → Add user / Reset password). Supabase reworks this UI periodically. Run once with HEADLESS=0 to watch, adjust the selectors, and re-lint before a real run — same dry-verify discipline as the GitLab flow.

After running — compliance + audit

  • The 3 created users and the reset are data changes for the running session, not code changes in the authoring PR.
  • Log to infra_operations (op_type=credential_rotation, resource=SUPABASE_SERVICE_ROLE_KEY for Task A) as in gitlab-token-rotation.md §“Audit Log”.