Harden a production installation
This page is the security checklist of a production installation of the Navin Platform: each step says what it does, why, the command or screen, the expected result and what to do if it fails. It is meant for the platform operator (platform_admin role) and the security officer (security_officer). Indicative duration: half a day, backups included.
Version française : ../hardening.md
The settings quoted are fields of services/control-plane/src/eai/config.py (EAI_ prefix); their full reference is in configuration.md and ../../operations/en/configuration.md. Infrastructure hardening (SPIFFE mTLS, Cilium, pods, supply chain) is described in ../../operations/en/security-hardening.md and is not repeated here.
Before you begin
- Access: a
platform_adminaccount and a distinctsecurity_officeraccount (several steps need two people). - OpenBao initialised and unsealed, entries written as in ../../operations/en/secrets.md.
- Keycloak with the realm
deploy/keycloak/realm-enterprise-ai.jsonimported. - Production Helm values:
deploy/helm/enterprise-ai/values-prod.yaml.
Never disable the startup guard to "get the Control Plane to start". Every message it prints is a real weakness: fix the setting.
Steps
-
Turn the startup guard on.
EAI_ENVIRONMENT=prodmakes the Control Plane refuse to start with any unsafe setting (eai.security.startup.enforce, which lists every problem). Instagingthe same problems are only logged.controlPlane:config:EAI_ENVIRONMENT: prodExpected result: the pod starts; no
mustmessage in the Control Plane log.If it fails: the pod stops with a
RuntimeErrorlisting every faulty setting; fix them one by one with the next steps. -
Encrypt every link (TLS). The guard refuses
EAI_DATABASE_URLandEAI_RUNTIME_DATABASE_URLwithoutsslmode=require,verify-caorverify-full,EAI_REDIS_URLwithoutrediss://, and a Redis withoutEAI_REDIS_PASSWORD_FILE(ACL user). Turn HSTS on (EAI_HSTS_ENABLED=true,EAI_HSTS_MAX_AGE_SECONDS=31536000). The file scanning engines must behttps://.psql "<EAI_DATABASE_URL>" -c "SHOW ssl"<EAI_DATABASE_URL>: the connection string read from OpenBao, withsslmode=verify-fulland the CA.Expected result:
on. API responses carryStrict-Transport-Security: max-age=31536000; includeSubDomains.If it fails: check
controlPlane.secretFormatsin Helm and the database certificate (../../operations/en/data-services-hardening.md). -
Close development authentication.
EAI_AUTH_DEV=false(theX-Dev-*headers authenticate anyone;eai.auth.dev_auth_activeignores them inprodanyway). Configure OIDC:EAI_OIDC_ISSUER,EAI_OIDC_AUDIENCE(the audience then becomes mandatory in the token), optionallyEAI_OIDC_JWKS_URL. KeepEAI_JWT_ALGORITHMSasymmetric (HS*andnoneare removed in every environment).EAI_AUTH_SPIFFE=trueonly behind the mesh, withEAI_TRUSTED_PROXIESlimited to the gateway pods.curl -s <control-plane-url>/v1/identity/whoami -H "Authorization: Bearer <token>"Expected result:
auth_methodisoidcandtenant_idis the user's tenant.If it fails: 401
invalid_tokenwith areason; check the issuer, the audience and the clock (EAI_JWT_LEEWAY_SECONDS). -
Secure the installer account. The local account created from
EAI_BOOTSTRAP_ADMIN_USERNAMEandEAI_BOOTSTRAP_ADMIN_PASSWORDholdsplatform_adminandtenant_adminand must change its password at the first sign in (POST /v1/auth/password). In production the guard refuses a weak password (fewer thanEAI_LOCAL_PASSWORD_MIN_LENGTHcharacters, no digit or no letter, equal to the login or known). Local accounts lock afterEAI_LOCAL_AUTH_MAX_FAILURESfailures forEAI_LOCAL_AUTH_LOCKOUT_SECONDS. If all authentication goes through Keycloak, setEAI_LOCAL_AUTH_ENABLED=false.Expected result: after the first sign in, remove
EAI_BOOTSTRAP_ADMIN_PASSWORDfrom the environment; a restart never resets the account.If it fails: 403
password_change_requiredon every other route until the initial password is changed. -
Restrict hosts, origins and documentation.
EAI_ALLOWED_HOSTSlists the public API host names,EAI_CORS_ORIGINSthe exact portal origins (never*),EAI_DOCS_ENABLED=falsecloses/docs,/redocand/openapi.json.EAI_METRICS_TOKEN(32 characters minimum) protectsGET /metrics.Expected result: a request with an unknown
Hostheader is refused;/docsanswers 404.If it fails: the guard names the faulty setting.
-
Close network egress.
EAI_EGRESS_ALLOW_HTTP=false(mandatory),EAI_EGRESS_ALLOWED_PORTS=443,EAI_EGRESS_PRIVATE_ALLOWLISTlimited to exact internal services. Per tenant,egress_allowed_hostsofPUT /v1/security/settingsrestricts egress to a list. Internal MCP servers go inEAI_MCP_INTERNAL_HOSTS.Expected result: a tool calling
169.254.169.254is refused and thesecurity.egress.deniedevent is written to the audit trail.If it fails: see runbooks.md and ../../operations/en/network-dns-hardening.md.
-
Wire OPA and OpenFGA fail closed. With
EAI_OPA_URL, everyengine=opapolicy is evaluated by OPA; an unreachable OPA means deny (403policy_denied, reasonpolicy engine unavailable). In production preferEAI_OPA_DELIVERY=bundlewithEAI_POLICY_BUNDLE_SIGNING_KEY_FILE(RS256 signed bundles, mandatory in bundle mode). WithEAI_OPENFGA_URL,EAI_OPENFGA_STORE_ID,EAI_OPENFGA_MODEL_IDandEAI_OPENFGA_API_TOKEN, an OpenFGA outage answers 502authz_backend_errorand the denial is audited.EAI_POLICY_DEFAULT=denyrefuses an action no rule covers (code default:allow, also settable per tenant withpolicy_default).curl -s <control-plane-url>/v1/policies/engines -H "Authorization: Bearer <token>"Expected result: OPA reachable and
in_syncistruein bundle mode.If it fails:
in_sync: falsemeans OPA keeps the previous revision (bundle refused); see identity-policy-plane.md. -
Keep rate and size limits.
EAI_RATE_LIMIT_ENABLED=true,EAI_RATE_LIMIT_PER_MINUTE(per principal),EAI_RATE_LIMIT_GATEWAY_PER_MINUTE(on/v1/gateway/*),EAI_RATE_LIMIT_AUTH_FAILURES_PER_MINUTE(per IP, then 429),EAI_MAX_BODY_BYTES,EAI_MAX_UPLOAD_BYTES,EAI_MAX_JSON_DEPTH. With several replicas,EAI_REDIS_URLshares the counters.Expected result: past the budget, a 429 answer and a
security.rate_limitedevent.If it fails: tuning in runbooks.md.
-
Check headers and CSP.
EAI_SECURITY_HEADERS_ENABLED=trueadds to every API response (errors included,eai.security.headers):X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none',Referrer-Policy: no-referrer,Permissions-Policy,Cross-Origin-Opener-Policy: same-origin,Cross-Origin-Resource-Policy: same-site. The portal sets its own CSP with a nonce (portal/src/middleware.ts,buildCspinportal/src/lib/security.ts).curl -sI <control-plane-url>/v1/identity/whoami | grep -i -E "content-security|x-frame|strict-transport"Expected result: the three headers are present.
If it fails: a CSP violation is handled as in runbooks.md, never with
unsafe-inline. -
Keep keys outside the database. The guard requires
EAI_OPENBAO_URLandEAI_OPENBAO_TOKEN(andEAI_AUDIT_SIGNINGother thanhmac),EAI_AUDIT_SEAL_SIGNING_KEY_REF(Ed25519 seal key in OpenBao),EAI_AUDIT_PAYLOAD_KEK=openbao-transit,EAI_CONTEXT_SIGNING_KEY_REFandEAI_CONTEXT_REQUIRED=true. For an HSM:EAI_KMS_PROVIDER=pkcs11and its three settings. Details: secrets-vault.md.Expected result:
GET /v1/context/jwkspublishes a key;GET /v1/evidence/verifyanswers without a break.If it fails: 503
context_authority_unavailablemeans the Context Authority key cannot be read. -
Enforce file scanning.
EAI_FILE_SCAN_MODE=enforce,EAI_CLAMAV_URLS,EAI_CLAMAV_API_KEY,EAI_YARAX_URLS,EAI_YARAX_API_KEYandEAI_FILE_SCAN_CA_FILE. Details: file-security-pipeline.md.Expected result:
GET /v1/file-security/statusshows both engines ready.If it fails: with no reachable engine every file upload is refused (503), never accepted unscanned.
-
Prepare the kill switch.
EAI_SOC_WEBHOOK_SECRET(32 characters minimum) authenticates the SOC webhooks and the cluster receipts; empty, every delivery is refused. Check that the registry answers and that two leads know the four eyes recovery procedure.curl -s <control-plane-url>/v1/soc/kill-switches -H "Authorization: Bearer <token>"Expected result: a list (empty on a fresh installation).
If it fails: see soc-runbook.md.
-
Bound break glass.
EAI_BREAK_GLASS_MAX_SECONDS(default 3600) caps the duration of a break glass grant; approval needs two distinct approvers, never the requester nor the beneficiary. Run a drill:POST /v1/break-glass, two approvals, thenPOST /v1/break-glass/{grant_id}/revoke.Expected result: the grant expires on its own or is revoked, and every step shows in
GET /v1/audit/events.If it fails: 403
separation_of_dutieswhen the requester tries to approve. -
Back up and prove the restore.
services/control-plane/scripts/pg_backup.shandservices/control-plane/scripts/pg_restore.shkeep a verifiable audit chain, seals and legal holds (test_restored_backup_keeps_verifiable_chain_seals_and_hold_state). The Navin Manager backup and DR plans are read withGET /v1/platform-manager/backup/plans; a restore needs two approvers other than the requester (POST /v1/platform-manager/backup/restores/{plan_id}/approve).Expected result: after a restore on a test environment,
GET /v1/audit/verifyandGET /v1/evidence/verifyare green.If it fails: see ../../operations/en/ha-dr.md.
Verify
GET /v1/security/posture (security_officer or tenant_admin roles) returns every posture check and a summary. A hardened installation leaves no failing check among auth_dev_disabled, cors_restricted, allowed_hosts_restricted, hsts_enabled, docs_disabled, secrets_backend, oidc_configured, egress_http_disabled, egress_private_allowlist_narrow, rate_limit_enabled, trusted_proxies_restricted, spiffe_behind_proxy, database_tls, jwt_algorithms_asymmetric, prompt_injection_enabled.
curl -s <control-plane-url>/v1/security/posture -H "Authorization: Bearer <token>"
Next steps
- Roles and rights: authentication-authorization.md.
- Governance: governance.md.
- Incident procedures: runbooks.md.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| The Control Plane refuses to start | Unsafe setting with EAI_ENVIRONMENT=prod | Fix every line listed by the guard |
| Every request seems to come from the gateway | Wrong EAI_TRUSTED_PROXIES | Set the CIDR of the gateway pods |
403 policy_denied on every tool call | OPA unreachable | Restore OPA; the denial is intended (fail closed) |
502 authz_backend_error | OpenFGA unreachable | Restore OpenFGA; the denial is audited |
| 503 on every file upload | No ClamAV or YARA-X engine reachable | Restore the engines (file-security-pipeline.md) |