Secret and Certificate Rotation¶
Status: Procedures below match the behaviour of the current Helm chart and gateway code. None of them have been exercised end-to-end on a real production cluster yet -- rotation is the most common cause of self-inflicted outages in cloud-native systems, so drill each runbook on a test cluster (and record the drill date in the "Last drilled" column) before relying on it under pressure.
Step-by-step runbooks for rotating every long-lived credential and certificate in a Cloud-Native DCS deployment. Each runbook is written as a graceful (zero or near-zero downtime) path, with an emergency branch when a credential is suspected compromised.
Commands assume the default install from
Deploy Your Own (helm install dcs … --namespace
dcs-system), which names chart-managed resources with the
dcs-cloud-native-dcs prefix (written <release>-… where this doc speaks
generically). Substitute your own release namespace and fullname prefix if
they differ.
What Needs Rotation¶
| Credential | How it is issued | Default lifetime | Graceful path | Last drilled |
|---|---|---|---|---|
| mTLS component certs (gateway, operators, io-probe, historian) | cert-manager, signed by the in-cluster CA ClusterIssuer |
mtls.certManager.duration = 24h, renewed at 8h remaining |
Automatic (cert-manager; pkg/tlsutil hot-reloads in place, no pod restart) |
2026-04-21 |
mTLS runtime cert (per site-<name> namespace) |
cert-manager Certificate created by the Site reconciler in each site namespace, signed by the CA ClusterIssuer |
24h, renewed at 8h remaining | Automatic (cert-manager rotates the Secret; Unit reconciler watches it and recreates the runtime Pod when resourceVersion changes — see Runtime cert drill) |
2026-04-21 (partial — no runtime Pod on drill cluster) |
| mTLS CA certificate | cert-manager self-signed Certificate |
8760h / 1 year, renewed at 720h remaining | Manual re-issue + cmctl renew --all (every renewal changes CA identity under cert-manager 1.18+ defaults) |
2026-04-21 |
| MQTT broker TLS cert | cert-manager, signed by the mTLS CA | ~90d (the mqtt.tls.certManager default; component certs use 24h) | Automatic (cert-manager) + broker rolling restart (mosquitto does not hot-reload) | 2026-04-21 |
| MQTT client passwords (gateway, operator, runtime, historian, omf-egress) | Helm-generated Opaque Secret <release>-mqtt-auth |
Indefinite | Credential slots (#1579, ADR 0061): three helm upgrades — open the standby account, flip activeSlot, retire the account you left. No client is ever locked out, and the broker never restarts. Unit runtimes are the exception: their pods are recreated by hand, at a moment of your choosing. See MQTT Client Passwords |
2026-04-21 (non-graceful); 2026-08-14 (#221 window drilled, found broken, #1579); 2026-08-15 (slot window drilled end to end, #1579) |
| Unit runtime bearer token | Helm-generated Opaque Secret <release>-runtime-auth |
Indefinite | helm upgrade + manual kubectl delete pod of runtime pods (bare Pods, so rollout restart deploy does not touch them) |
2026-04-21 (partial — no runtime Pod on drill cluster) |
| HMAC signing key (e-signatures / audit) | Versioned Secret dcs-signing-key in kube-system (auto-generated v1 on first startup) |
Annual | kubectl patch to add new key version + gateway restart. Historical signatures remain verifiable via key-ID chaining. See HMAC procedure |
2026-04-21 |
LDAP bind password (gateway.auth.ldap.bindPasswordSecret) |
External directory | Indefinite | helm upgrade + gateway pod restart |
not yet |
OMF endpoint credential (omfEgress.auth) |
The customer's PI System, or whoever administers the OMF endpoint | Indefinite | Update the Secret + restart the egress pod. No dual-credential window, and the overlap would have to exist in the PI Server's own account store — see OMF Endpoint Credential | not yet |
| Historian DB password | CNPG operator-managed Secret | Indefinite | CNPG kubectl cnpg restart + password rotation |
not yet |
| Cluster node join credentials | Deployment layer (Talos machine config, k3s token, …) | Distro-defined | Deployment-layer concern (ADR 0004), out of product scope | n/a |
Lifetime note: The runbook tables that used to claim 90-day mTLS lifetimes were wrong. With the shipped defaults, component certs are short-lived (24h) precisely so rotation exercises its own code path every day. A silent cert-manager failure surfaces within hours instead of quarters.
Rotation Schedule¶
| Event | Action |
|---|---|
| Every 24h (automatic) | cert-manager renews component mTLS certs at 8h remaining. Release-namespace Deployments (gateway, operators, io-probe, historian) hot-reload the cert files in place via pkg/tlsutil. Per-site runtime Pods are bare Pods, so the Unit reconciler recreates them when it sees the Secret resourceVersion change (dcs.io/runtime-mtls-rv annotation). Nothing to do if monitoring is green. |
| Annually (automatic, 30 days before CA expiry) | cert-manager renews the mTLS CA cert. Every renewal changes the CA identity (new private key under cert-manager 1.18+ defaults), so this is not hands-off: run cmctl renew -n dcs-system --all immediately after any CA renewal so every component's trust store picks up the new CA. See mTLS CA Certificate. |
| Quarterly | Drill one credential from the "Graceful path" column above that isn't on cert-manager autopilot. Rotate MQTT passwords and the runtime bearer token at least once per quarter. MQTT passwords no longer need a quiet window for the release-namespace clients (#1579), but the unit-runtime step still does: the runtime pods are recreated by hand, and a runtime restart puts a Running batch on Hold. Plan that step for a unit that is idle. |
| Annually | Rotate the gateway HMAC signing key (see caveats) and review the LDAP bind password if in LDAP mode. |
| Immediately on suspected compromise | Any credential: follow the emergency branch of the relevant runbook. |
Detection and Monitoring¶
The rotation runbooks are only useful if someone notices a certificate is
drifting towards expiry before it expires. The chart ships with
DCS-authored PrometheusRule alerts in
deploy/helm/cloud-native-dcs/templates/prometheusrule.yaml
that cover the cert-expiry signal end-to-end:
| Alert | Expression | Threshold | Severity | Intended routing |
|---|---|---|---|---|
DCSCertExpiryWarning |
dcs_cert_expiry_seconds - time() in [8h, 48h) for 15m |
< 48h remaining | warning |
Ticket — cert-manager still has ~8h before it normally renews; investigate next business day unless the 8h renewal also fails. |
DCSCertExpiryCritical |
dcs_cert_expiry_seconds - time() in [0, 8h) for 5m |
< 8h remaining | critical |
Page — cert-manager's renewal window has passed without renewal; mTLS will break at NotAfter. |
Both alerts fire off the DCS-emitted dcs_cert_expiry_seconds gauge
(pkg/tlsutil/certexpiry.go). The gauge reads the actual cert file
mounted in the container, which can disagree with what cert-manager
reported to the API server. That disagreement is the 2026-04-19 failure
mode (cert rotated in etcd, but kubelet's cached Secret volume was
stale), and a cert-manager-native alert would have missed it entirely.
Complementary upstream signals
(certmanager_certificate_ready_status,
certmanager_certificate_expiration_timestamp_seconds) are still worth
forwarding if cert-manager itself is in the same monitoring stack. They
alert on cert-manager health, while the DCS alerts above alert on
workload impact.
Grafana dashboards: the shipped dashboards in grafana/dashboards/
are batch- and audit-focused (no cert-expiry panels as of this writing).
Operators who want an at-a-glance cert-expiry view should add a custom
panel using dcs_cert_expiry_seconds - time() grouped by
{component, host, file}. File an issue if the out-of-the-box
dashboards should grow a cert-health row. It is a small addition
against the existing metric.
Log-side signal: a failed renewal usually shows up as
x509: certificate has expired or is not yet valid on the client side
before the server-side alert fires. Forward these log lines to your SIEM
and alert on any occurrence from the DCS namespaces.
Verification After Rotation¶
Every runbook ends with this checklist. Do not mark a rotation complete until every box is ticked.
- [ ] All pods using the rotated credential are
RunningandReady. - [ ] No TLS handshake or auth errors in gateway, operator, or runtime logs since the rotation started.
- [ ]
dcs healthreports all components healthy. - [ ] A canary batch runs to completion against a simulated unit.
- [ ] The AuditRecord trail for the rotation window is present and signatures verify (see HMAC caveat).
- [ ] The old credential is fully decommissioned -- Secret data removed, broker ACL entry deleted, or identity provider client deleted.
- [ ] Rotation drill logged with date, operator, and outcome.
mTLS Component Certificate (Graceful)¶
Applies to release-namespace Deployments: gateway, the operators, io-probe, and historian. Runtime pods follow a different path, covered in Runtime cert drill below.
cert-manager renews each component certificate automatically at
renewBefore of its expiry (mtls.certManager.renewBefore, default 8h).
The human-driven drill exists to prove that the rotation path works.
The rotation itself already happens on its own.
# 1. Verify cert-manager is healthy.
kubectl get pods -n cert-manager
kubectl get certificate -n dcs-system \
-l app.kubernetes.io/part-of=cloud-native-dcs
# 2. Pick one component and force a renewal.
cmctl renew -n dcs-system dcs-cloud-native-dcs-gateway-mtls
# 3. Watch the Secret `dcs-cloud-native-dcs-gateway-mtls` get rewritten and the
# gateway pod pick up the new cert on its next poll.
kubectl get secret -n dcs-system dcs-cloud-native-dcs-gateway-mtls \
-o jsonpath='{.metadata.resourceVersion}'
# 4. Confirm no TLS errors and that active sessions still work.
kubectl logs -n dcs-system deploy/dcs-cloud-native-dcs-gateway --since=5m | grep -i tls
dcs health
Deployments in the release namespace pick up the new cert without a pod
restart because pkg/tlsutil hot-reloads the mounted Secret files. If you
see x509 errors on any of these clients, something has gone wrong.
Fall through to the emergency branch.
mTLS Runtime Certificate (Per Site)¶
Runtime pods run in per-site namespaces (site-<name>), outside the
release namespace. Each site has its own cert-manager.io/Certificate,
created by the Site reconciler
(internal/controller/physical/site_controller.go) and signed by the same
CA ClusterIssuer as the release-namespace components. cert-manager owns
rotation natively per site. There is no copy-from-dcs-system step.
Runtime pods are bare Pods (not Deployments), so neither Stakater Reloader
nor the operator's existing spec-drift check picks up a Secret rotation.
Instead, the Unit reconciler stamps the Secret's resourceVersion on each
pod as the dcs.io/runtime-mtls-rv annotation at create time, and
Watches() the Secret. On change, the reconciler deletes and recreates the
pod so the fresh cert is mounted.
Drill path:
# 1. Pick a site and confirm its Certificate is Ready.
SITE=site-plant-01 # replace with any existing site-<name>
kubectl get certificate -n "$SITE"
kubectl get secret -n "$SITE" dcs-cloud-native-dcs-runtime-mtls \
-o jsonpath='{.metadata.resourceVersion}'
# 2. Capture the current annotation on a runtime pod in that site.
POD=$(kubectl get pod -n "$SITE" -l dcs.io/component=unit-runtime \
-o jsonpath='{.items[0].metadata.name}')
kubectl get pod -n "$SITE" "$POD" \
-o jsonpath='{.metadata.annotations.dcs\.io/runtime-mtls-rv}'
# 3. Force cert-manager to rotate the site's runtime cert.
cmctl renew -n "$SITE" dcs-cloud-native-dcs-runtime-mtls
# 4. Within seconds, the Unit reconciler should delete and recreate the
# pod. Watch for the new pod to reach Ready, and confirm its annotation
# matches the new Secret's resourceVersion.
kubectl get pod -n "$SITE" -l dcs.io/component=unit-runtime -w
kubectl get secret -n "$SITE" dcs-cloud-native-dcs-runtime-mtls \
-o jsonpath='{.metadata.resourceVersion}'
kubectl get pod -n "$SITE" -l dcs.io/component=unit-runtime \
-o jsonpath='{.items[0].metadata.annotations.dcs\.io/runtime-mtls-rv}'
# 5. Confirm no x509 errors from the operators talking to the new pod.
kubectl logs -n dcs-system deploy/dcs-cloud-native-dcs-procedural-operator --since=5m \
| grep -iE 'x509|tls'
dcs health
What to watch for:
- In-flight batches should tolerate the short pod-recreate window (the runtime replays persisted FB state from hostPath on startup). If a batch faults or goes to Hold during the drill, file a bug. The recreate flow is supposed to be transparent.
- Pre-existing pods without the
dcs.io/runtime-mtls-rvannotation are deliberately left alone by the reconciler so that operator upgrades don't thrash every runtime pod at once. In a real rotation, those pods need a one-time manualkubectl delete podto opt into the watcher. Note this in the drill report if any pods predate the annotation.
mTLS Component Certificate (Emergency)¶
Use this when cert-manager itself has failed or a key is suspected compromised.
# 1. Find which cert-manager component is unhealthy.
kubectl get pods -n cert-manager
kubectl logs -n cert-manager deploy/cert-manager --since=15m
# 2. If cert-manager is recoverable, force renewal and let it heal.
cmctl renew -n dcs-system --all
# 3. If cert-manager is down and a cert is expired, manually issue from
# the CA Secret. (This is a break-glass path; prefer fixing cert-manager.)
kubectl get secret -n dcs-system dcs-cloud-native-dcs-mtls-ca \
-o jsonpath='{.data.tls\.crt}' | base64 -d > ca.crt
kubectl get secret -n dcs-system dcs-cloud-native-dcs-mtls-ca \
-o jsonpath='{.data.tls\.key}' | base64 -d > ca.key
# Use openssl or cfssl to re-issue the component cert against ca.crt/ca.key
# with the same dnsNames from the Certificate CR.
# 4. Patch the component Secret with the new cert+key.
kubectl create secret tls dcs-cloud-native-dcs-gateway-mtls \
--cert=new.crt --key=new.key --dry-run=client -o yaml \
| kubectl apply -n dcs-system -f -
# 5. Restart the component to force a clean reload.
kubectl rollout restart deploy/dcs-cloud-native-dcs-gateway -n dcs-system
kubectl rollout status deploy/dcs-cloud-native-dcs-gateway -n dcs-system
After recovery: file an incident ticket on the cert-manager failure mode. Do not leave the cluster running on break-glass certs. Re-issue via cert-manager as soon as it is healthy.
mTLS CA Certificate¶
The in-cluster CA certificate has a 1-year lifetime. cert-manager renews it 30 days before expiry.
Important — CA identity changes on every renewal. Under cert-manager 1.18+ defaults (
privateKey.rotationPolicy: Always), every CA renewal generates a new private key and therefore a new CA identity (fingerprint + serial change) even though the Subject CN stays the same. Verified on 2026-04-21: pre-renewal fingerprintDA:5A:41:BB…→ post-renewal63:E1:A7:42…. The earlier version of this runbook claimed "the CA identity stays the same"; that is not true with current cert-manager defaults.This matters because each component's mTLS Secret carries a copy of the CA cert under
ca.crt(used as the trust store). The Secret is not automatically refreshed when the CA rotates — it is only rewritten when cert-manager next issues that component's own certificate. So there is a window (up to 24h with default componentduration=24h, renewBefore=8h) where some components trust only the old CA while others present certs signed by the new CA — a silent cross-component mTLS break.
After any CA renewal (automatic or manual) you must force every component cert to be re-issued so every trust store updates:
cmctl renew -n dcs-system --all
Wait ~30 seconds. Every component's Secret ca.crt will match the new
CA. Verify:
LIVE=$(kubectl -n dcs-system get secret dcs-cloud-native-dcs-mtls-ca \
-o jsonpath='{.data.tls\.crt}' | base64 -d | \
openssl x509 -noout -fingerprint -sha256 | cut -d= -f2)
for c in gateway physical-operator procedural-operator control-operator io-probe historian; do
FP=$(kubectl -n dcs-system get secret dcs-cloud-native-dcs-$c-mtls \
-o jsonpath='{.data.ca\.crt}' | base64 -d | \
openssl x509 -noout -fingerprint -sha256 | cut -d= -f2)
[ "$FP" = "$LIVE" ] && echo "$c: in-sync" || echo "$c: STALE"
done
If you need to replace the CA with a different identity (e.g. compromise or a switch to an organisational PKI):
- Create the new
ClusterIssuerorIssueryou want to use. helm upgrade --set mtls.certManager.issuerRef.name=<new-issuer> \ --set mtls.certManager.issuerRef.kind=ClusterIssuer.- Force renewal of every component certificate:
cmctl renew -n dcs-system --all(same step as above). - Roll every deployment that has cached the old CA bundle:
kubectl rollout restart deploy,ds,sts -n dcs-system. - Delete the old auto-created Issuer and CA Secret only after you have confirmed no component is still presenting the old CA.
This is effectively a full re-keying -- exercise it on a test cluster before running it in production.
MQTT Broker TLS Certificate¶
The broker cert is cert-manager managed (mqtt.tls.certManager.enabled,
issued by the mTLS CA by default). The rotation path is identical to the
mTLS component flow above -- cmctl renew -n dcs-system
dcs-cloud-native-dcs-mqtt-tls, then restart Mosquitto so it reloads the cert:
kubectl rollout restart deploy/dcs-cloud-native-dcs-mqtt -n dcs-system # HA: sts/dcs-cloud-native-dcs-mqtt
Clients reconnect automatically. The gateway, operators, runtimes, and historian use persistent MQTT connections with auto-reconnect.
MQTT Client Passwords¶
The Helm chart gives each MQTT client identity a broker account in the Secret
<release>-mqtt-auth, keyed by account name. Mosquitto loads them from the
rendered passwd file at startup. The broker pod runs a passwd-reloader
sidecar that detects Secret changes, rewrites the hashed passwd file, and
SIGHUPs mosquitto in place. A password change therefore never restarts the
broker.
Each identity has up to two accounts, its credential slots
(ADR 0061). Slot a is the
base name, slot b is that name with a -b suffix, and both carry the same ACL
rules. A release with no rotation in flight has slot a only.
| Identity (values key) | Slot a account |
Slot b account |
|---|---|---|
gateway |
dcs-gateway |
dcs-gateway-b |
operator |
dcs-operator |
dcs-operator-b |
runtime |
dcs-runtime |
dcs-runtime-b |
historian |
dcs-historian |
dcs-historian-b |
omfEgress |
dcs-omf-egress |
dcs-omf-egress-b |
Mosquitto's builtin backend keys credentials on the username, so two accounts
are what makes a window. Two passwords for one username is what the chart used
to write, and it either swapped the accepted credential or killed the broker.
That is #1579,
and mqtt.auth.users.*.rotationPassword is refused at render time.
Rotation is three helm upgrades. They can be minutes or days apart. What
matters is that step 3 happens, because the old credential works until it does.
# Step 1 — OPEN the window. The dcs-gateway-b account appears with the same ACL
# rules as dcs-gateway; the reloader sidecar rehashes and SIGHUPs the broker in
# place. Nothing restarts, and no client changes credential.
NEW_GW=$(openssl rand -base64 24 | tr -d '=+/')
helm upgrade dcs deploy/helm/cloud-native-dcs --reuse-values \
--set-string mqtt.auth.users.gateway.passwordB="$NEW_GW"
# Confirm the reload landed and the account is there:
kubectl logs -n dcs-system deploy/dcs-cloud-native-dcs-mqtt -c passwd-reloader | tail -1
# passwd-reloader: mqtt-auth changed, rehashing and SIGHUP mosquitto
kubectl exec -n dcs-system deploy/dcs-cloud-native-dcs-mqtt -c mosquitto -- \
cut -d: -f1 /mosquitto/auth/passwd
# dcs-gateway
# dcs-gateway-b
# …
Wait for the account to appear before flipping. The raw credentials reach the broker through a mounted Secret, so kubelet's sync period (up to a minute) and then the reloader's 10s poll sit between the
helm upgradeand the account existing. A flip that overtakes them rolls the clients onto an account the broker has not loaded yet, and they are refused until it catches up. Thecut -d: -f1check above is the gate, and it is the whole reason step 1 and step 2 are separate upgrades.
# Step 2 — FLIP. Only the gateway's pods roll, and they roll onto dcs-gateway-b.
# Both accounts are served throughout, so a pod that has not rolled yet keeps
# working.
helm upgrade dcs deploy/helm/cloud-native-dcs --reuse-values \
--set-string mqtt.auth.users.gateway.activeSlot=b
kubectl rollout status -n dcs-system deploy/dcs-cloud-native-dcs-gateway
kubectl logs -n dcs-system deploy/dcs-cloud-native-dcs-gateway | grep -i mqtt | tail -3
# MQTT connection established (and no "bad user name or password")
# Step 3 — RETIRE. The old account stops being served. Until this runs, the
# credential you are rotating away from still authenticates.
helm upgrade dcs deploy/helm/cloud-native-dcs --reuse-values \
--set-string mqtt.auth.users.gateway.retireSlot=a
The next rotation runs the same three steps in the other direction: set
password (slot a), flip activeSlot back to a, then retireSlot=b.
Clear retireSlot in the step that flips, since the chart refuses a
retireSlot that names the active slot.
Retiring is not optional, and the chart cannot enforce it. A slot you leave standing keeps the password it was serving. Flipping back onto it later therefore re-adopts a credential that was already in use, and nothing is rotated. Render-time values say nothing about which slot was active before, so this is a hazard the procedure carries and not one
helmcan catch.
Unit runtimes are the one manual step. Their pods are bare Pods written by
the physical operator, so rollout restart does not touch them and the operator
deliberately leaves running ones alone: recreating a runtime pod puts any batch
that pod is executing on Hold. Flip the runtime slot, then delete the pods when
each unit is idle.
# Step 2 for the runtime identity: the flip reaches new pods only.
helm upgrade dcs deploy/helm/cloud-native-dcs --reuse-values \
--set-string mqtt.auth.users.runtime.passwordB="$(openssl rand -base64 24 | tr -d '=+/')" \
--set-string mqtt.auth.users.runtime.activeSlot=b
# Then, per site, at a moment the units are idle:
for ns in $(kubectl get ns -l dcs.io/site -o name); do
kubectl delete pod -n "${ns#namespace/}" -l dcs.io/component=unit-runtime
done
# Only once every runtime pod has been recreated:
helm upgrade dcs deploy/helm/cloud-native-dcs --reuse-values \
--set-string mqtt.auth.users.runtime.retireSlot=a
Emergency (compromise suspected): the compromised credential has to stop working now, so the window is the wrong shape. Set the identity's active-slot password directly and accept the disconnect.
helm upgrade dcs deploy/helm/cloud-native-dcs --reuse-values \
--set-string mqtt.auth.users.gateway.password="$(openssl rand -base64 24 | tr -d '=+/')"
That rolls the identity's pods on the new credential with no overlap, which is roughly 40 seconds of MQTT disconnect for that client. If the runtime identity is the compromised one, any batch in Running goes to Hold on the runtime side and an operator has to Resume it.
With mqtt.auth.existingSecret, the Secret is yours to key by account name.
The chart still reads activeSlot to decide which key each client mounts, and
it cannot check that the key or its ACL rules are there. Add the -b key and a
matching ACL block before flipping.
Unit Runtime Bearer Token¶
The runtime-facing REST API (port 61152) authenticates with a bearer
token in the <release>-runtime-auth Secret (key token). Every
unit-runtime pod mounts the same token.
# 1. Generate a new token and push it through helm.
NEW_TOKEN=$(openssl rand -hex 32)
helm upgrade dcs deploy/helm/cloud-native-dcs \
--reuse-values \
--set-string unitRuntime.auth.token="$NEW_TOKEN"
# 2. Delete every unit-runtime pod so the Unit reconciler recreates it
# with the new token (bare Pods — rollout restart does not touch them,
# see the note below).
for ns in $(kubectl get ns -l dcs.io/site -o name); do
kubectl delete pod -n "${ns#namespace/}" -l dcs.io/component=unit-runtime
done
The operator clients (physical, procedural, control) read the same
<release>-runtime-auth Secret. They pick up the new token on their
next reconcile without a restart. If they don't, restart them:
kubectl rollout restart deploy/dcs-cloud-native-dcs-physical-operator \
deploy/dcs-cloud-native-dcs-procedural-operator deploy/dcs-cloud-native-dcs-control-operator \
-n dcs-system
Runtime pods are bare Pods, not Deployments. The
kubectl rollout restart deploy -n site-<name>command that used to be in this section does not restart the runtime pods themselves — they are created directly by the Unit reconciler as standalone Pods, and Kubernetesrolloutonly operates on workload controllers (Deployment / StatefulSet / DaemonSet). Unlike the runtime mTLS rotation (where the Unit reconciler watches the Secret and recreates Pods via thedcs.io/runtime-mtls-rvannotation — see #211), theruntime-authSecret has no such watcher. To deliver the new bearer token to a runtime Pod you must manually delete it:for ns in $(kubectl get ns -l dcs.io/site -o name); do kubectl -n "${ns#namespace/}" delete pod \ -l dcs.io/component=unit-runtime doneThe Unit reconciler will immediately recreate the deleted Pods with the new token injected as an env var. Surfaced during the #168 drill on 2026-04-21.
HMAC Signing Key¶
The gateway stores the HMAC-SHA256 signing key(s) for e-signatures and
audit records in a versioned Kubernetes Secret: dcs-signing-key in
kube-system. Each key version is stored under key.<id> (e.g.
key.v1, key.v2), and a current field names the active signing key.
The gateway auto-generates the first key (v1) on startup.
Key-ID chaining ensures historical signatures remain verifiable after
rotation: each ElectronicSignature and BatchRecordFinalization carries
a keyID field that identifies which key version was used. The verifier
selects the matching key from the store. Legacy signatures (created before
key-ID chaining) have an empty keyID, and the verifier falls back to
trying every key in the store for them.
Session tokens are chained the same way. The gateway mints a session JWT
under the current key and stamps that version into the token's kid
header, then verifies each token against the version it names. A rotation
therefore leaves signed-in users signed in: their tokens were issued under
a key the store still holds, and they expire on their own schedule.
Removing an old key version does end the sessions it signed, which is one
more reason step 1 below says to retain old keys.
Rotation procedure¶
- Generate a new key and add it to the Secret alongside the existing
key(s). Do not remove old keys.
# Generate a 32-byte random key. NEW_KEY=$(openssl rand 32 | base64) # Patch the Secret to add the new key version and set it as current. kubectl patch secret dcs-signing-key -n kube-system --type merge \ -p "{\"data\":{\"key.v2\":\"$NEW_KEY\",\"current\":\"$(echo -n v2 | base64)\"}}" - Restart the gateway to pick up the new key store:
kubectl rollout restart deploy/dcs-cloud-native-dcs-gateway -n dcs-system
Signed-in users stay signed in, as long as step 1 was followed and the old key versions are still in the Secret. Tokens issued under the previous version name it in their
kidheader, the restarted gateway still holds that key, and those sessions run to their normal expiry. Remove an old key version and the sessions it signed end at the next restart, with browser sessions bouncing to the login page and CLI sessions needingdcs loginagain.
- Verify that every historical signature still resolves to a key:
All records should show
dcs audit verifyPASSorWARN(legacy, no keyID). If any showFAIL, the Secret is misconfigured. Check that the old key data was not modified.
What this command proves. For active records it checks that each signature's
keyIDis present in the key store. It does not recompute the digest, because the AuditRecord does not carry the payload that was signed. That is exactly the check this step needs — it catches the one mistake rotation can make, which is dropping a key version that existing records still reference — but it is not a cryptographic re-verification of the signatures themselves. The archived path (dcs audit verify --archived) does recompute digests, because the archive stores the covered records. 4. Test a new signature: approve a draft recipe or finalize a batch record and confirm the AuditRecord carries the newkeyID.
Compromised key procedure¶
If a specific key version is suspected compromised:
- Add a new key version (as above) and set it as
current. - Do not remove the compromised key from the Secret — it is still needed to verify historical records signed before the compromise.
- Document the compromise window in a compliance note (AuditRecord).
- Run
dcs audit verifyto confirm all signatures remain structurally valid.
Secret format reference¶
apiVersion: v1
kind: Secret
metadata:
name: dcs-signing-key
namespace: kube-system
data:
current: djI= # base64("v2") — active signing key ID
key.v1: <base64> # original key (32 bytes)
key.v2: <base64> # rotated key (32 bytes)
Legacy format (single key field) is auto-detected and treated as
keyID="v1". No migration is required.
Gateway Setup Token (retired)¶
gateway.auth.setupToken was the pre-shared secret that unlocked
/api/v1/setup* before OIDC was live. The setup endpoints and the token
mechanism were removed in June 2026 together with the in-product k3s join
brokering (ADR 0004, #487). There is no setup-token credential to rotate
on current versions. The 2026-04-21 drill entry below is retained as a
historical record.
LDAP Bind Password (LDAP Mode Only)¶
When the gateway is in auth.mode: ldap, the bind password lives in the
Secret named by gateway.auth.ldap.bindPasswordSecret, key password.
# 1. Rotate the password in your directory.
# 2. Update the Secret.
kubectl create secret generic <secret-name> \
--from-literal=password="<new-password>" \
--dry-run=client -o yaml \
| kubectl apply -n dcs-system -f -
# 3. Restart the gateway so the new password is read on startup.
kubectl rollout restart deploy/dcs-cloud-native-dcs-gateway -n dcs-system
# 4. Verify a user can still log in.
The bind password is read once, from the LDAP_BIND_PASSWORD environment
variable, when the gateway process starts. Step 3 is therefore load-bearing:
updating the Secret alone changes nothing until the pod restarts.
Between step 1 and the end of step 3 the gateway is binding with a password the directory no longer accepts, so no one can sign in. Keep the old password valid in the directory until the restart completes if your directory allows two, or plan the window.
Logins during that gap fail in a way an operator can act on. A failed
service-account bind means the directory is unreachable, and it proves
nothing about anyone's credential. The gateway therefore answers 503
with authentication backend unavailable, where a wrong user password
draws 401, and the attempt does not count toward the SR 1.11
consecutive-failure lockout. An operator who
sees that message should stop retrying their own password and check this
runbook's step 3.
OMF Endpoint Credential¶
The northbound OMF egress authenticates to a host somebody else administers,
usually a PI Web API server. The credential is HTTP Basic by default and lives
in the Secret named by omfEgress.auth.existingSecret, keys username and
password. A deployment that set omfEgress.auth.username and
omfEgress.auth.password inline instead is holding the credential in the Helm
release. For that deployment, step 2 below is a helm upgrade.
The old and new values cannot be made to overlap on our side, because the overlap would have to exist in the PI Server's own account store. Whether it can is the PI administrator's answer to give. The MQTT client passwords used to have the same problem, and that one was ours: it is fixed by the credential slots above (#1579).
# 1. Rotate the credential in the PI System (or wherever the OMF endpoint
# authenticates). Keep the old one valid until step 3 if that is possible.
# 2. Update the Secret the egress reads.
kubectl create secret generic pi-omf-credential \
--from-literal=username="<user>" \
--from-literal=password="<new-password>" \
--dry-run=client -o yaml \
| kubectl apply -n dcs-system -f -
# 3. Restart the egress so the new credential is read on startup.
kubectl rollout restart deploy/dcs-cloud-native-dcs-omf-egress -n dcs-system
# 4. Confirm the endpoint is accepting again. The readout that matters is
# the metric, not the pod — a pod whose credential is refused is Ready.
kubectl port-forward -n dcs-system deploy/dcs-cloud-native-dcs-omf-egress 8096:8096 &
curl -s localhost:8096/metrics | grep dcs_omf_egress
# dcs_omf_egress_endpoint_up back to 1
# dcs_omf_egress_queue_depth falling
# dcs_omf_egress_dropped_total not advancing
The credential is read once from the environment when the process starts, so step 3 is load-bearing. Updating the Secret alone changes nothing.
Two properties of this component make the rotation gentler than it looks, and one makes it sharper.
The gentle part is that the gap between step 1 and step 3 is an endpoint outage, which is the case the egress is built to ride out. It buffers, retries with backoff, and posts in order when the endpoint accepts it again. A rotation done inside the buffer window therefore loses nothing.
The sharp part is that the buffer is bounded and in memory. A rotation left
half-finished past omfEgress.buffer.queueLimit records starts dropping the
oldest, and the pod restart in step 3 empties whatever is still queued. Do the
restart promptly, and do not restart the pod between steps 1 and 2. That
throws away the buffer without having fixed the credential.
Read dcs_omf_egress_dropped_total after any rotation on this component. It is
the only readout that distinguishes a rotation that cost nothing from one that
lost a window of plant history, and it does not recover on its own.
For auth.mode: producerToken, the key is producerToken and everything else
above is unchanged.
Historian DB Password¶
With historian.database.cnpg.enabled, the CloudNativePG operator
manages the PostgreSQL superuser and application passwords. Rotate
through CNPG. Never edit the Secret directly.
# 1. Check the Cluster name and managed secrets.
kubectl get cluster -n dcs-system
# 2. Rotate the app user password (CNPG-native).
kubectl cnpg psql <cluster-name> -- \
-c "ALTER USER app PASSWORD '<new-password>';"
# Update the Kubernetes Secret the historian reads.
kubectl edit secret -n dcs-system <cluster-name>-app
# 3. Restart the historian so it reconnects with the new password.
kubectl rollout restart deploy/dcs-cloud-native-dcs-historian -n dcs-system
# 4. Verify WAL shipping is uninterrupted.
kubectl cnpg status <cluster-name>
With an external PostgreSQL (historian.database.externalURL), the
password lives wherever the external DB admin stored it. Update the
external URL Secret and restart the historian.
k3s Node Join Token¶
The k3s join token is install-time and is not normally rotated. It only matters when adding a new node. Treat a compromised join token as a cluster-rebuild event (it implies the attacker can provision new controllers). That event is out of scope for this runbook. File a platform issue.
Drill History and Notes¶
Observations recorded during the #168 drill campaign. Each paragraph is dated. Add new ones at the top so the most recent run stays in view.
2026-08-15 — MQTT credential slots (the window that works, #1579)¶
Drill target: the three-step rotation documented above, run as three
helm upgrade --reuse-values against a live release on a kind cluster. The
gateway identity was rotated end to end, then rotated back.
- The window is real. Step 1 put
dcs-gateway-bin the auth Secret. The broker loaded it 100 seconds later without restarting, and from that point both credentials authenticated at the same time, from one passwd file. Slota's password was byte-identical before and after the upgrade, which is thelookuppreservation the whole procedure rests on. - The flip cost nothing. Step 2 moved the gateway to
dcs-gateway-b. Slotastayed accepted throughout, and the gateway logged zero authentication failures across the roll. - Retiring works, and did not before this drill. Step 3 removed the key from the Secret immediately and the broker dropped the account 50 seconds later. The old credential was refused from then on, with no effect on the running gateway.
- The next rotation is a real rotation. Flipping back to slot
agenerated a fresh password. It did not re-adopt the retired one, and that credential stayed refused. This is what step 3 buys. - The propagation delay is the thing to plan around. Between a
helm upgradeand the broker serving the change sit kubelet's Secret sync and the reloader's 10-second poll: 50 to 100 seconds in this drill. Step 2 must not overtake step 1, which is why the procedure gates on reading the account out of the broker's own passwd file. - Found here, fixed here. The first attempt failed step 3. The Secret used
stringData, which Kubernetes merges intodataand then clears. A key the chart stops rendering is therefore a key Helm's three-way merge has nothing to diff against.retireSlotrendered a manifest with the account gone and left the credential authenticating three upgrades later. Every render test agreed the account was gone, because in the manifest it was. The Secret carriesdatanow, and the chart test asserts that it never goes back. - Method note. The first attempt drove the
docs-shotscapture stack. That stack applieshelm templateoutput and holds no Helm release, andlookupreturns nothing outsidehelm install/upgrade. Every apply therefore regenerated every password, and no window could stay open. A rotation drill has to be run against a real release.
2026-08-14 — MQTT graceful rotation window (found broken, #1579)¶
Drill target: the dual-credential window the 2026-04-21 entry below asked for. #221 shipped it on 2026-04-22, one day after that drill, and nothing drilled it in the four months since. The runbook and the drill log disagreed about whether the graceful path existed, which is what sent someone to check (#1552).
- The window has never worked, on any Mosquitto 2.x. A passwd file holds one
password per username, and the chart's helper wrote a second line for the
same username whenever a
rotationPasswordwas set. - Drilled by lifting the helper script out of
_helpers.tpland running it, unmodified, in the broker image the chart pins. On 2.0.18 and 2.0.21 the broker starts and accepts only the last entry, so the credential every running client holds is rejected the instant the window opens. On 2.1.2, which is what the pinnedeclipse-mosquitto:2resolves to today, the broker reportsDuplicate user 'dcs-gateway' in password fileand terminates. - Both of the chart's roads reach it. The init container builds the same file
on a cold install, so a fresh install with a window configured never comes
up. The
passwd-reloadersidecar SIGHUPs a healthy broker into a crash loop on the upgrade that opens one. - Fixed here: the chart refuses a
rotationPasswordat render time, and the helper refuses to emit the duplicate entry so an operator's ownmqtt.auth.existingSecretcarrying rotation keys leaves the broker up on the primary credential. MQTT password rotation was the single-step path above until #1579 shipped a mechanism Mosquitto can serve, which is the credential slots of ADR 0061. - Why it survived four months: #221's acceptance was signed off by a
chart test that renders the templates and greps the YAML for the Secret
keys. That proves what the manifest says. The question the values raise is
what the broker does with the file, and only a running broker answers it.
pkg/mqttnow runs the chart's own script against one undermake test-interop.
2026-04-21 — MQTT client passwords (non-graceful)¶
Drill target: helm upgrade --set-string mqtt.auth.users.*.password=…
+ parallel client rollout. Graceful (dual-credential window) path is
blocked on a missing chart helper. Drilled the non-graceful path per
the #168 decision.
- Full rotation wall-clock: 125 seconds. Helm upgrade + rollout trigger: ~90s. Pod rollout itself: 39s.
- MQTT broker was rolled automatically by helm (the Deployment spec
changed via its Secret reference annotation). Broker pod
creationTimestamp confirmed it restarted during the upgrade window.
Mosquitto came back with the new bcrypted passwd file loaded from
the refreshed
mqtt-authSecret mount. - Gateway log around reconnect:
connecting to MQTT broker→MQTT connection established→ subscribes resumed. Nobad username/passwordor auth errors after reconnect. - Broker disconnect events during the window ("Protocol error") were kubelet probe connections hitting the TLS port without a TLS handshake, unrelated to the password rotation.
- Gap: the chart still lacks a dual-credential helper so a graceful window where old + new passwords both work simultaneously is impossible without a hand-rolled ACL overlay. 125s of client disconnect is acceptable for annual rotation in a planned window but would be uncomfortable for anything more frequent. Follow-up issue to file for the chart helper (mqtt.auth.users.*.rotationPassword).
- Closed on 2026-08-15, by a third route. #221 answered this gap the next
day with the
rotationPasswordknob. The 2026-08-14 entry above found that the knob never opened a window. The gap this paragraph names was therefore continuously true for four months. #1579 closed it with credential slots, and the entry at the top of this log is the drill.
2026-04-21 — Unit runtime bearer token rotation (partial)¶
Drill target: helm upgrade --set-string unitRuntime.auth.token=…
followed by operator + runtime-pod roll.
- Secret
dcs-cloud-native-dcs-runtime-authrotated correctly: RV 2681 → 23867, token SHA-256 changed. - helm upgrade wall-clock: 273 seconds on this small cluster, longer than hoped because the chart also re-renders every component with the new token reference, triggering rolling restarts where pod templates changed.
- Post-upgrade pod ages:
physical-operatorandcontrol-operatorrolled (pod creationTimestamp matches the upgrade window).procedural-operatordid not roll (pod still 90+ min old). The procedural-operator likely uses the same Secret via envFrom but wasn't triggered because its Deployment spec didn't change during the upgrade (a subtle behavior worth calling out in production). - Finding: the original runbook suggested
kubectl rollout restart deploy -n site-<name>to roll runtime pods. That command does nothing for runtime pods because they are bare Pods created directly by the Unit reconciler, with no Deployment behind them. Patched the runbook with the correctkubectl delete pod -l dcs.io/component=unit-runtimecommand scoped across alldcs.io/site-labeled namespaces. - Not exercised on this cluster: runtime Pod actually picking up the new token. Same limitation as drill #6: no IOModule chain.
2026-04-21 — Per-site runtime Certificate rotation (partial)¶
Drill target: the #213 per-site Certificate flow. site-drill-site
namespace was created by the Site reconciler. A runtime Certificate
dcs-cloud-native-dcs-runtime-mtls was auto-created by the reconciler
and signed by the cluster-wide mTLS CA ClusterIssuer.
- Pre-drill ClusterIssuer check:
kubectl get certificate -n site-drill-siteshowedspec.issuerRef.kind=ClusterIssuerpointing atdcs-cloud-native-dcs-mtls-ca, the cluster-wide CA. No per-site Issuer is involved, which confirms the #213 design. cmctl renew -n site-drill-site dcs-cloud-native-dcs-runtime-mtlsrotated the Secret in 28 seconds. Fingerprint changed, issuer still the cluster CA.- Trust chain: per-site
ca.crtfingerprint matched the cluster CA Secret fingerprint byte-for-byte. The ca-injector path is working. - Unit reconciler did fire on the Secret change (two reconcile log
entries within seconds of the cert rotation), confirming the
Watches()wiring from #211 is live. However, the Unit on this drill cluster has no IOModules (no full Controller/IOModule/ControlModule/ ControlModuleTemplate chain), so no runtime Pod was ever created. The pod-recreate observation (thedcs.io/runtime-mtls-rvannotation flipping and the bare Pod being deleted + recreated) was therefore not exercised in this drill. That code path is covered by the Unit reconciler's unit tests. Drilling end-to-end requires a cluster with a working reference-plant Unit setup and is a follow-up.
2026-04-21 — mTLS CA cert re-issue¶
Drill target: force CA rotation via cmctl renew and observe downstream
impact. Cluster: drill cluster with 7 components deployed, mTLS enabled.
- CA re-issue: 3 seconds. But: CA identity CHANGED (fingerprint
DA:5A:41…→63:E1:A7…, serial also changed). The prior version of the runbook claimed "CA identity stays the same". That claim was wrong for cert-manager 1.18+ defaultprivateKey.rotationPolicy: Always. Updated the runbook to document the real behavior and the requiredcmctl renew --allfollow-up. - Immediately after CA renewal, component Secrets'
ca.crtstayed STALE (still held old CA) for every component except the one whose cert I force-renewed next. That one (gateway-mtls) picked up the new CA via cert-manager's secret rewrite. All 4 operators remained on old CA in their trust store. - Ran
cmctl renew -n dcs-system --all(the runbook's documented full re-key step): 31 seconds wall-clock. Afterwards every component'sca.crtfingerprint matched the live CA. - No TLS errors logged on any component during the out-of-sync window. Can't rule out that the drill cluster is idle enough that no cross-component mTLS handshake happened during the stale-trust window. A production cluster with active batch traffic would likely have seen handshake failures.
- Action taken: rewrote the mTLS CA section to (a) drop the
"identity stays the same" claim, (b) make
cmctl renew --alla mandatory post-renewal step, (c) ship a verify-sync snippet that diffs each component'sca.crtfingerprint against the live CA.
2026-04-21 — HMAC signing key rotation (v1 → v2, key-ID chaining)¶
Drill target: graceful HMAC key rotation per the procedure documented
above. Cluster: drill cluster. Secret dcs-signing-key initially held
only key.v1 (auto-generated on first gateway startup).
- Full rotation wall-clock (patch + gateway rollout): 10 seconds.
- Pre-rotation gateway log:
loaded HMAC signing keys {currentKeyID: v1, totalKeys: 1}. - After patch + rollout:
loaded HMAC signing keys {currentKeyID: v2, totalKeys: 2}, with both keys loaded and current pointing to v2. Key-ID chaining is functional. dcs audit verifyran cleanly against the gateway (returned 0 records since the drill cluster is fresh, with no signed AuditRecords yet). The verify path works end-to-end. Verification across a real corpus of v1-signed records requires a cluster with batch history. Re-drill on a cluster with pre-rotation signed records is a nice-to-have follow-up.- No gotchas. The procedure as documented produced the expected state.
2026-04-21 — Gateway setup token revocation¶
Drill target: helm upgrade --set gateway.auth.setupToken="" + gateway
rolling restart. Cluster: drill cluster running gateway.auth.mode: none
(no OIDC configured on the drill box).
- Baseline (pre-revocation, with
auth.mode: none):GET /api/v1/setupreturned HTTP 200 with no token, with wrong token, and with the valid token. The setup token was not gating anything becausemode: nonealready exposes the admin endpoints publicly. - Revocation (
setupToken=""+ gateway rollout): 29 seconds end-to-end. - Post-revocation: same GET still returns HTTP 200 with any or no token.
No setup-marker ConfigMap was created (none/revoked doesn't trigger the
setupTokenInvalidatedAtwrite path). - Finding: the runbook's "revoke the setup token" procedure is a
no-op in
mode: none. Noted inline in the Gateway Setup Token section. A production cluster runningmode: oidcormode: ldapis the only configuration where the drill is meaningful. The setup token is the pre-OIDC bootstrap admin, and once OIDC is live, revocation closes the bootstrap door. Re-drilling on an OIDC-configured cluster is a follow-up, filed separately.
2026-04-21 — MQTT broker TLS (dcs-cloud-native-dcs-mqtt-tls)¶
Drill target: broker cert renewal via cmctl + rolling restart of the mosquitto Deployment. Cluster: same as the mTLS drill.
- Secret
resourceVersionbumped after 14s (slower than the 4s for component-mTLS, likely because the mqtt-tls private key rotated too under cert-manager 1.18's defaultprivateKey.rotationPolicy: Always). - Broker rolling restart: 31s (single-replica on drill cluster — an HA
StatefulSetwith multiple instances would be longer). - Gateway MQTT client logged
MQTT connection lost, will reconnectfollowed byMQTT connection establishedwithin the same second. No messages queued beyond the broker outage window. - Gotcha surfaced: the MQTT broker cert lifetime is ~90 days (cert
observed
notAfter=Jul 20 16:33:39 2026from a renewal on2026-04-21), where component mTLS uses the 24h default. The runbook's original "Same as components (24h)" row was wrong. The table now shows~90d. Drill exposed this inconsistency.
2026-04-21 — mTLS component cert (gateway-mtls)¶
Drill target: release-namespace Deployment cert renewal via the graceful
(automatic cert-manager) path. Cluster: fresh DO k3s droplet, chart at
defaults, mTLS with ClusterIssuer mode. Method: cmctl renew -n
dcs-system dcs-cloud-native-dcs-gateway-mtls, observe.
- Secret
resourceVersionbumped in 4 seconds after the cmctl call (1605 → 13179). New certnotAfteradvanced by ~1h to reflect a fresh 24h lifetime. - Gateway pod
restartCountstayed at 0 —pkg/tlsutilhot-reload works as documented, no rolling restart needed. - No
x509or TLS errors in gateway logs in the 2-minute window around renewal. - Gotcha surfaced during drill setup (not part of this rotation path but
worth recording): chart template bug in
templates/mqtt-certificate.yamlandtemplates/webhook-certificate.yamlhardcoded$issuerKind = "Issuer"in the mtls-CA fallback branch, which breaks mqtt-tls and webhook-tls on any install using the defaultmtls.certManager.clusterIssuer: true(i.e. every default install post-#213). Fixed in the same working session by usingternary "ClusterIssuer" "Issuer" .Values.mtls.certManager.clusterIssuer. Without this fix, drill #8 (MQTT broker TLS) would have failed on renewal withReferenced "Issuer" not found.
Related Documentation¶
- Security Hardening -- initial security posture and Helm defaults
- Security Operations -- day-to-day auth, RBAC, and electronic signatures
- Backup and Recovery -- back up the mTLS CA and HMAC signing key before rotating
- DR Runbook -- recovery when rotation goes wrong
- 21 CFR Part 11 Traceability -- electronic records and the HMAC key-ID-chaining gap