How to create the first administrator of a fresh d-oauth-server install, how to
replace that bootstrap account immediately, and how to respond to a full
database disclosure. There is no HTTP bootstrap endpoint: provisioning is
CLI/Job only.
Task 48 owns
SECURITY.md; this file is the task-47 operator runbook. The threat model and the replace-immediately rationale live there.
d-oauth-server migrateApply the PII envelope-encryption expansion
(migrations/1791417600_pii_encryption.sql) during a maintenance window with
the app tier quiesced, from the dedicated migration Job:
d-oauth-server migrate
ALTER TABLE ... DROP NOT NULL takes ACCESS EXCLUSIVE on users,
redirect_uris, and oauth_clients, blocking reads and writes on each table for
the duration. Each non-concurrent CREATE INDEX in the file takes SHARE on its
table, blocking writes. A non-transactional CREATE INDEX CONCURRENTLY is not
used because sqlx applies every migration inside one transaction, and
PostgreSQL rejects CONCURRENTLY in a transaction. The migration is additive:
no column is dropped or renamed and the legacy unique constraints are kept, so
version N-1 replicas keep working through the rollout.
Interactive (password read from stdin, never argv, never logged):
d-oauth-server admin bootstrap --email ops@example.com
Non-interactive / cluster Job (mounted secret file):
d-oauth-server admin bootstrap --email ops@example.com \
--password-file /run/secrets/bootstrap-password
When neither a file nor stdin yields a password, the command generates a
256-bit password and prints it exactly once, and only to a TTY. If stdout is
not a TTY the command refuses instead of risking the secret in Job logs:
re-run attached to a terminal or supply --password-file.
A second admin bootstrap refuses while any enabled admin exists:
d-oauth-server admin bootstrap --email second@example.com
# error: already exists: an administrator already exists (1); ...
The override requires explicit --force, prints a loud warning to stderr,
and emits an audited admin.bootstrap.force event. Prefer routine
provisioning instead:
d-oauth-server admin bootstrap --email second@example.com --force
d-oauth-server user create --admin --email named-admin@example.com
The bootstrap account starts with must_change_password=true and
totp_enabled=false. Its first login yields a restricted session that can
only change the password and enroll TOTP — /authorize, /oauth/*, and
admin-gated actions deny it until both steps complete.
/login/change-password.must_change_password and stamps password_changed_at./login/totp/setup and confirm with an authenticator
code. The session is re-issued with full authority only now.d-oauth-server user create --admin --email <name>@example.comd-oauth-server user disable --email ops@example.com. user disable purges the account's Redis sessions, tokens, grants, codes, device records, and lockout counter before flipping the Postgres disabled flag, and fails closed (leaving the user enabled) if Redis is unavailable. Records with no subject (PAR, pending, replay markers, rotation map, TOTP replay, rate-limit counters) are untouched. See docs/OPERATIONS.md./run/secrets/bootstrap-password material and revoke any copy of a
generated one-time password.On the audit target (never carrying secrets): admin.bootstrap.created
and admin.count.changed on success, admin.bootstrap.refused on refusal,
admin.bootstrap.force on a forced override. First-login, password-changed,
and TOTP-enrolled outcomes are emitted by the login flow itself.
migrate seeds zero users
and serve never creates one.Use this when a PostgreSQL dump, backup, or read replica has left your control
on unknown terms. The blast radius and the rationale for the order below are in
docs/SECURITY.md; this is the operator checklist. Preserve
evidence first: capture the audit stream and keep the X-Request-Id
correlation. No step below writes a secret to a log.
D_OAUTH_TOKEN_PEPPER / D_OAUTH_TOKEN_PEPPER_FILE,
D_OAUTH_TOTP_ENCRYPTION_KEY / D_OAUTH_TOTP_ENCRYPTION_KEY_FILE); if it
is not provably untouched, assume it leaked and continue.D_OAUTH_TOKEN_PEPPER (or
D_OAUTH_TOKEN_PEPPER_FILE) at a new value, bump
D_OAUTH_TOKEN_PEPPER_ID, and perform a rolling restart. Dropping the old
id invalidates every live opaque access and refresh token, so clients
re-authenticate.invalid_grant for a token minted before the rotation.D_OAUTH_TOTP_ENCRYPTION_KEY (or D_OAUTH_TOTP_ENCRYPTION_KEY_FILE) at a
new value, bump D_OAUTH_TOTP_KEY_ID, and roll the replicas. Retain the
old key only until every user has re-enrolled.d-oauth-server user enroll-totp --email <email>
(prints the secret and otpauth:// URI exactly once).[ ] 3. Rotate every confidential client secret. Read the current metadata,
then re-submit it through RFC 7592 PUT /register/{client_id} to mint a
new secret and a new registration token:
curl -sS https://<issuer>/register/<client_id> \
-H "Authorization: Bearer <current-registration-access-token>"
curl -sS -X PUT https://<issuer>/register/<client_id> \
-H "Authorization: Bearer <current-registration-access-token>" \
-H 'Content-Type: application/json' \
--data @client-metadata.json
The PUT response carries client_secret and
registration_access_token exactly once. Store both before the process
exits; the previous registration token immediately answers 403.
[ ] 4. Rotate every DCR registration token. Every successful PUT above
already rotated the registration token. If you also need new
registrations after the breach, issue a fresh single-use initial-access
token:
d-oauth-server initial-access-token issue --uses <n> --ttl <seconds>
(the raw token is printed once).
[ ] 5. Verify fail-closed behavior. Readiness must report ready only with
both dependencies up:
d-oauth-server healthcheck --url http://127.0.0.1:8081/health/ready.
A dependency outage during recovery must yield 503, never a fail-open
success.
Every rollout ships the signed ghcr.io/syavne/d-oauth-server image the
supply-chain.yml merge job signed (step Keyless sign (cosign, OIDC))
and the pin-digest job pinned into deploy/k8s/kustomization.yaml
(step Pin kustomization to the built digest + strict lint). Nothing ships
from an unverified local tarball. The full path is in
docs/DEPLOYMENT.md.
pin-digest job committed:
grep -A2 'name: ghcr.io/syavne/d-oauth-server' deploy/k8s/kustomization.yaml.[ ] Verify it before applying (placeholder sha256:<digest> stands for the
value from the previous step; no real digest is recorded here; signatures are
cosign v3 OCI 1.1 referrers, so use a cosign v3+ client):
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp 'supply-chain' \
'ghcr.io/syavne/d-oauth-server@sha256:<digest>'
kustomize build deploy/k8s
python3 scripts/check_manifests.py --strict-digest
kubectl apply --dry-run=server -k deploy/k8s
kubectl apply -k deploy/k8s
[ ] Record the rollout: the shipped sha256:<digest>, the build run that
produced the sbom.spdx.json artifact and trivy.sarif, and the
cosign verify result. A rollout with no matching record is unverified:
stop and re-verify.
[ ] Local-build fallback only (registry unreachable): build with
docker buildx bake image --set image.platform=linux/amd64, sign with
cosign sign --key <local-key-ref>, confirm with
cosign verify --key <local-pub-ref>, then record the local digest as an
explicitly local, non-ghcr.io deploy. Never
docker save | docker load an unsigned image onto a live host.