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)
| Flag | Task | Output |
|---|---|---|
VERIFY_SERVICE_KEY=1 | A — read the current service_role key from Project Settings → API | SUPABASE_SERVICE_ROLE_KEY_CURRENT → out-file only (never stdout). Compare/write is secret-manager. |
CREATE_TECH_USERS=1 | B — create user-test-tech-{w,z,m} accounts, strong password, auto-confirm email | TEST_TECH_{W,Z,M}_UUID (non-secret, stdout ok) + _EMAIL + _PASSWORD (out-file only) |
RESET_PLAC=1 | C — reset the user-test-plac password | plac_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
| Piece | Path | Role |
|---|---|---|
| Broker wrapper | scripts/secret-broker/Invoke-PlaywrightWithSecret.ps1 | Lints 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 JS | scripts/rotate/supabase-dashboard-admin.js | Logs in once, runs the flag-gated actions, writes all generated secrets to SECRETS_OUT_FILE, prints only non-secret confirmations. |
| Broker core | scripts/lib/sops-common.psm1 → Invoke-WithSopsSecret, Test-PlaywrightSecretUsage, Protect-SecretInText, Test-SopsSecretRedaction | Decrypt-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 — seesecret-manager-request.md. Run via the broker as below;Test-PlaywrightSecretUsagepasses because the JS referencesprocess.env.SUPABASE_PASSexactly 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
storageStateJSON in a logged-in browser and run the JS directly withSUPABASE_STORAGE_STATE=<path>(the password lint is not engaged on that path; the-SecretsOutFilescrub still applies). If Supabase’s dashboard also has strong bot-detection that defeats automation, the correct disposition is to re-addhuman-actionand 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/*.jsflows). SOPS_AGE_KEY_FILEpointing at the developer age key.- Password path:
SUPABASE_DASHBOARD_PASSWORDpresent as a SOPS key (secret-manager add if missing — key NAME only, never paste the value). - storageState path: a fresh
storageStateJSON 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_FILEThe 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.jsThe 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_KEYfor Task A) as ingitlab-token-rotation.md§“Audit Log”.