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_PAT can still create a new PAT via the API, use docs/playbooks/gitlab-token-rotation.md (now routed through the sops-invoke.ps1 broker). 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

PiecePathRole
Broker wrapperscripts/secret-broker/Invoke-PlaywrightWithSecret.ps1Lints the JS, delivers the login secret only as a child-process env var, scrubs stdout + trace
Automation JSscripts/rotate/gitlab-admin-pat.jsLogs in, creates the PAT, writes the new token to TOKEN_OUT_FILE, prints only non-secret confirmation
Broker corescripts/lib/sops-common.psm1Invoke-WithSopsSecret, Test-PlaywrightSecretUsage, Protect-SecretInText, Test-SopsSecretRedactionDecrypt-in-process, lint, redact-all-encodings

The four carve-out rules (ADR 003 §Playwright carve-out)

  1. 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 reads process.env.GITLAB_PASS by name only.
  2. Script lint before executionTest-PlaywrightSecretUsage refuses 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. -SkipLint exists but is discouraged and logged.
  3. 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.
  4. 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_FILE for the downstream sops-set.ps1 write.

Prerequisites (on the host with the age key)

  • The GitLab login password must exist as a SOPS key (e.g. GITLAB_LOGIN_PASSWORD in secrets/pinbox24-w4-auth.env.sops). If it does not, that is a secret-manager add — reference the key NAME only, never paste the value; see docs/playbooks/secret-manager-request.md.
  • Node + Playwright + Chromium installed (same as scripts/rotate/*.js flows).
  • SOPS_AGE_KEY_FILE pointing 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_FILE

The 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 Supabase dev_r_services (consistent with playwright-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 in gitlab-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.