diff --git a/.claude/rules/session-protocol.md b/.claude/rules/session-protocol.md index 56220d3..0c2d89e 100644 --- a/.claude/rules/session-protocol.md +++ b/.claude/rules/session-protocol.md @@ -3,6 +3,10 @@ Dev Hub (State Hub API): http://127.0.0.1:8000 MCP server name in `~/.claude.json`: `dev-hub` +**Before production, credential or GitOps work** read +`~/the-custodian/docs/agent-environment-orientation.md`; KeyCape specifics are +in `docs/operations.md`, "Before any live change". + **Step 1 — Orient** Read the offline-safe brief first — it works without a live hub connection: diff --git a/docs/operations.md b/docs/operations.md index 36be767..142fe9a 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -125,12 +125,42 @@ curl -s -H "Host: auth.coulomb.social" \ | jq -r .issuer ``` +## Before any live change + +The estate-wide rules are in +`the-custodian/docs/agent-environment-orientation.md` (revision 2026-09-21); +read it first. The parts that apply to KeyCape's objects in `sso` and `mfa`: + +- The founder's go-ahead comes first, recorded as + `ADMINISTER @ realm:kubernetes/railiance01`, `activation=APPROVED`. Run changes + as `ssh railiance01 '…'`. A harness block is a stop signal, not something to + route around. +- Check CPU headroom before a restart or rollout + (`kubectl describe node | grep -A4 "Allocated resources"`). A rolling update + starts the new pod before stopping the old one. +- Diff first: client-side `kubectl diff`, then `--dry-run=server`. +- **Never `kubectl apply` a Secret, and never read one's metadata.** `apply` + copies the whole Secret into the `last-applied-configuration` annotation, so + even a metadata read prints the signing key and client secrets. Write Secrets + with `kubectl replace`. To test for the annotation without printing it: + `kubectl get secret -n sso -o go-template='{{ if index .metadata.annotations "kubectl.kubernetes.io/last-applied-configuration" }}HAS-ANNOTATION{{ else }}clean{{ end }}'`. + +**Open finding, 2026-09-23:** `sso/keycape-config`, `sso/authelia-secrets`, +`sso/lldap-secrets` and `mfa/privacyidea-config` all carry that annotation. The +earlier rotation script wrote them with `apply`; it now uses `replace`. Anyone +who can `get` these Secrets could read the data anyway, so this is not a new +reader. What it breaks is the assumption that a metadata read is safe. The fix +is a live change and waits for the founder's go-ahead: +`kubectl annotate secret -n kubectl.kubernetes.io/last-applied-configuration-` +for each of the four. It prints no value and leaves `.data` untouched, so pods +need no restart. Re-check with the template above afterwards. + ## Unreleased fail-closed startup changes (read before the next rollout) The deployed image at the time of writing is `sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611`, built -from `dcebd46`. Two changes on `main` postdate it, both affect startup or -issuance, and **both fail closed**, so they land together on the next rollout and +from `dcebd46`. The changes below on `main` postdate it, all affect startup or +issuance, and **all fail closed**, so they land together on the next rollout and a mistake in either presents as a refusal rather than as a warning. **1. Browser clients reject service-identity fields (`74b35b6`, KEY-WP-0028).** @@ -155,7 +185,15 @@ grant today, so nothing changes on rollout; the refusal only becomes reachable once the approver client is registered. See [the tenant contract](tenant-claim-contract.md). -Neither has been exercised against a running issuer. The honest proof for both is +**3. KeyCape enforces login freshness itself (`11ce29a`, KEY-WP-0033).** +`prompt=login` is no longer forwarded to Authelia, because 4.38 refuses it for +every real login. `prompt=login` and `max_age=0` go upstream as `max_age=10`, +and the callback refuses any upstream `auth_time` that misses the requirement, +or is absent (`stale_upstream_authentication`). This changes nothing for clients +that do not ask for freshness. The proof is a completed fresh-login journey, +not a redirect check. + +None of these has been exercised against a running issuer. The honest proof for both is a live boot with the new binary, which belongs to the attended rollout window and not to a session running against production on its own. diff --git a/tools/rotate-live-exposed-secret.sh b/tools/rotate-live-exposed-secret.sh index a534fdb..0dcb542 100755 --- a/tools/rotate-live-exposed-secret.sh +++ b/tools/rotate-live-exposed-secret.sh @@ -283,8 +283,12 @@ sys.stdout.write(updated) kubectl create configmap authelia-config -n sso --from-file=configuration.yml="$work_dir/authelia-configuration.updated.yml" --dry-run=client -o yaml | kubectl apply -f - >/dev/null +# replace, not apply: apply copies the full Secret data into the +# last-applied-configuration annotation, where any metadata read prints it. +# replace writes only the manifest, which also drops an annotation left by an +# earlier apply. kubectl create secret generic keycape-config -n sso --from-file=config.yaml="$work_dir/config.yaml" --from-file=key.pem="$work_dir/key.pem" --dry-run=client -o yaml | - kubectl apply -f - >/dev/null + kubectl replace -f - >/dev/null kubectl rollout restart deployment/authelia deployment/keycape -n sso >/dev/null kubectl rollout status deployment/authelia -n sso --timeout=180s