Skip to content

docs(runbooks): the connector-secret ring rotation order (append, flip, rewrap, drop) - #1902

Merged
lilyshen0722 merged 2 commits into
mainfrom
kai/ring-rotation-runbook
Sep 25, 2026
Merged

lilyshen0722 merged 2 commits into
mainfrom
kai/ring-rotation-runbook

Conversation

@lilyshen0722

@lilyshen0722 lilyshen0722 commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

What

docs/runbooks/connector-credentials-setup.md gains §5 Rotating the connector-secret key ring, plus a step in §4 pointing at it. Docs only, no code, no values.

The gap, found by connector-ops (74229): the two-phase rotation order exists only in the header of backend/scripts/rewrap-connector-secrets.ts, which nobody opens before editing a Secret Manager value. A rotation that replaces the ring value instead of appending to it drops the old key while rows still name it — those rows then stop decrypting, and the connector that needs one fails at use, not at boot.

What §5 says

  • The rule the order follows from, on one line: the ring value is appended to, never replaced.

  • The four steps, in order: append the new key (leave ACTIVE_KEY alone) → flip ACTIVE_KEY to the new id (old key still in the ring) → rewrap until the script reports remaining under non-active keys: 0 → only then drop the old key.

  • The in-pod invocation, both halves:

    kubectl exec -n <namespace> deploy/backend -- node dist/scripts/rewrap-connector-secrets.js
    kubectl exec -n <namespace> deploy/backend -- node dist/scripts/rewrap-connector-secrets.js --apply
  • Two quiet properties: both env refs are optional: true, so a dropped ring starts the pod with no ring instead of refusing to start; and api-keys maps both at latest, so destroying a version is the one irreversible act.

Verified before writing (not assumed)

Claim Where
rewrap() returns early when a row is already under the active key; rewrites are compare-and-set backend/services/connectorSecrets.ts:164-172
parseKeyRing() refuses an unset var, a duplicate id, or an active id absent from the ring backend/services/connectorSecrets.ts:49-79
node dist/scripts/rewrap-connector-secrets.js exists in the image tsconfig.build.json includes scripts/** (excludes only tests); Dockerfile runs npx tsc -p tsconfig.build.json
Both ring env refs are optional: true k8s/helm/commonly/templates/core/backend-deployment.yaml
api-keys maps both at latest, refreshInterval: 1h k8s/helm/commonly/templates/secrets/api-keys.yaml
Ring format and refusal modes already documented (linked, not repeated) §2.4 of the same page

Gate

@vera — the places to spend scepticism: the four-step order (is there a window where a row is unreadable?), and the dist/scripts/... invocation (does the deployed image really carry it, and is the working directory right for kubectl exec?). Everything else is prose from the code above.

Also note where this sits: the ring is the substrate of the just-merged #1898 (connector_secrets rows keyed by (integrationId, kind)), so a wrong rotation would take those credentials with it — the reason the order is documented rather than left in a script header.


Hold round (Vera 74235) — head moved 5c34249b → 588149db

Vera's hold found the window the first draft walked into, and it is the same question I had asked her to press. If step 3 runs while the pod still holds ACTIVE_KEY=k1, the script's selector { keyId: { $ne: activeKeyId } } matches nothing, so the run prints rewrapped 0; remaining under non-active keys: 0 — the documented acceptance line, satisfied by "nothing needed it" rather than by a completed rewrap. The operator would then drop k1 and the rows naming it become unreadable.

Applied, exactly her list:

  1. Step 2 ends with the pod-side confirmation — printenv CONNECTOR_SECRET_ACTIVE_KEY must read k2 — and says why: until it does, step 3 reports success for that reason. Added with it: that variable is the key id, a label, and CONNECTOR_SECRET_KEYS is never printed.
  2. Step 3's acceptance is two numbers — the dry run's count is the pre-state, and the apply line must read rewrapped <that count>; remaining under non-active keys: 0. A rewrapped 0 against a non-zero dry-run count means the pod is still on the old active id.
  3. The residual she marked non-blocking is folded in (from her 74233 item 1): the dry run selects on keyId and never decrypts, so a row whose key has already left the ring still reads as merely "needing rewrap". It surfaces only when --apply calls rewrap(), which decrypts before it writes (getKey throws ConnectorSecretKeyMissing) — fail-safe, nothing written, but not evidence the old key is present.
  4. The §0.1 contrast she asked for (74233 item 2): the freeze is for a missing remoteRef; an optional: true ref that resolves to nothing is simply absent, which is why this pair goes quiet instead of failing the sync.

Checked against backend/scripts/rewrap-connector-secrets.ts before writing: the { keyId: { $ne: activeKeyId } } selector, the .select('_id keyId integrationId').lean() that never decrypts, both printed strings, and that decrypt is reached only from rewrap. Vera supplied the measurement I could not take: dist/scripts/rewrap-connector-secrets.js is present in the serving pod (2365 bytes), the invocation runs, the dry run prints connector secrets needing rewrap: 0, and ls dist/scripts/no-such-script.js fails as the negative control.

One file, +33/−4 on top of the held head; nothing else moved.

…p, rewrap, drop)

connector-ops found the gap (74229): the two-phase rotation order lives only in
the header of `backend/scripts/rewrap-connector-secrets.ts`, which nobody opens
before editing a Secret Manager value. Add it to the runbook that operators
actually read before touching these values.

New §5 gives the order, the one-line rule the rest follows from — the ring
value is appended to, never replaced — and the in-pod dry-run/`--apply`
invocation. Plus §4 gains a step pointing at it.

Facts read before writing, none assumed:
- `rewrap()` (`connectorSecrets.ts:164-172`) returns early when a row is already
  under the active key and compare-and-sets each rewrite — so re-running is
  safe, and the old key must stay in the ring until the count reaches zero.
- `parseKeyRing()` (`:49-79`) refuses an unset var, a duplicate id, or an active
  id absent from the ring, which is what makes "flip before append" a failure.
- `node dist/scripts/rewrap-connector-secrets.js` is real in the image:
  `tsconfig.build.json` includes `scripts/**` (`include: **/*.ts`, not excluded)
  and the Dockerfile runs `npx tsc -p tsconfig.build.json`.
- Both ring env refs are `optional: true`
  (`templates/core/backend-deployment.yaml`), and `api-keys` maps both at
  `latest` with no `version:` — the two properties that make a dropped ring
  quiet at boot and a destroyed version irreversible.

No code, no values; follows the page's existing `-n <namespace>` convention.
… 2 needs a pod check (Vera's hold)

Vera's hold at `5c34249b` found a window the first draft walked straight into: if
step 3 runs while the pod still holds `ACTIVE_KEY=k1`, its selector
`{ keyId: { $ne: activeKeyId } }` matches nothing, so the run prints
`rewrapped 0; remaining under non-active keys: 0` — the documented acceptance
line, satisfied by "nothing needed it" rather than by a completed rewrap. The
operator would then drop `k1` and the rows naming it become unreadable.

- Step 2 now ends with a pod-side confirmation
  (`printenv CONNECTOR_SECRET_ACTIVE_KEY` must read `k2`) and says why: until it
  does, step 3 reports success for that reason.
- Step 3's acceptance is two numbers: the dry run's count is the pre-state, and
  the apply line must read `rewrapped <that count>; remaining under non-active
  keys: 0`. A `rewrapped 0` against a non-zero count sends the operator back to
  step 2.
- Residual from 74233 folded in: the dry run selects on `keyId` and never
  decrypts, so a row whose key has already left the ring still reads as merely
  "needing rewrap". It surfaces only at `--apply`, which decrypts before it
  writes (`getKey` throws `ConnectorSecretKeyMissing`) — fail-safe, nothing
  written, but not evidence the old key is present.
- §0.1 contrast added as Vera asked: its freeze is for a *missing* `remoteRef`,
  not for an `optional: true` ref that resolves to nothing.

Verified in `rewrap-connector-secrets.ts` before writing: the selector, the
`.select(...).lean()` with no decrypt, both printed strings, and that `rewrap()`
is the only thing that calls `decrypt`. Vera measured the piece I could not —
`dist/scripts/rewrap-connector-secrets.js` is present in the serving pod and the
dry run prints `connector secrets needing rewrap: 0`.
@lilyshen0722
lilyshen0722 added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit e1d7453 Sep 25, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant