GitLab Token Rotation Playbook

Token types in use

PrefixTypeRotation method
glpa-Personal Access Token (PAT)Tier 1: API or GitLab UI
glcbt-CI Job Token (ephemeral)Automatic — expires when CI job ends; no manual rotation
gldt-Deploy TokenTier 2: GitLab API (DELETE /api/v4/projects/:id/deploy_tokens/:id)
glptt-Project Access TokenTier 1: API (DELETE /api/v4/projects/:id/access_tokens/:id)

Where GitLab credentials live in p24-infra

KeySOPS fileWhere used
GITLAB_ADMIN_PATsecrets/administration.env.sopsAPI operations, CI management

CI clone tokens (glcbt-) are ephemeral — valid only for one CI job run. If found hardcoded in config files, they are almost certainly already expired.

Rotating GITLAB_ADMIN_PAT (glpa- prefix)

Tier check

GITLAB_ADMIN_PAT is a Personal Access Token — Tier 1 (can rotate via GitLab API or UI).

Via GitLab API (preferred) — through the SOPS broker (#5298, ADR 003)

Preferred (Windows dev / secret-manager session): use the sops-invoke.ps1 broker so GITLAB_ADMIN_PAT is decrypted inside the broker’s own process, exposed only as $env:GL_TOKEN for the wrapped command, and redacted out of the command’s output. Never hand-roll a sops -d | grep | cut → $GL_TOKEN → curl extraction — that ad-hoc shape is the exact class behind every historical leak (#2040/#3545/#3714/#5223). See docs/adr/003-sops-secret-access-broker.md.

# Create a new PAT for the same user. The broker holds the token; the command
# references it only as $env:GL_TOKEN and the response is auto-redacted.
.\scripts\sops-invoke.ps1 -SopsFile secrets\administration.env.sops `
  -Keys '{"GL_TOKEN":"GITLAB_ADMIN_PAT"}' `
  -Command 'curl -s -X POST "https://gitlab.com/api/v4/users/<USER_ID>/personal_access_tokens" -H "PRIVATE-TOKEN: $env:GL_TOKEN" -H "Content-Type: application/json" -d ''{"name":"p24-infra-admin-20260803","scopes":["api","read_user","read_repository","write_repository"]}'''
 
# Revoke the old token by ID (same broker pattern):
.\scripts\sops-invoke.ps1 -SopsFile secrets\administration.env.sops `
  -Keys '{"GL_TOKEN":"GITLAB_ADMIN_PAT"}' `
  -Command 'curl -s -X DELETE "https://gitlab.com/api/v4/personal_access_tokens/<OLD_ID>" -H "PRIVATE-TOKEN: $env:GL_TOKEN"'

The new token value is returned by the API only once. When you write it into SOPS, pipe it through sops-set.ps1 ($env:NEW_VALUE) — never print it.

⚠️ Capture and persist in the SAME command (issue #5717). GitLab also exposes a one-call self-rotate endpoint, POST /personal_access_tokens/self/rotate (rotates the calling token, invalidating the old value immediately and returning the new one) — simpler than the create+delete flow above, but the exact same rule applies: capture $resp.token and hand it to sops-set.ps1 inside the same PowerShell call that made the rotate request. Windows tool calls do not retain shell state (env vars, local variables) between separate invocations — a rotate-then-save-later split loses the new value the instant the rotate call’s process exits, with no way to recover it (GitLab shows the value exactly once). This is exactly how GITLAB_ADMIN_PAT got locked out end-to-end on 2026-08-06 (#5717): the rotate call succeeded, only its id/expires_at were printed, and a later call to persist it found the SOPS-stored (pre-rotation) value already invalidated — 401 with no path back in short of a human re-issuing a token from the GitLab UI (creation via API is blocked on GitLab.com SaaS — rotation of an existing token is the only API path in or out). Also use Invoke-RestMethod -Headers @{...} (a typed hashtable) for every GitLab API call in PowerShell — curl -H "..." binds to the Invoke-WebRequest alias, which rejects -H with an exception that embeds the literal expanded header (token included) in its message; see static-api-key-incident-rotation.md’s FORBIDDEN list for the same #5717 finding.

Fallback (bash/Linux workers, until the bash broker sibling lands — ADR 003 §Known gap): the documented safe-extraction pattern is still permitted where the PowerShell broker is unavailable:

GL_TOKEN=$(sops -d --input-type dotenv --output-type dotenv secrets/administration.env.sops | grep '^GITLAB_ADMIN_PAT=' | cut -d= -f2-)
curl -s -H "PRIVATE-TOKEN: $GL_TOKEN" https://gitlab.com/api/v4/user >/dev/null
unset GL_TOKEN

UI-only case: if the API path is unavailable, use the Playwright carve-out — docs/playbooks/gitlab-token-playwright-broker.md.

Distribution chain after rotation

  1. Update secrets/administration.env.sops (GITLAB_ADMIN_PAT) via /role-secret-manager
  2. Confirm no other files reference the old token:
    grep -r "glpa-" secrets/ 2>/dev/null   # should be empty
  3. Update GH Secrets if CI uses this token:
    gh secret set GITLAB_ADMIN_PAT --repo radieu/p24-infra
  4. Log rotation in docs/playbooks/secret-rotation-log.md

Handling exposed glcbt- (CI Job Token) incidents

CI Job Tokens are automatically invalidated when the pipeline job ends. If one was exposed:

  1. Assess timeline: when did the CI job that generated this token run?
    • If the job finished → token is already invalid. Risk = zero.
  2. Check if hardcoded: grep -r 'glcbt-' /etc/ /root/ 2>/dev/null
    • If found: remove from files, restart affected services
  3. Create GH issue with label security + human-action (Tier 3 if bms-1 UI config)
  4. No emergency rotation needed unless token is confirmed still active

Confirming a glcbt- token is still active

# Returns 200 if active, 401/403 if expired
curl -s -o /dev/null -w "%{http_code}" \
  "https://gitlab.com/api/v4/projects/<ID>/repository/branches" \
  -H "PRIVATE-TOKEN: glcbt-6b_..."

Deploy Token rotation (gldt-)

# List deploy tokens
curl -s "https://gitlab.com/api/v4/projects/<ID>/deploy_tokens" \
  -H "PRIVATE-TOKEN: $GITLAB_ADMIN_PAT"
 
# Revoke old
curl -s -X DELETE "https://gitlab.com/api/v4/projects/<ID>/deploy_tokens/<TOKEN_ID>" \
  -H "PRIVATE-TOKEN: $GITLAB_ADMIN_PAT"
 
# Create new
curl -s -X POST "https://gitlab.com/api/v4/projects/<ID>/deploy_tokens" \
  -H "PRIVATE-TOKEN: $GITLAB_ADMIN_PAT" \
  -H "Content-Type: application/json" \
  -d '{"name":"deploy-20260701","scopes":["read_repository"]}'

Escalation

If GITLAB_ADMIN_PAT itself is expired or revoked:

  1. Log into gitlab.com with the radieu account (human)
  2. Create new PAT at: User settings → Access Tokens
  3. Update SOPS via /role-secret-manager

Audit Log — Log to infra_operations

After this operation completes, log it to the infra_operations audit table.

Python (Linux server — bms-4, vps-i1, vps-h1, or similar):

import sys
sys.path.insert(0, '/opt/p24-infra')
from scripts.lib.log_op import log_op
 
log_op(
    actor="claude",  # "radieu" for manual human ops, "claude" for agent
    op_type="credential_rotation",
    resource="GITLAB_TOKEN",
    result="success",  # "success" | "failed" | "skipped"
    detail="Scheduled rotation — GitLab access token regenerated and SOPS updated",
    env="bms-4",
    gh_issue=2730,
)

PowerShell (Windows dev machine):

$env:SUPABASE_URL = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_URL=").ToString().Split("=",2)[1].Trim()
$env:SUPABASE_SERVICE_KEY = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_SERVICE_KEY=").ToString().Split("=",2)[1].Trim()
python -c "
import os, sys
sys.path.insert(0, 'C:/code_2026/p24-infra')
from scripts.lib.log_op import log_op
log_op('claude', 'credential_rotation', 'GITLAB_TOKEN', 'success', 'Scheduled rotation — GitLab access token regenerated and SOPS updated', 'bms-4')
"
$env:SUPABASE_URL = ''; $env:SUPABASE_SERVICE_KEY = ''