GitLab Token — Playwright Secret Broker Playbook
Issue: #5298 · Design: docs/adr/003-sops-secret-access-broker.md
This is the UI-only rotation flow for cases where GitLab.com SaaS blocks PAT creation via API and a real login credential has to reach a live browser. It is the concrete carve-out from ADR 003: the one confirmed case where a plaintext value leaves the broker’s process — and even then the value must never re-enter any tool output Claude reads.
Prefer the API. If
GITLAB_ADMIN_PATcan still create a new PAT via the API, usedocs/playbooks/gitlab-token-rotation.md(now routed through thesops-invoke.ps1broker). Use this Playwright flow only when the API path is unavailable (admin PAT expired/revoked, or the target flow is UI-only).
Ownership (do not skip — role matrix)
Authoring the wrapper/JS/this playbook is dev-coder work. Running a
GitLab PAT rotation is a secret-manager (SOPS write) + sys-admin /
infra-task operation — see docs/w3-w4-stack-operations.md
and the role matrix in CLAUDE.md. A dev-coder session must not run this flow
against real credentials; it delegates to secret-manager.
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 |
| Automation JS | scripts/rotate/gitlab-admin-pat.js | Logs in, creates the PAT, writes the new token to TOKEN_OUT_FILE, prints only non-secret confirmation |
| Broker core | scripts/lib/sops-common.psm1 → Invoke-WithSopsSecret, Test-PlaywrightSecretUsage, Protect-SecretInText, Test-SopsSecretRedaction | Decrypt-in-process, lint, redact-all-encodings |
The four carve-out rules (ADR 003 §Playwright carve-out)
- Value delivery — the login secret is set only as an env var in the child
Node process (never a CLI arg → no
ps/Task Manager exposure). The JS readsprocess.env.GITLAB_PASSby name only. - Script lint before execution —
Test-PlaywrightSecretUsagerefuses to run a JS where the env-var name appears anywhere except as the argument to a.fill()on a password locator. Heuristic v1 (regex); the redaction net is the real guarantee.-SkipLintexists but is discouraged and logged. - Capture surfaces — the JS starts tracing/screenshots only after the
post-login redirect; the login step is never captured. The wrapper
additionally scrubs the whole
TraceDir. - Output filtering — the broker holds the plaintext, so it (and only it)
redacts the login password and the newly created token (raw + URL-encoded
- base64) out of stdout/stderr and every trace/artifact file before returning
pass/fail + the non-secret token id. The token value is left only in
TOKEN_OUT_FILEfor the downstreamsops-set.ps1write.
- base64) out of stdout/stderr and every trace/artifact file before returning
pass/fail + the non-secret token id. The token value is left only in
Prerequisites (on the host with the age key)
- The GitLab login password must exist as a SOPS key (e.g.
GITLAB_LOGIN_PASSWORDinsecrets/pinbox24-w4-auth.env.sops). If it does not, that is a secret-manager add — reference the key NAME only, never paste the value; seedocs/playbooks/secret-manager-request.md. - Node + Playwright + Chromium installed (same as
scripts/rotate/*.jsflows). SOPS_AGE_KEY_FILEpointing at the developer age key.
Run (secret-manager / sys-admin session)
$env:GITLAB_URL = "https://gitlab.com"
$env:GITLAB_EMAIL = "radieu@gmail.com" # not secret
$env:TOKEN_NAME = "p24-infra-admin-20260803"
$env:TOKEN_SCOPES = "api,read_user,read_repository,write_repository"
$env:TOKEN_OUT_FILE = Join-Path $env:TEMP "new-gitlab-pat.txt"
$env:TRACE_DIR = Join-Path $env:TEMP "gitlab-pat-trace"
.\scripts\secret-broker\Invoke-PlaywrightWithSecret.ps1 `
-SopsFile secrets\pinbox24-w4-auth.env.sops `
-SopsKey GITLAB_LOGIN_PASSWORD `
-EnvVarName GITLAB_PASS `
-ScriptPath scripts\rotate\gitlab-admin-pat.js `
-TraceDir $env:TRACE_DIR `
-TokenOutFile $env:TOKEN_OUT_FILEThe wrapper prints only redacted output + [broker] automation exit code: N.
On success $env:TOKEN_OUT_FILE holds the new token value (mode 0600). Do not
cat/Read that file — pipe it straight into the SOPS write:
# Write the new token INTO SOPS via the enforced write path — value never printed.
$env:NEW_VALUE = Get-Content $env:TOKEN_OUT_FILE -Raw
.\scripts\sops-set.ps1 -SopsFile secrets\administration.env.sops -Key GITLAB_ADMIN_PAT
$env:NEW_VALUE = ""
Remove-Item $env:TOKEN_OUT_FILE -Force
# GITLAB_ADMIN_PAT also lives in secrets/pinbox24-gitlab.env.sops (2-recipient
# narrow copy, #4796) — rotate BOTH atomically, same value.Then complete the distribution + revoke-old chain in
gitlab-token-rotation.md §“Distribution chain”.
After running — compliance + audit
- Register
rotation_type='playwright'for the GitLab admin PAT in Supabasedev_r_services(consistent withplaywright-rotation-template.md). This is a data change for the running secret-manager session, not a code change in this PR. - Log to
infra_operations(op_type=credential_rotation,resource=GITLAB_TOKEN) as ingitlab-token-rotation.md§“Audit Log”.
If the lint fails
Test-PlaywrightSecretUsage reports total/allowed. total > allowed means
GITLAB_PASS is referenced somewhere other than a .fill() on a password
locator (a console.log, an assertion, an assignment, a page.evaluate). Fix
the JS so the env var is only ever .fill()-ed into the password field. The
line-scoped locator check is the documented v1 limitation — put the .fill() on
the locator statement itself (page.locator('input[type="password"]').fill(process.env.GITLAB_PASS))
or pass a matching -PasswordLocatorPattern.
If GitLab’s PAT UI changed
gitlab-admin-pat.js targets gitlab.com’s current selectors
(/-/user_settings/personal_access_tokens, falling back to the legacy
/-/profile/...). GitLab reworks this UI periodically. Run once with
HEADLESS=0 to watch, adjust the name/scope/created-token selectors, and
re-lint before a real run.