Security Hardening Guide¶
Overview¶
This guide hardens a Cloud-Native DCS deployment for production use in pharmaceutical and regulated manufacturing environments.
Target security level: IEC 62443 SL 2-3 (protection against intentional violation using simple to sophisticated means).
Prerequisites: cert-manager, an OIDC provider (Keycloak, Azure AD, Okta), and an ingress controller (nginx or Traefik). Familiarity with the Security Operations guide is assumed.
BPCS-layer protection — not a safety-rated system
Cloud-Native DCS is a Basic Process Control System. It is not a Safety Instrumented System under IEC 61508 / IEC 61511 and is not rated for SIL 1, SIL 2, SIL 3, or SIL 4 functions. Security hardening protects the process-control plane against cyber threats. It does not turn it into a safety-rated shutdown system. Its interlocks and alarms are BPCS-layer protection functions. Whether a given hazard (emergency shutdown, overpressure trip, burner management, LEL/gas trip) requires an independent SIS is a process-hazard-analysis (PHA/LOPA) decision, and any function assigned to an SIS lives on independent, separately certified safety-PLC or hardwired-trip hardware that this software does not control.
Hardening Checklist¶
The chart is secure by default: most controls below ship enabled. For those rows, hardening means verifying the default is still in effect and filling in deployment-specific configuration (OIDC issuer, site namespaces, field bus CIDRs). There is no switch to flip.
| Feature | Helm Value | Default | Production |
|---|---|---|---|
| Gateway mode | gateway.mode |
production |
production |
| Authentication | gateway.auth.mode |
oidc |
oidc (configure issuer) |
| Mutual TLS | mtls.enabled |
true |
true |
| MQTT security | mqtt.securityProfile |
production |
production |
| Network policies | networkPolicies.enabled |
true |
true (add site namespaces + field bus CIDRs) |
| Audit webhook | webhook.enabled |
true |
true |
| Audit archival | historian.audit.archival.enabled |
false |
true |
| Unit runtime auth | unitRuntime.auth.enabled |
true |
true |
| Rate limiting | gateway.rateLimit.enabled |
true |
true |
| Stream conn. ceiling | gateway.streamLimits.maxConnectionsPerIP |
10 |
size to the consumers behind each address |
| Ingress TLS | gateway.ingress.enabled |
false |
true (with TLS) |
Commands in this guide 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. Substitute your own release namespace and
fullname prefix if they differ.
Personas, Surfaces, and Privilege Tiers¶
kubectl grants broad, unaudited control and assumes Kubernetes fluency.
It is not the interface the plant runs on. The governed tier is the
gateway. The web UI, the dcs CLI, and the gateway terminal all
authenticate through OIDC, resolve to a gateway RBAC persona, are scoped to
a single Site, and write every state-changing action to the audit trail.
Only one persona needs a kubeconfig at all.
| Persona | Surface | Authorization | Kubernetes credentials | Can write |
|---|---|---|---|---|
| Operator | Gateway UI / HMI | dcs-operator, dcs-lead-operator |
none | ISA-88 state commands (audited) |
| Supervisor | Gateway UI | dcs-supervisor |
none | Recipe approval / release, batch-record finalization (e-signature) |
| Process engineer | dcs CLI, gateway terminal |
dcs-engineer |
none | Recipe authoring, control logic via ChangeRequest under gitopsEnforcement |
| Platform engineer | kubectl |
dcs:k8s:viewer or dcs:k8s:platform |
read-only | nothing; direct writes are rejected |
| Cluster admin | kubectl |
your IdP + dcs:admin:break-glass |
full | break-glass only, audited at elevated severity |
Operator and supervisor never leave the gateway. Their privilege is
entirely a function of the groups claim in their OIDC token. See
§ 2. OIDC Authentication for the group table.
Process engineer works from the dcs CLI, either against the gateway
API or through the in-browser terminal at /terminal. That page requires
dcs-engineer or above, is scoped to the user's assigned Site, and runs
bash --restricted with PATH limited to the dcs binary. The user
cannot change PATH, invoke absolute paths, or redirect output.
The terminal's shell restriction is defence in depth, not a boundary
bash --restricted hardens a shell that is already reachable only by an
authenticated dcs-engineer. It is not a sandbox. For a deployment that
must contain a hostile engineer, disable /terminal at the ingress and
give each user a shell pod with its own ServiceAccount instead.
Platform engineer is the only human persona holding Kubernetes
credentials, and they are read-only (§ 7. RBAC). Under
gitopsEnforcement.enabled, ValidatingAdmissionPolicies reject CREATE /
UPDATE / DELETE on engineering-class CRDs from every identity except the
Flux ServiceAccount and break-glass holders, so an over-broad kubeconfig
handed out by mistake still cannot mutate plant configuration. See
GitOps Enforcement.
Cluster admin is outside our control surface. Cluster-admin is granted
by your IdP and Kubernetes RBAC. What the chart does own
is the dcs:admin:break-glass ClusterRole: bind it to the on-call admin only
during incident response, because every write it permits is recorded as an
elevated-severity AuditRecord.
1. Gateway Production Mode¶
Production mode (the default) prevents deletion of batch records, procedural
instances, and audit records (21 CFR Part 11). DELETE requests return
403 Forbidden. Confirm no environment override has switched the gateway
back to development:
gateway:
mode: "production"
Verification: curl -s -o /dev/null -w "%{http_code}" -X DELETE https://dcs.example.com/api/v1/audit/records/test -- expect 403.
2. OIDC Authentication¶
Configure the gateway to authenticate all API requests via OIDC. See Security Operations for the full authentication flow and exempt routes.
gateway:
auth:
mode: oidc
oidc:
issuerURL: "https://keycloak.example.com/realms/dcs"
clientID: "cloud-native-dcs"
audience: "cloud-native-dcs"
allowedOrigins:
- "https://dcs.newark-plant.example.com"
Group mapping -- configure these groups in your OIDC provider's groups claim:
| Group | Purpose |
|---|---|
dcs-admin |
Administrative operations, setup, config; can perform any operation |
dcs-supervisor |
Recipe approval / release / activation, batch-record finalization |
dcs-engineer |
Recipe authoring, control logic configuration |
dcs-lead-operator |
Everything an operator can do, plus launching equipment-oriented ad-hoc Operations (CIP, sampling, calibration) |
dcs-operator |
Batch execution, ISA-88 state commands |
dcs-viewer |
Reads only. Every GET the roles above can make and no state change of any kind, which is the grant a SIEM collector or a reporting job runs as |
See Security Operations § Authorization (RBAC)
for the full per-role permission breakdown, and
Identity Providers for how a
specific provider is configured to emit those names in its groups claim.
The gateway compares each claim value against the role names for exact
equality and discards what does not match, so a user whose provider emits
group object IDs or a Keycloak full group path authenticates successfully and
then holds no permissions at all.
CORS: Never use ["*"] in production -- list specific origins only.
Verification:
curl -H "Authorization: Bearer <token>" \
https://dcs.newark-plant.example.com/api/v1/sites # expect 200
curl https://dcs.newark-plant.example.com/api/v1/sites # expect 401
Session Lifecycle (IEC 62443 SR 2.5 / 2.6 / 3.8)¶
The gateway terminates sessions server-side before token expiry
(#971):
on logout (POST /api/v1/auth/logout, which the UI Logout and Lock buttons
and the CLI call), after a configurable inactivity window, and on demand by
an administrator (DELETE /api/v1/auth/sessions/{id}, listed via
GET /api/v1/auth/sessions). Session IDs are unique per session (UUID
jti) and invalid once revoked. The browser UI additionally locks after a
configurable idle period and requires re-authentication to resume.
Termination reaches the connections a session is already holding, and it
reaches them immediately (#1445).
Revoking a session closes its live WebSocket, SSE and terminal streams.
Each stream also re-checks the identity behind it every 50 seconds, and
that recheck is what ends one whose token has expired or whose session
crossed the inactivity window. Clients are told why (WebSocket close code
4001, a session-ended event on SSE). An integration therefore stops
reconnecting and never loops into 401.
See SCADA integration for the client contract.
An open stream is not activity: it never keeps an idle session alive.
A session terminated out from under a working page is announced on that page's next request (#1546). The browser UI sends the operator to the login page carrying the path they were on and the reason the gateway gave, which distinguishes an inactivity termination and an administrative revocation from a token that simply ran out. Signing back in returns them to the view they were standing on, the same resume the manual lock performs.
gateway:
auth:
sessionInactivityTimeout: "30m" # server-side; "0" disables
uiIdleLockSeconds: 900 # browser idle lock; 0 disables
DELETE /api/v1/auth/sessions/{id}) is not in this take.Essential-function constraint (62443-3-3 Clause 4.2): access control
must never prevent the operation of essential functions. Control execution
never depends on a browser session (the edge keeps running the batch
regardless of UI lock state), but a dedicated operator station whose
screens must stay visible and immediately actionable should set
uiIdleLockSeconds: 0 (and, if warranted, a longer
sessionInactivityTimeout) and rely on physical access control instead.
This is the sanctioned configuration. It is not a workaround.
HA note: the session registry is per-replica (like the anti-replay nonce store): with multiple gateway replicas, a revocation takes effect on the replica that processed it, and a restart clears revocations (token expiry is the backstop). A shared backing store is tracked for multi-replica deployments.
3. Mutual TLS (mTLS)¶
Encrypts and authenticates all inter-component HTTP (IEC 62443-3-3 SR 3.1 / SR 4.1). Requires cert-manager.
mtls:
enabled: true
certManager:
enabled: true
clusterIssuer: true # use ClusterIssuer so per-site Certificates work
perSiteRuntimeCert: true # Site reconciler creates a Certificate per site-* namespace
issuerRef:
name: "" # empty = auto-create self-signed CA
kind: ClusterIssuer # set to "Issuer" if clusterIssuer is false
duration: 24h
renewBefore: 8h
Cert distribution model. Gateway, operators, historian, and io-probe
receive Certificates issued directly into dcs-system from this chart. Per-site
unit-runtime pods run in site-* namespaces (one per tenant) and each site
namespace gets its own Certificate created by the physical-operator's Site
reconciler when the Site CR is reconciled. cert-manager owns rotation for
every site: no cross-namespace secret copying, no manual kubectl apply,
no drift when the CA rotates.
cert-manager prerequisite. With clusterIssuer: true, the auto-created
dcs-cloud-native-dcs-mtls-ca Issuer is cluster-scoped and its CA Secret lives in
the release namespace (typically dcs-system). The cert-manager controller
must be installed with --cluster-resource-namespace=dcs-system so it can find
the CA Secret when signing per-site leaf certs. If your cert-manager install
uses the default cert-manager namespace for cluster resources, either move
the chart's CA Secret there, or set clusterIssuer: false (in which case
per-site Certificates are disabled and you must manage runtime cert
distribution yourself).
To sign with an organizational PKI in place of the auto-created CA, set
issuerRef.name to your ClusterIssuer (e.g., org-ca-issuer) and
kind: ClusterIssuer.
Verification:
# dcs-system certs (gateway, operators, historian, io-probe)
kubectl get certificates -n dcs-system -l app.kubernetes.io/part-of=cloud-native-dcs
# Per-site runtime certs
kubectl get certificates -A -l app.kubernetes.io/component=unit-runtime
kubectl get certificate -n dcs-system dcs-cloud-native-dcs-gateway-mtls \
-o jsonpath='{.status.notAfter}'
Migration from pre-#213 installs. Existing clusters with manually-copied
dcs-cloud-native-dcs-runtime-mtls Secrets in site namespaces should migrate with:
# 1. Upgrade the chart with clusterIssuer: true, perSiteRuntimeCert: true.
# 2. For each site namespace, delete the manual Secret so the new Certificate
# can own it:
for NS in $(kubectl get ns -l dcs.io/site -o name); do
kubectl delete secret -n "${NS#namespace/}" dcs-cloud-native-dcs-runtime-mtls --ignore-not-found
done
# 3. Trigger a Site reconcile (e.g., annotate each Site CR with dcs.io/reconcile)
# so the Site reconciler creates Certificates. cert-manager then issues
# fresh Secrets in each namespace.
# 4. Bounce the runtime pods once so they mount the fresh Secret content.
4. MQTT Broker Security¶
The production profile is already the default -- TLS on port 8883,
per-role authentication, and topic ACLs are active. Plaintext port 1883
is disabled. See Security Operations for ACL
role details.
mqtt:
securityProfile: production
auth:
users:
gateway: { password: "" } # auto-generated (recommended)
operator: { password: "" }
runtime: { password: "" }
historian: { password: "" }
Auto-generated passwords are stored in the Secret
dcs-cloud-native-dcs-mqtt-auth (one key per broker username:
dcs-gateway, dcs-operator, dcs-runtime, dcs-historian). Use
--set-string mqtt.auth.users.<role>.password=... only if you need
explicit values.
Verification:
HIST_PASS=$(kubectl get secret -n dcs-system dcs-cloud-native-dcs-mqtt-auth \
-o jsonpath='{.data.dcs-historian}' | base64 -d)
mosquitto_sub -h dcs-cloud-native-dcs-mqtt.dcs-system.svc -p 8883 \
--cafile ca.crt -u dcs-historian -P "$HIST_PASS" -t "dcs/#" -C 1
5. Network Policies¶
Enforces IEC 62443 zone/conduit segmentation with default-deny ingress.
networkPolicies:
enabled: true
siteNamespaces:
- site-newark-plant
fieldBusCIDRs:
- 10.0.100.0/24
gateway:
ingressNamespace: ingress-nginx
monitoring:
namespace: monitoring
Zone model:
flowchart TD
subgraph Ext["External Zone"]
Ing[Ingress Controller]
end
subgraph Ctrl["Control Zone"]
GW[Gateway]
Doc[Docs Site]
Op[Operators]
MQ[MQTT Broker]
end
subgraph Field["Field Zone"]
RT[Unit Runtimes<br/>site-newark-plant]
end
subgraph Bus["Field Bus Zone"]
IO[I/O Modules<br/>10.0.100.0/24]
end
Ing -->|HTTPS| GW
Ing -->|HTTPS| Doc
Ctrl -->|mTLS| RT
RT -->|Modbus / EthernetIP / OPC UA| IO
Every site namespace must appear in siteNamespaces for runtime policies
to be installed. Field bus CIDRs restrict runtime egress to known device
networks.
Verification:
kubectl get networkpolicies -A -l app.kubernetes.io/part-of=cloud-native-dcs
6. Pod Security¶
The Helm chart enforces hardened security contexts on the control-plane
workloads it deploys (gateway, operators, historian, MQTT, audit
components): runAsNonRoot, readOnlyRootFilesystem, no privilege
escalation, all capabilities dropped, seccompProfile: RuntimeDefault.
The non-root UID itself (65532) comes from the distroless base image's
USER directive. The chart pins no runAsUser.
Exception: the unit-runtime pod. The per-unit runtime pod that the physical operator creates on device nodes does not fit this profile, by design:
hostNetwork: true— deterministic host ports for the runtime HTTP API and health endpoints, and direct field-network access (see the Port Allocation Policy in Architecture).- A
hostPathvolume for local FB-state persistence, so the unit keeps executing through control-plane outages (local autonomy).
Those two deviations are the whole exception. Everything else is
enforced. The runtime container carries the same securityContext
controls as the chart-deployed profile: runAsNonRoot,
allowPrivilegeEscalation: false, all capabilities dropped,
seccompProfile: RuntimeDefault, and readOnlyRootFilesystem: true
(all runtime state is written under the hostPath data mount, and none
touches the root filesystem). See
#722.
One supporting detail: the kubelet creates the hostPath data dir
root-owned, so the pod runs a short-lived init-data-perms init
container as root to chown it to the runtime UID before the app
container starts. It is never privileged and is bounded to the
CHOWN/DAC_OVERRIDE/FOWNER capabilities with everything else
dropped (#731).
Compensating controls for the runtime pod: mTLS plus fail-closed bearer token auth on its HTTP API, NetworkPolicies restricting ingress and limiting egress to the field-bus CIDRs (§5), cosign-signed distroless images, and physical/host security of the dedicated device nodes. See the unit-runtime section of the Threat Model.
Pod Security Admission guidance therefore differs by namespace.
restricted is safe for the control-plane namespace:
kubectl label namespace dcs-system \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/audit=restricted
Do not apply enforce=baseline or enforce=restricted to site
namespaces that host unit runtimes: hostNetwork and hostPath are
forbidden at both levels, so enforcement blocks every runtime pod in the
site (taking down all I/O) the next time one is created or rescheduled.
Keep enforcement at privileged on runtime-hosting site namespaces and
use warn/audit at baseline for visibility into everything else
scheduled there:
kubectl label namespace site-newark-plant \
pod-security.kubernetes.io/enforce=privileged \
pod-security.kubernetes.io/warn=baseline \
pod-security.kubernetes.io/audit=baseline
Verification:
kubectl get pods -n dcs-system -o json | \
jq '.items[0].spec.containers[0].securityContext'
7. RBAC¶
The generated manager-role ClusterRole uses least-privilege verbs.
AuditRecords are restricted to create only -- no update or delete.
By default each operator gets its own ServiceAccount, named
dcs-cloud-native-dcs-<component> (override per component with
<component>Operator.serviceAccount.name).
Verification:
kubectl auth can-i --list \
--as=system:serviceaccount:dcs-system:dcs-cloud-native-dcs-physical-operator
kubectl auth can-i delete auditrecords.audit.dcs.io \
--as=system:serviceaccount:dcs-system:dcs-cloud-native-dcs-batch-operator # expect: no
Human read roles¶
For the platform-engineer persona the chart ships two read-only ClusterRoles. Both are disabled by default and neither carries a write verb. Enabling one renders the ClusterRole but binds nothing until you supply subjects.
| Value | ClusterRole | Grants |
|---|---|---|
humanRoles.viewer.enabled |
dcs:k8s:viewer |
get/list/watch on all eight dcs.io API groups |
humanRoles.platform.enabled |
dcs:k8s:platform |
the above, plus pods, pod logs, events, workloads, services, nodes, and CRD schemas |
Neither role can read Secrets or ConfigMaps -- those hold MQTT credentials, the e-signature signing key, and MES API keys.
humanRoles:
platform:
enabled: true
subjects:
- kind: Group
name: platform-engineers
apiGroup: rbac.authorization.k8s.io
# Cluster-wide when empty. Listing namespaces binds per-namespace instead,
# which cannot grant read on the cluster-scoped Enterprise and Site.
namespaces: []
Leave subjects empty to render the ClusterRole and bind it yourself from
IdP-managed group bindings. Do not extend either role with write verbs: the
write path is Flux plus change control, and a human-bound write role
reintroduces exactly the out-of-band mutation
GitOps enforcement exists to
prevent.
Verification:
kubectl auth can-i list units.physical.dcs.io --as=alice # expect: yes
kubectl auth can-i delete units.physical.dcs.io --as=alice # expect: no
kubectl auth can-i get secrets --as=alice # expect: no
8. Secrets Management¶
Enable etcd encryption at rest for Kubernetes Secrets via
EncryptionConfiguration on control plane nodes (aescbc or aesgcm
provider).
| Secret | Contents | Rotation |
|---|---|---|
| mTLS certificates | TLS cert/key + CA | cert-manager (automatic) |
| MQTT passwords | Per-role credentials | helm upgrade |
| Runtime auth tokens | Bearer token per runtime | helm upgrade |
| E-signature keys | HMAC-SHA256 signing key | Manual rotation |
For vault integration, use the External Secrets Operator (ESO) with
ExternalSecret resources referencing your vault paths.
Best practices: Never log secrets. Mount them as read-only volumes, and keep them out of environment variables. Enable Kubernetes audit logging for secret access.
9. Audit Trail and Retention¶
Enable the AuditRecord webhook for immutability enforcement (21 CFR Part 11) and archival for long-term retention:
webhook:
enabled: true
certManager:
enabled: true
historian:
enabled: true
audit:
archival:
enabled: true
schedule: "0 2 * * *" # daily at 2 AM UTC
activeRetentionDays: 90 # kept in etcd
archiveRetentionDays: 1095 # 3 years in PostgreSQL
batchSize: 500
Back up the PostgreSQL archive with pg_dump and etcd with snapshots.
Verification:
dcs --site newark-plant audit trace batch-20260324-001
# Confirm webhook rejects mutations:
kubectl patch auditrecord test-record -n site-newark-plant \
--type merge -p '{"spec":{"action":"tampered"}}' 2>&1 # expect: denied
dcs audit verify --archived re-proving the signed archive.10. Supply Chain Verification¶
Release images are signed with cosign (Sigstore keyless) and include SPDX SBOMs.
cosign verify ghcr.io/cloud-native-dcs/dcs-gateway:v0.1.0 \
--certificate-identity-regexp="github.com/your-org/Cloud-Native-DCS" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com"
cosign download sbom ghcr.io/cloud-native-dcs/dcs-gateway:v0.1.0
Enforce signed images in the cluster with a Kyverno policy:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-dcs-images
spec:
validationFailureAction: Enforce
rules:
- name: verify-cosign-signature
match:
any:
- resources:
kinds: ["Pod"]
verifyImages:
- imageReferences: ["ghcr.io/cloud-native-dcs/*"]
attestors:
- entries:
- keyless:
subject: "https://github.com/your-org/*"
issuer: "https://token.actions.githubusercontent.com"
Pin images by digest¶
Tags are mutable: whoever controls the registry (or a compromised CI
token) can re-point a tag at a different image. Every DCS component
accepts an optional image.digest, which is preferred over any tag and
renders the immutable repository@sha256:... form:
gateway:
image:
digest: "sha256:4f5cdc8a6f7e..." # from `crane digest` or the release notes
Resolve the digest of a release tag with
crane digest ghcr.io/cloud-native-dcs/dcs-gateway:v0.1.24 (or
docker buildx imagetools inspect). Digests are per-image, so there is
no global.image.digest. Pin each component individually (setting the
global key fails the render). GitOps consumers can keep digests current
automatically with Flux image automation's :digest setter.
11. Monitoring Security Events¶
Enable ServiceMonitors and Grafana dashboards:
monitoring:
enabled: true
serviceMonitor: { enabled: true, interval: 30s }
grafana:
dashboards: { enabled: true }
Key metrics: dcs_audit_records_created_total, controller-runtime metrics.
The MQTT broker logs connection and auth events in the production profile --
forward to your SIEM. Example PrometheusRule alerts:
groups:
- name: dcs-security
rules:
- alert: HighAuditRecordCreationRate
expr: rate(dcs_audit_records_created_total[5m]) > 10
for: 5m
annotations: { summary: "Unusual audit record creation rate" }
- alert: GatewayRateLimitExceeded
expr: rate(dcs_gateway_rate_limited_total[5m]) > 5
for: 2m
annotations: { summary: "Sustained rate limit rejections" }
Verification: kubectl get servicemonitors -n dcs-system
12. Rate Limiting¶
Per-IP rate limiting is enabled by default (IEC 62443 SR 7.1).
gateway:
rateLimit:
enabled: true
read: { requestsPerSecond: 100, burst: 200 }
write: { requestsPerSecond: 20, burst: 40 }
static: { requestsPerSecond: 500, burst: 1000 }
Requests fall into three tiers, each with its own per-IP bucket:
| Tier | Matches |
|---|---|
| read | GET/HEAD against the API |
| write | POST/PUT/DELETE (any path) |
| static | GET/HEAD for the UI bundle — /css/, /js/, /vendor/, /favicon.svg, and the app shells (/login, /system, /data, /hmi, /terminal) |
Size the read and write tiers in requests. Size the static tier in
page loads. A cold-cache shell load fetches roughly 100 files from the
embedded bundle, so the default burst of 1000 is about ten cold page views.
Before the static tier existed, those files were charged to the read bucket and
a single page view spent half of it. Two quick navigations from one IP (or
several operator stations behind one NAT) 429'd the shell's own <script> tags,
and the page half-booted with only a browser MIME-type error to go on.
| Environment | Read (RPS/burst) | Write (RPS/burst) | Static (RPS/burst) |
|---|---|---|---|
| Single operator, manual use | 50 / 100 | 10 / 20 | 500 / 1000 |
| Multiple operators + HMI clients | 100 / 200 | 20 / 40 | 500 / 1000 |
| SCADA integration | 200 / 400 | 50 / 100 | 500 / 1000 |
The static tier does not scale with SCADA client count, because SCADA integrations call the API and never load the UI. It scales with the number of browsers behind a shared source address. Raise it if many operator stations sit behind one NAT.
Live-stream connection ceiling¶
Rate limiting bounds requests. What bounds the live stream is a ceiling on concurrent connections per source address, counting WebSocket and SSE together (#1447):
gateway:
streamLimits:
maxConnectionsPerIP: 10
Size it in consumers behind one address. A client count is the wrong unit. Ten fits browsers, which is what the default was chosen for. Every case that outgrows it shares one shape: several consumers arriving from one address.
| Deployment | Ceiling |
|---|---|
| A handful of operator browsers, distinct IPs | 10 |
| Control room behind one NAT | 2–3 × the number of stations |
| Redundant SCADA pair, one egress address | 4 × the connections one half opens |
| Gateway behind a proxy that governs connections itself | high enough to defer to it |
Count generously. A SCADA that opens one connection per area or per screen spends its allowance faster than its client count suggests, a browser holds a connection until the tab closes, and both halves of a redundant pair subscribe independently.
There is no setting that removes the ceiling. A value below 1 is read as the default of 10, and no value means "unbounded". An unbounded per-address fan-out is the denial-of-service surface SR 7.1 asks the gateway to bound, and a typo in a values file must not be able to take a control out silently. Defer to a front door that governs connections itself by raising the number high enough to stay out of its way.
A client over the ceiling is refused with 429 Too Many Requests and
Retry-After: 5 on both transports. The WebSocket handshake is refused
outright, with no upgrade followed by a close. A library therefore cannot
mistake a quota for a dropped connection. Refusals increment
dcs_gateway_stream_connections_refused_total{transport=…}. Alert on it,
because the symptom on the plant side is a redundant SCADA half that
silently never connected.
13. Ingress TLS Termination¶
gateway:
ingress:
enabled: true
className: nginx
hostname: dcs.newark-plant.example.com
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
tls:
- secretName: dcs-gateway-tls
hosts:
- dcs.newark-plant.example.com
For air-gapped environments, substitute letsencrypt-prod with an
internal CA issuer.
The in-cluster documentation site takes its own hostname and certificate
through docs.ingress, which mirrors gateway.ingress field-for-field.
docs:
ingress:
enabled: true
className: nginx
hostname: docs.newark-plant.example.com
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
tls:
- secretName: dcs-docs-tls
hosts:
- docs.newark-plant.example.com
Leaving docs.ingress disabled keeps the documentation reachable under the
gateway's /docs path. Either route reaches the docs pod through the
-docs NetworkPolicy, which admits the ingress controller named in
networkPolicies.gateway.ingressNamespace (§ 5).
Verification:
kubectl get certificate -n dcs-system dcs-gateway-tls
curl -v https://dcs.newark-plant.example.com/healthz 2>&1 | \
grep "SSL certificate verify ok"
Search-engine indexing¶
Any gateway reachable from the public internet is also reachable by a
crawler. The gateway serves /robots.txt on every host that fronts it, and
that file disallows crawling of the entire host. There is no Helm value for
this and nothing to turn on.
The blanket rule matters because the ingress can mount more than the gateway
on one hostname. With docs.enabled, the in-cluster documentation container
answers /docs/ on the same host, serving a full copy of the published
documentation site. An indexable gateway host would therefore put a duplicate
of every documentation page into the index under a second hostname, splitting
the ranking between the two. The documentation image carries a second layer of
its own: every response leaves it with X-Robots-Tag: noindex, nofollow.
Verification:
curl -s https://dcs.newark-plant.example.com/robots.txt
curl -sI https://dcs.newark-plant.example.com/docs/ | grep -i x-robots-tag
Related Documentation¶
- Threat Model -- STRIDE analysis and risk assessment
- Security Operations -- authentication, authorization, and electronic signatures
- IEC 62443 Traceability -- compliance requirements mapping
- 21 CFR Part 11 Traceability -- electronic records compliance
- MQTT Telemetry -- MQTT topic structure and client configuration