chore(secrets): rotation policy for the unscheduled secrets; make 'rotate lldap' safe #159

Merged
jfraeys merged 1 commit from jfraeys/chore/secrets-rotation-policy into main 2026-10-14 16:31:39 -04:00
Owner

P8 decision 3 (operator, 2026-10-14). Manifest metadata only; no values.

Policy

  • Scheduled (18): access credentials every 180 days: POSTFIX_SENDER_*_APIKEY (4), ZEDMAIL_API_KEY, TF_VAR_cloudflare_api_token, TF_VAR_desec_api_token, TF_VAR_linode_token, TF_VAR_root_pass, TF_VAR_user_password, OS_PASSWORD, OVH_APPLICATION_SECRET, VAULT_DEPLOY_TOKEN, POSTGRES_PASSWORD, AUTHELIA_LDAP_SERVICE_PASSWORD, CI_EPHEMERAL_ANSIBLE_SSH_KEY, FORGEJO_RUNNER_APP_SKELETON_DEPLOY_KEY; OVH_OBJECT_STORAGE_ACCESS_KEY_ID every 90 (with its secret half).
  • Exposure-only (9), reason recorded in expiry: secrets that encrypt or sign, where rotating means a re-key or invalidation: AUTHELIA_STORAGE_ENCRYPTION_KEY, RESTIC_PASSWORD, AUTHELIA_OIDC_PRIVATE_KEY_PEM, LLDAP_KEY_SEED; identifiers that are not credentials: the four *_HEARTBEAT_URL, RESTIC_SECONDARY_REPOSITORY.
  • Expiry report: unknown 44 -> 32, ok 18 -> 30. The 32 left have a schedule but no recorded rotation date; the rotation order is in #117 (each rotation now records its date through the PR it opens, #151).

./infra rotate lldap made safe (it had never been run):

  • It rotated LLDAP_KEY_SEED, which derives the server key that verifies every stored password (LLDAP docs: the key "used to store the passwords"), so all logins would fail. It now leaves the seed alone.
  • A rotated LLDAP_ADMIN_PASSWORD never reached LLDAP: per LLDAP's docs ldap_user_pass "is only used when initially creating the admin user". The vault and LLDAP would disagree and the role's bootstrap login (tasks/main.yml) would fail. The compose file now sets LLDAP_FORCE_LDAP_USER_PASS_RESET: "always", so the vault is the source of truth (same model as Vaultwarden). A password changed in the LLDAP UI is reset on restart. Staging exercises this: the role logs in with the vault password after the container starts.
  • roles/lldap/CLAUDE.md records both.

Checked: manifest parses, ansible-lint (lldap), yamllint, bash -n, test_secret_expiry, test-rotation-lock, test-bitwarden-save, test-infra-cli.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ge5tovcwdEUBtPwUktsZZZ

P8 decision 3 (operator, 2026-10-14). Manifest metadata only; no values. **Policy** - **Scheduled (18):** access credentials every 180 days: `POSTFIX_SENDER_*_APIKEY` (4), `ZEDMAIL_API_KEY`, `TF_VAR_cloudflare_api_token`, `TF_VAR_desec_api_token`, `TF_VAR_linode_token`, `TF_VAR_root_pass`, `TF_VAR_user_password`, `OS_PASSWORD`, `OVH_APPLICATION_SECRET`, `VAULT_DEPLOY_TOKEN`, `POSTGRES_PASSWORD`, `AUTHELIA_LDAP_SERVICE_PASSWORD`, `CI_EPHEMERAL_ANSIBLE_SSH_KEY`, `FORGEJO_RUNNER_APP_SKELETON_DEPLOY_KEY`; `OVH_OBJECT_STORAGE_ACCESS_KEY_ID` every 90 (with its secret half). - **Exposure-only (9), reason recorded in `expiry`:** secrets that encrypt or sign, where rotating means a re-key or invalidation: `AUTHELIA_STORAGE_ENCRYPTION_KEY`, `RESTIC_PASSWORD`, `AUTHELIA_OIDC_PRIVATE_KEY_PEM`, `LLDAP_KEY_SEED`; identifiers that are not credentials: the four `*_HEARTBEAT_URL`, `RESTIC_SECONDARY_REPOSITORY`. - Expiry report: unknown 44 -> 32, ok 18 -> 30. The 32 left have a schedule but no recorded rotation date; the rotation order is in #117 (each rotation now records its date through the PR it opens, #151). **`./infra rotate lldap` made safe (it had never been run):** - It rotated `LLDAP_KEY_SEED`, which derives the server key that verifies every stored password (LLDAP docs: the key "used to store the passwords"), so all logins would fail. It now leaves the seed alone. - A rotated `LLDAP_ADMIN_PASSWORD` never reached LLDAP: per LLDAP's docs `ldap_user_pass` "is only used when initially creating the admin user". The vault and LLDAP would disagree and the role's bootstrap login (`tasks/main.yml`) would fail. The compose file now sets `LLDAP_FORCE_LDAP_USER_PASS_RESET: "always"`, so the vault is the source of truth (same model as Vaultwarden). A password changed in the LLDAP UI is reset on restart. Staging exercises this: the role logs in with the vault password after the container starts. - `roles/lldap/CLAUDE.md` records both. Checked: manifest parses, ansible-lint (lldap), yamllint, `bash -n`, test_secret_expiry, test-rotation-lock, test-bitwarden-save, test-infra-cli. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Ge5tovcwdEUBtPwUktsZZZ
chore(secrets): rotation policy for the unscheduled secrets; make rotate lldap safe
All checks were successful
Lint / lint (pull_request) Successful in 2m7s
Secret scan / scan (pull_request) Successful in 14s
6d101e75b9
Policy (P8, operator decision 2026-10-14), manifest metadata only:
- 18 access credentials get a schedule: 180 days (Postfix sender keys, Zedmail,
  the TF_VAR_* provider tokens, OS_PASSWORD, OVH_APPLICATION_SECRET,
  VAULT_DEPLOY_TOKEN, POSTGRES_PASSWORD, the LDAP service password, deploy and
  SSH keys); OVH_OBJECT_STORAGE_ACCESS_KEY_ID 90, with its secret half.
- 9 are exposure-only, with the reason in `expiry`: secrets that encrypt or sign
  (AUTHELIA_STORAGE_ENCRYPTION_KEY, RESTIC_PASSWORD, AUTHELIA_OIDC_PRIVATE_KEY_PEM,
  LLDAP_KEY_SEED) and identifiers that are not credentials (heartbeat URLs,
  RESTIC_SECONDARY_REPOSITORY).
Expiry report: unknown 44 -> 32 (the rest have a schedule but no recorded date and
get rotated next), ok 18 -> 30.

`./infra rotate lldap` was unsafe in two ways (never run; LLDAP_KEY_SEED has no
rotation date):
- it rotated LLDAP_KEY_SEED, which derives the server key that verifies every
  stored password: everyone would be locked out. It now leaves it alone.
- a rotated LLDAP_ADMIN_PASSWORD never reached LLDAP, which reads LDAP_USER_PASS
  only when it first creates the admin (LLDAP docs), so the vault and LLDAP would
  disagree and the role's bootstrap login would fail. The compose file now sets
  LLDAP_FORCE_LDAP_USER_PASS_RESET=always: the vault is the source of truth.
roles/lldap/CLAUDE.md records both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ge5tovcwdEUBtPwUktsZZZ
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jfraeys/infra!159
No description provided.