Skip to content

Configuration

Reference for helm/norviq/values.yaml at chart version 0.2.5. Every default below was read from that file; where the chart’s inline comment records why a default is what it is, that reasoning is summarised here. The chart’s comments are worth reading directly — several of them carry the measurements the default was chosen from.

For the install walkthrough see Deployment; for a quick single-cluster evaluation see Get started. This page is the value-by-value reference behind both.

The chart declares kubeVersion: ">=1.30.0-0". helm install refuses on an older API server.

Three overlays ship alongside the defaults:

File What it changes
helm/norviq/values-prod.yaml Multi-node production posture. pullPolicy: IfNotPresent on the four app components; api/engine/webhook get PDBs, HPAs and node spread; webhook.injection.enabled: true; api resources are re-pinned to req 250m/256Mi, lim 1/512Mi and opa to lim 500m/256Mi; postgresql.ha.enabled + redis.ha.enabled with postgresql.storage: 20Gi; config.dbSslMode: require; gracefulShutdown.preStopSleepSeconds: 5; and every shipped datastore/fleet password is blanked so the operator must supply one. Not live-validated on a single-node cluster; template-validated only. Requires ≥3 nodes, metrics-server, the CloudNativePG operator and a RedisFailover operator.
helm/norviq/values-light.yaml The smallest viable single-node footprint. Enforcement is byte-identical to the default — it only changes replica counts, PDBs and resource envelopes (api.replicas 1, api.workers 1, api memory limit 384Mi, no api PDB, engine.replicas 1, webhook.replicas 1). The optional planes it pins off (fleet, HA, HPA, SPIFFE CSI) are already off by default; it pins them to be explicit.
helm/norviq/values-dev.yaml Dev cluster: a fixed non-secret api.secretKey, fixed dev Redis/Postgres passwords, postgresql.storage: 1Gi, config.logLevel: DEBUG, config.enforcementMode: audit, pullPolicy: Always on engine/api/ui.

Apply one with:

Terminal window
helm install norviq oci://ghcr.io/norviq-dev/charts/norviq \
--version 0.2.5 \
-n norviq --create-namespace \
-f helm/norviq/values-prod.yaml \
--set policyQuotaNamespaces='{prod-agents}'

--set always wins over a -f file for the same key.

The chart refuses to render several combinations rather than produce a deployment whose failure would surface far from its cause. These are deliberate; the error text tells you what to set.

Condition Where Why the chart refuses
baselineClusterPolicy.enabled: true (the default) with policyQuotaNamespaces: [] (the default) templates/baseline-cluster-policy.yaml The baseline renders one NrvqPolicy per listed namespace. With the list empty it would render zero baselines while NOTES.txt claims a baseline is installed — a silent gap. A pure-defaults helm install therefore fails. Either set policyQuotaNamespaces, or set baselineClusterPolicy.enabled=false to run without a cluster baseline explicitly.
A namespace in policyQuotaNamespaces does not exist templates/resource-quota.yaml ResourceQuota and NrvqPolicy are namespaced. The pre-flight uses lookup, and first probes for kube-system — if that probe comes back empty the chart knows it is rendering offline (helm template, --dry-run=client) and stays silent rather than failing on missing information. kubectl create namespace <ns> first.
api.resources.limits.memory ÷ api.workers below 128Mi templates/api-deployment.yaml Each uvicorn worker is a full process (~150Mi under load). Mismatched, the pod OOMKills in a loop and the install times out on “Pending termination” — which points at Helm rather than at the two numbers that disagree. The floor is 128Mi rather than 150Mi so it catches the obviously-wrong profile without second-guessing an operator running deliberately close to the line.
otel.enabled: true with an empty otel.endpoint templates/configmap.yaml Trace export with no collector silently drops every span.
oidc.enabled: true with an empty issuer, jwksUrl or audience templates/configmap.yaml Each is required to validate a token.
<component>.autoscaling.enabled: true with neither CPU nor memory target set templates/hpa.yaml An HPA with no metric is invalid.
redis.ha.enabled: true with an empty redis.password templates/redis-ha.yaml The Sentinel cluster takes its password through customConfig and cannot use the chart-generated one.
postgresql.ha.enabled: true with an empty postgresql.password templates/postgresql-ha.yaml CloudNativePG manages its own credential Secret and cannot use the chart-generated one.
postgresql.enabled: false (or redis.enabled: false) with neither host nor existingSecret templates/secret.yaml The chart will not render an address it would only guess wrong.
openshift.enabled: true with the bundled Postgres/Redis still on templates/validate-openshift.yaml The official postgres/redis entrypoints start as root to chown their data dir, so they cannot run under OpenShift’s restricted-v2 SCC. Point the chart at datastores you manage.
agentEgressPolicy.enabled: true with no namespaces, an unknown engine, or the control-plane namespace in the list templates/agent-egress-*.yaml An egress policy that protects nothing, or one aimed at Norviq’s own namespace.
agentEgressPolicy.embeddedDatastores: true while Redis/Postgres are external templates/agent-egress-*.yaml No podSelector can reach an out-of-cluster datastore; add its CIDR to allowedCIDRs.
config.requireStrongSecret: true with fleet.hub.postgresql.password empty or still norviq_dev (or that password inside fleet.hub.pgUrl) templates/fleet-hub.yaml The shipped fleet DB password is a dev default.
webhook.injection.mcp.enabled: true with an empty webhook.injection.mcp.proxyImage templates/webhook-deployment.yaml A Helm required with no fallback — see MCP injection.

One combination deliberately does not fail: webhook.injection.credentialSecret.enabled: true with no namespaces to bind in. See Injected credentials.

helm/norviq/values.schema.json is checked by Helm on install, upgrade, template and lint, so a typo in a load-bearing enum fails with a path and a message before anything is applied. It is deliberately permissive (additionalProperties allowed) so --set of any leaf keeps working; only types, enums and ranges that matter are constrained:

Key Accepted values
config.enforcementMode block | audit — there is no allow/monitor value
config.noPolicyDecision allow | deny
config.opaMode server | subprocess
config.dbSslMode "" | disable | allow | prefer | require | verify-ca | verify-full
config.trustThreshold, config.violationPenalty number, 0–1
webhook.injection.sidecarMode proxy | embedded
webhook.injection.failurePolicy Fail | Ignore
agentEgressPolicy.engine networkpolicy | cilium
baselineClusterPolicy.preset strict | moderate | permissive
images.*.pullPolicy Always | IfNotPresent | Never

baselineClusterPolicy.enforcementMode is not in the values schema. It is validated one layer later by the NrvqPolicy CRD, whose enum is block | audit | escalate — so a typo there fails at apply time rather than at render time.

Key Default Why it matters
api.secretKey change-me-in-production (sentinel) Left at the sentinel the chart auto-generates a strong random JWT signing secret on first install and persists it across upgrades via a live lookup, so upgrades never invalidate existing sessions. Set an explicit value only to pin your own (rotation, multi-cluster fleet trust) — an explicit value always wins.
config.requireStrongSecret true Fail-closed: the API refuses to start on a weak/default/short JWT secret or the default admin password. Turn it off only for a throwaway cluster.
imagePullSecrets [] Empty is correct for the public ghcr.io/norviq-dev images. Set a pull-secret name only if you point images.registry at a private registry. The list is copied verbatim into each pod spec, so entries take the Kubernetes shape: - name: my-pull-secret.
config.dbSslMode "" (derived) Derived from the datastore you actually chose — see below. An explicit value is honoured verbatim.
auth.requireBoundAgentIdentity true Every non-admin caller must present a credential bound to an agent class (403 otherwise). See Local auth.
policyQuotaNamespaces [] Must be set (or the baseline disabled) or the install fails — see above.
flowchart TD
  A["config.dbSslMode set explicitly?"] -->|yes| B["use it verbatim"]
  A -->|no| C{"which datastore?"}
  C -->|"bundled StatefulSet<br/>(postgresql.enabled, no host, no ha)"| D["disable"]
  C -->|"CloudNativePG HA"| E["require"]
  C -->|"external / managed host"| F["require"]

The bundled single-node Postgres has no TLS listener, so require there could only crash on startup — which is exactly what a hard require default used to do to a pure-defaults install. A managed database still gets require with no operator action. values-prod.yaml pins require explicitly.

Key Default What it does
global.imageRegistry "" Air-gapped/private-mirror host prepended to the third-party images only (opa, redis, postgres, the internal-TLS nginx, the helm test curl, and an explicitly-set crdFinalizerCleanup.image). Norviq’s own images — engine, api, ui, webhook and bootstrap — use images.registry instead. Mirror the upstream images preserving their path.
policyQuotaNamespaces [] The tenant-namespace list. Two things read it: a ResourceQuota capping count/nrvqpolicies.norviq.io at 100 per listed namespace, and baselineClusterPolicy, which renders one guard policy per listed namespace. It is also the fallback for agentEgressPolicy.namespaces and webhook.injection.credentialSecret.namespaces. NrvqClass and NrvqConfig are cluster-scoped and bounded by RBAC, not by this quota.
imagePullSecrets [] Pull secrets for the component images.
podAnnotations {} Extra annotations stamped onto every Norviq pod template. Chiefly a rollout lever — a value that changes per build (image ID, git sha) forces the Deployment to roll, which a rebuilt-but-same-tag image otherwise will not.
tests.image curlimages/curl:8.10.1 Connection-probe pod for helm test. Routes through global.imageRegistry.
openshift.enabled false On OpenShift, omit runAsUser/runAsGroup/fsGroup entirely and let the platform assign a UID from the namespace’s range, while keeping runAsNonRoot: true. A chart that pins runAsUser is rejected by restricted-v2 no matter which UID it pins. Leave off for vanilla Kubernetes/AKS/EKS/GKE, where pinning an explicit non-root UID is the stronger posture.

Baseline cluster policy (baselineClusterPolicy.*)

Section titled “Baseline cluster policy (baselineClusterPolicy.*)”
Key Default What it does
baselineClusterPolicy.enabled true For each namespace in policyQuotaNamespaces, renders one NrvqPolicy in the release namespace whose spec.target.namespace is that tenant namespace.
baselineClusterPolicy.name baseline-cluster-guard Rendered as <name>-<ns>.
baselineClusterPolicy.clusterPriority 900 Priority band of the rendered guard.
baselineClusterPolicy.preset strict Which preset the guard runs.
baselineClusterPolicy.enforcementMode audit audit — every baseline control evaluates and records a non-compliance event, and the call proceeds. block — the baseline refuses the call outright. escalate is also accepted by the CRD.

Norviq’s tool-call PEP is cooperative — the agent’s SDK asks the sidecar for a forward/drop decision and the agent executes the tool itself, so a pod that ignores the SDK can reach tools directly. This opt-in default-deny egress policy bounds that at the network layer: an agent pod may egress only to the Norviq API, DNS, and an operator-approved allowlist. It does not replace the PEP (per-call parameter policy still needs the SDK) and it requires a NetworkPolicy-enforcing CNI — Calico or Cilium; kindnet ignores NetworkPolicy entirely.

Key Default What it does
agentEgressPolicy.enabled false Render the policy.
agentEgressPolicy.engine networkpolicy networkpolicy — a portable Kubernetes NetworkPolicy; the allowlist is IP/CIDR only. cilium — a CiliumNetworkPolicy that also supports FQDN allowlisting. Anything else fails the render.
agentEgressPolicy.namespaces [] Namespaces to lock down. Empty reuses policyQuotaNamespaces; if both are empty the render fails. Listing the Norviq control-plane namespace also fails the render.
agentEgressPolicy.allowDNS true Permit DNS egress.
agentEgressPolicy.allowedCIDRs [] Approved tool endpoints. Everything else, including the internet, is denied. Leave empty only if every tool the agent calls lives in the Norviq namespace — otherwise its tool calls fail.
agentEgressPolicy.allowedFQDNs / allowedFQDNPatterns [] engine: cilium only — egress by hostname via Cilium’s DNS-aware toFQDNs. Exact names in the first, wildcards in the second.
agentEgressPolicy.allowedPorts [] Restrict the allowlist to these TCP ports; empty means all ports.
agentEgressPolicy.embeddedDatastores false embedded sidecar mode reaches Redis/Postgres directly rather than the API. Set true to also permit egress to the Norviq datastores. Leave false for the default proxy mode.
Key Default What it does
crdFinalizerCleanup.enabled true A pre-delete hook Job that releases the norviq.io/policy-protection finalizer on every NrvqPolicy while the API is still up. Without it, Helm removes the controller in the same uninstall that needs it, the CRs strand in Terminating, the uninstall blocks until it times out, and the release is left half-removed. Best-effort by design — it never fails the uninstall. Disable only if your cluster forbids hook Jobs, and then strip the finalizers yourself before helm uninstall.
crdFinalizerCleanup.image "" Empty uses the first-party bootstrap image, which is what you want. This defaulted to bitnami/kubectl:1.31 until Bitnami removed those tags from Docker Hub — the pull 404s, the hook never runs, and every CR strands. Set this only to pin an image you mirror yourself; it must provide /bin/sh, curl and jq. An explicit value routes through global.imageRegistry.
Key Default What it does
images.registry ghcr.io/norviq-dev/ Prefix (with trailing slash) prepended to every Norviq component repository. Override to ghcr.io/<your-org>/, an Artifact Registry/ACR path, or "" plus a Docker Hub repository.
images.{engine,api,ui,webhook,bootstrap}.repository norviq-engine One multi-stage build produces all five; they differ only by tag.
images.{engine,api,ui,webhook,bootstrap}.tag engine-latest / api-latest / ui-latest / webhook-latest / bootstrap-latest Per-component tag within the shared repository.
images.*.digest "" Empty in the source tree and stamped by the release workflow. When set it wins over tag, so --version 0.2.5 pulls the exact image built, scanned and signed for 0.2.5. A chart pulled from the OCI registry is digest-pinned even though the in-tree values.yaml is not.
images.{engine,api,ui,webhook,bootstrap}.pullPolicy Always values-prod.yaml sets IfNotPresent for the four app components.
images.redis.repository/tag redis / 7-alpine Bundled Redis image.
images.postgresql.repository/tag postgres / 16-alpine Bundled Postgres image.

The bootstrap image is first-party and carries openssl (self-sign the internal CA) and curl (talk to the API server with the pod’s service-account token). It deliberately does not carry kubectl: vendoring that Go binary pulled its CVEs into Norviq’s own fail-closed Trivy gate, which then refused to publish the image. The hooks only ever GET/PATCH a MutatingWebhookConfiguration and apply a Secret — plain authenticated REST calls. Baking openssl in also removes a network dependency from the install path, so the hooks work air-gapped, and it avoids the apk add that the previous alpine/k8s image needed, which requires root and is rejected outright by OpenShift’s restricted-v2 SCC. Because it is first-party it resolves through images.registry, not global.imageRegistry.

Third-party images (opa.image, images.redis, images.postgresql, config.internalTls.proxyImage, postgresql.ha.image, tests.image, an explicit crdFinalizerCleanup.image) route through global.imageRegistry for air-gapped mirroring.

Key Default What it does
securityContext.enabled true Applies a restricted container profile to the primary app containers and the opa/tls-proxy sidecars. Set inline rather than relying on an external PSA/Kyverno mutation that need not exist on the target cluster. The UI (nginx) is not covered by this default — it gets a component-specific uid/port/emptyDir treatment. Set false to defer entirely to a cluster-level PodSecurity/Kyverno policy.
securityContext.runAsNonRoot true
securityContext.allowPrivilegeEscalation false
securityContext.readOnlyRootFilesystem true A consumer whose container writes its root FS must supply a writable emptyDir.
securityContext.capabilities.drop ["ALL"]
securityContext.seccompProfile.type RuntimeDefault
gracefulShutdown.preStopSleepSeconds 15 preStop sleep on api/engine/webhook. This is the window in which the pod’s removal from the Service endpoints propagates to every kube-proxy. Until it lands, traffic still routes to a terminating pod — and for an enforcement point that traffic does not merely fail, the engine fails closed and the agent’s tool call is refused. Measured (delete one replica under ~1,500 concurrent evaluate calls): at 3, 1 and 31 benign calls refused across two runs; at 15, 0, 0 and 2 across three runs. Never once in the fail-open direction. 0 disables the hook. values-prod.yaml pins 5.
Key Default What it does
api.replicas 2 HA default: survive a single pod restart/drain without an enforcement gap.
api.pdb.enabled / minAvailable true / 1 Keep at least 1 API pod available during voluntary disruptions.
api.autoscaling.enabled false HPA; needs metrics-server. minReplicas/maxReplicas/targetCPUUtilizationPercentage = 2/6/70; targetMemoryUtilizationPercentage "" (do not scale on memory). When enabled the Deployment omits replicas so it does not fight the autoscaler.
api.spread.enabled false podAntiAffinity + topologySpreadConstraints across nodes.
api.rollout.maxSurge/maxUnavailable 1 / 0 Zero-downtime rolling update; needs node headroom for the surge pod.
api.resources.requests 100m / 128Mi Requests drive scheduling and are deliberately left small — a 1000m request did not fit at all on a 2-node/2-vCPU AKS cluster.
api.resources.limits 2000m / 1Gi Only the limits are generous. At a 500m limit this pod was throttled on 92.5% of CFS periods under sustained single-client load — 23.8s of throttle across 400 evaluations, ~60ms added to every call; raising the limit alone took caller-observed p50/p95 from 115/172ms to 62/101ms with zero throttled periods. Memory got the same treatment: a single worker idled at 214Mi against the old 256Mi limit. Lower these only to deliberately cap a noisy tenant — the cost is paid as p95 latency on every enforcement decision.
api.workers 4 Uvicorn worker processes. With one worker a pod served one request at a time regardless of its CPU limit: measured p50 66 → 131 → 291ms at concurrency 1/2/4, and past ~4 concurrent it crossed the engine’s 2s budget and failed closed, refusing 92% of ordinary calls. Track this to the CPU limit; raising it needs memory to match, and the chart fails the render if it does not.
api.port 8080 Container port. Prometheus metrics are served at /metrics on this same port.
api.secretKey change-me-in-production See the production checklist.

The standalone evaluation engine. Mirrors the API’s HA knobs at a smaller scale:

Key Default
engine.replicas 1
engine.pdb.enabled / minAvailable false / 1
engine.autoscaling.enabled false (minReplicas 2, maxReplicas 6, CPU target 70, memory target "")
engine.spread.enabled false
engine.rollout.maxSurge / maxUnavailable 1 / 0
engine.resources req 100m/128Mi, lim 500m/256Mi
engine.port 8282
Key Default What it does
ui.replicas 1 Console pod count.
ui.fleetApiUrl "" Set to /fleet-api on the hub cluster to show the multi-cluster Fleet view (same-origin, proxied by nginx to norviq-fleet-api). Empty on spokes and single-cluster installs — the Fleet view stays gated off.
ui.rollout.maxSurge/maxUnavailable 1 / 0 Zero-downtime rollout.
ui.resources req 50m/64Mi, lim 200m/128Mi Per-pod CPU/memory.
ui.port 8080 The console runs on the unprivileged nginx image (uid 101) binding 8080, so the container is non-root and needs no NET_BIND_SERVICE. Its nginx also proxies /api/* and /ws/* to norviq-api, so one host serves UI and API. The Service and probes reference the named http port, so a change here cascades.
Key Default What it does
webhook.enabled true Deploy the admission webhook server (which also hosts the CRD controller).
webhook.replicas 2 Pod count.
webhook.pdb.enabled / minAvailable false / 1 Off by default because a PDB blocks node drains on a single-node cluster. On a multi-node cluster turn this on — otherwise one drain can evict both replicas and reject pod creation in every governed namespace until they reschedule.
webhook.autoscaling.enabled false 24 replicas @ 70% CPU when enabled.
webhook.spread.enabled true On by default, unlike the other components: the injector is the only workload whose unavailability rejects pod creation. Both replicas on one node turns a routine drain into a pod-creation outage across every governed namespace — observed on a 2-node cluster during a drill. The constraint is soft (whenUnsatisfiable: ScheduleAnyway), so single-node clusters still schedule both.
webhook.rollout.maxSurge/maxUnavailable 1 / 0 Zero-downtime rollout.
webhook.port 8443 Admission server port.
webhook.resources req 50m/64Mi, lim 200m/128Mi Per-pod CPU/memory.
webhook.spiffe.inject false Inject the SPIFFE Workload API socket + workload-api mode into injected workloads. Needs SPIRE and the SPIFFE CSI driver.
webhook.oidc.enabled false The controller mints its API bearer via OIDC client-credentials instead of the HS256 service token.
webhook.oidc.tokenUrl / clientId / clientSecret "" / norviq-webhook / "" Token endpoint, client id and confidential-client secret (rendered into norviq-secrets when enabled).
Key Default What it does
webhook.injection.enabled false Turnkey sidecar injection: renders the MutatingWebhookConfiguration plus a pre/post-install hook Job that self-signs a TLS cert and patches the webhook’s caBundle — no cert-manager. Enable, then label target namespaces norviq-injection=enabled. values-prod.yaml turns this on.
webhook.injection.sidecarMode proxy proxy — the sidecar POSTs each tool call to the central norviq-api /evaluate with a namespace-scoped service JWT; DB and OPA stay centralized. embedded — the sidecar runs its own Redis cache, OPA subprocess and policy loader, for air-gapped/edge use; the chart then wires NRVQ_REDIS_URL/NRVQ_PG_URL through to the injector.
webhook.injection.gateOnlyAgentPods true Gate admission on the pod’s own norviq.io/agent-class label rather than the whole namespace. Without it, norviq-injection=enabled routes every pod created in that namespace through the injector — databases, ingress controllers, batch jobs — so with failurePolicy: Fail a Norviq outage blocks pod creation for workloads Norviq does not govern. CREATE-only, so running pods are never touched.
webhook.injection.failurePolicy Fail Admission posture. Fail is fail-closed: if the injector is unavailable, creation of a pod the webhook routes is rejected, so a routed pod can never start un-guarded. kube-system, kube-public, kube-node-lease and the release namespace are excluded from the selector, as are control-plane=true and AKS-managed namespaces, so this never self-deadlocks. Note what it does not cover: with gateOnlyAgentPods: true, an unlabelled pod is not routed and starts ungoverned regardless of this setting. Set Ignore only for a dev/eval cluster.
webhook.injection.allowPodOptOut true Honour the per-pod opt-out (norviq-injection=disabled label / norviq.io/skip-injection annotation). Set false so a routed pod cannot self-exempt. This is not namespace-uniform while gateOnlyAgentPods is true — omitting norviq.io/agent-class is an earlier exit this does not close. Pair with RBAC on the agent-class label as well as on the opt-out label/annotation.
webhook.injection.fallbackMode allow Data-plane posture — see below.
webhook.injection.sidecarResources.proxy req 50m/64Mi, lim 200m/128Mi A thin forwarder.
webhook.injection.sidecarResources.embedded req 200m/256Mi, lim 2000m/384Mi Measured, not guessed. The request sits above the measured peak (214Mi after 80 evaluations) on purpose — an enforcement sidecar evicted under node pressure fails the workload it guards. The 2000m CPU limit is load-bearing: measured back-to-back on AKS, proxy p50 59.0ms / p95 92.7ms, embedded @ 500m p50 72.0ms / p95 93.1ms with 58.9% of CFS periods throttled, embedded @ 2000m p50 30.7ms / p95 58.0ms with 0% throttled. At 500m, embedded was slower than the default it exists to beat.

Injected credentials (webhook.injection.credentialSecret.*)

Section titled “Injected credentials (webhook.injection.credentialSecret.*)”
Key Default What it does
credentialSecret.enabled true Deliver the injected sidecar’s NRVQ_API_TOKEN and mTLS client cert/key through a Secret + valueFrom.secretKeyRef instead of literal value: entries in the pod spec. Kubernetes deliberately excludes Secrets from the built-in view ClusterRole (view grants get pods, not get secrets), so a credential in the pod spec is readable by anyone a read-only grant was considered safe for — and it lands in etcd, kubectl describe and any GitOps diff.
credentialSecret.required false The webhook needs namespaced Secret write; the chart renders a Role + RoleBinding per namespace (not a ClusterRole — the injector already holds admission-time mutation power over every pod in labelled namespaces, and cluster-wide secret write would make compromising it far more valuable). If it cannot write the Secret it falls back to literal pod env and logs NRVQ-WHK-4049 at ERROR for every injected pod rather than refusing to schedule, because this webhook runs failurePolicy: Fail. Set true to refuse instead.
credentialSecret.namespaces [] Namespaces labelled norviq-injection=enabled that get the Role/RoleBinding. Empty defaults to policyQuotaNamespaces.

If both lists are empty the chart renders no Role/RoleBinding and does not fail. That is deliberate: “injection on, no tenant namespaces declared” turned out to be a mainstream configuration, and breaking every such upgrade to close a read-exposure was the wrong trade. The exposure is not silent — the per-pod NRVQ-WHK-4049 ERROR names both the consequence and the remedy.

webhook.injection.mcp.* gives Model Context Protocol traffic the same zero-code-change deal the sidecar gives the SDK. A pod opts in per container:

metadata:
annotations:
norviq.io/mcp-servers: "filesystem,github" # containers whose command IS an MCP server
norviq.io/mcp-server-id.github: "github-prod" # optional stable pin id (default: the container name)

The injector rewrites each named container’s command to exec the proxy, and an init container copies that proxy in from proxyImage — so the MCP server’s own image needs nothing installed.

Two admission rules follow from “never leave a named server ungoverned”: a name that matches no container, and a container with no explicit command (its argv is the image ENTRYPOINT, which admission cannot see), are both denied rather than skipped.

Key Default What it does
webhook.injection.mcp.enabled false Turn on MCP injection.
webhook.injection.mcp.proxyImage "" Required when enabled, with no fallback — the render fails without it. Must hold the relocatable payload at proxySourcePath; build it with scripts/mcp-proxy-payload.Dockerfile and confirm it runs in the images your MCP servers actually use with scripts/mcp-proxy-payload-verify.sh. Defaulting this to the engine image was wrong — that image carries the norviq package, not the frozen payload, so the init container’s cp found nothing and every governed pod failed at start.
webhook.injection.mcp.proxySourcePath /opt/norviq/mcp-proxy Where the payload lives inside proxyImage.
webhook.injection.mcp.pinStore control-plane Where Gate-A definition pins live. control-plane is the right posture in a cluster: pins are approvals, so they belong with policy — tenant-scoped, RBAC’d, audited, console-visible, and not lost when a pod restarts. memory/file exist for air-gapped single-process use.
webhook.injection.mcp.pinMode tofu tofu trusts a server’s definitions on first sight; strict quarantines them until an operator approves. strict is safer and needs an approval workflow to be practical.

Everything under webhook.injection.mcp.* is inert unless the MCP proxy is actually running, so turning it on changes nothing about existing SDK-governed traffic.

Key Default What it does
redis.enabled true true runs a bundled Redis (single StatefulSet, or Sentinel under HA). false means bring your own — nothing is deployed and you set host (and usually existingSecret).
redis.host "" External hostname. Empty uses the bundled service. Required when enabled: false.
redis.existingSecret / existingSecretKey "" / url Production credential path: a Secret you manage holding the full Redis URL. When set, the chart never renders or generates a Redis credential, so it never passes through values, --set (which lands in helm history and your shell history), or the chart’s own Secret.
redis.replicas 1 Single instance.
redis.ha.enabled / replicas / serviceName false / 3 / norviq-redis-ha Gated, not live-validated on a single node. Renders a Sentinel topology; requires the operator/Sentinel chart pre-installed and an explicit redis.password. The chart renders that Service itself, selecting the operator’s redisfailovers-role: master pod label so it tracks the current master through failover with no client change.
redis.resources req 100m/128Mi, lim 300m/256Mi Per-pod CPU/memory.
redis.port 6379
redis.password "" (generated) Empty means the chart generates a strong random password on first install into norviq-secrets and reuses it on every upgrade. Read it with kubectl get secret norviq-secrets -o jsonpath='{.data.NRVQ_REDIS_PASSWORD}' | base64 -d. Required explicitly when redis.ha.enabled=true.
redis.storage 1Gi PVC size.
Key Default What it does
postgresql.enabled true true runs a bundled Postgres. false means bring your own (RDS, Cloud SQL, Azure Database) — the usual production shape.
postgresql.host "" External hostname. Empty uses the bundled service. Required when enabled: false (or supply existingSecret).
postgresql.existingSecret / existingSecretKey "" / url A Secret you manage holding the full Postgres URL. The URL carries user/password/host/port/database, so those keys are then unused.
postgresql.replicas 1 Single instance.
postgresql.ha.enabled / instances / serviceName false / 3 / norviq-postgresql-ha-rw Gated, not live-validated on a single node. Renders a CloudNativePG Cluster and points the API’s PG URL at its service; requires the CNPG operator and an explicit postgresql.password.
postgresql.ha.image ghcr.io/cloudnative-pg/postgresql:16 The CNPG operand image — deliberately not images.postgresql. CNPG hardcodes postgres UID/GID 26 and runs initdb as that UID; in postgres:16-alpine the postgres user is UID 70, so initdb aborts with could not look up effective user ID 26 and the cluster never bootstraps.
postgresql.resources req 200m/256Mi, lim 500m/512Mi Per-pod CPU/memory.
postgresql.port 5432
postgresql.database / username norviq / norviq Bundled DB name and user.
postgresql.password "" (generated) Empty means the chart generates a strong random password into norviq-secrets and reuses it on every upgrade — Postgres only honours POSTGRES_PASSWORD at initdb, so rotating it under an existing PVC would strand the database, and the chart never does. Read it with kubectl get secret norviq-secrets -o jsonpath='{.data.NRVQ_PG_PASSWORD}' | base64 -d. Required explicitly when postgresql.ha.enabled=true.
postgresql.storage 5Gi PVC size.
Key Default What it does
opa.enabled true Run OPA as a long-lived sidecar in every api/engine pod (localhost:8181) instead of forking opa eval per call. Each replica gets its own OPA — no shared single point of failure.
opa.image openpolicyagent/opa:1.19.1-static Pinned (not latest-static) so the running OPA version stays matched to the one scripts/gen-opa-capabilities.py generated helm/norviq/files/opa-capabilities.json from. A drifted OPA could carry a different builtin set than norviq/engine/opa_client.py::_check_capabilities validates against.
opa.port 8181
opa.resources.requests 50m / 64Mi
opa.resources.limits 1500m / 512Mi Sized for the compile burst, not the query path. Rego compilation is a CPU- and memory-bound burst that happens whenever a policy is created or edited, and OPA stops answering queries while it recompiles its module store. Throttled at 250m that burst ran past the engine’s 2s budget, so the first tool call after every policy change was wrongly blocked (evaluator_timeout): measured on AKS, 250m → 0/5 correct on the first call after a save, 1500m → 5/5. Memory saw the same — 117Mi against a 128Mi limit at idle, and OOMKilled during a concurrent policy push. Steady-state evaluation uses ~7m CPU and ~117Mi either way.

config.opaMode (below) selects whether the engine talks to this sidecar (server, the default) or falls back to a per-call opa eval fork (subprocess).

Key Default What it does
config.internalTls.enabled true Zero-touch internal mTLS for control-plane traffic — on by default, no operator action. See the note below.
config.internalTls.proxyImage nginx:1.27-alpine The API pod’s TLS terminator sidecar.
config.internalTls.proxyResources req 10m/24Mi, lim 64Mi memory No CPU limit on the proxy.
config.logLevel INFO values-dev.yaml sets DEBUG.
config.enforcementMode block Global default enforcement mode (block | audit). Individual NrvqPolicy objects override per target; values-dev.yaml sets audit.
config.noPolicyDecision allow See the caution below.
config.opaMode server server — HTTP to the per-pod OPA sidecar. subprocess — per-call opa eval fork (rollback path).
config.requireStrongSecret true See the production checklist.
config.trustThreshold 0.7 Agent trust score below which evaluation escalates.
config.violationPenalty 0.05 Trust-score deduction per policy violation.
config.rateLimit 60 Per-identity evaluation throttle (non-read calls per 60s). Wired since 0.2.4 — see the note below.
config.httpRateLimitTrustedProxyHops 1 See HTTP rate limiting.
config.httpRateLimitTrustedProxyCidrs ["127.0.0.0/8", "::1/128"] See HTTP rate limiting.
config.inprocCacheTtlS 0 (off) Opt-in per-pod L1 cache — see In-process cache.
config.inprocCacheMax 8192 Hard per-cache entry cap, bounding per-pod memory under identity/namespace churn.
config.dbSslMode "" (derived) See the derivation above.
config.dbPoolMaxOverflow / dbPoolTimeout / dbCommandTimeout 10 / 10 / 10 DB connection pool tuning.
config.spiffeMode mock mock — env-var identity, no SPIRE needed. workload-api — real SPIFFE SVID resolution, fail-closed; requires SPIRE on the cluster plus pyspiffe.
config.spiffeSocket /spiffe-workload-api/spire-agent.sock Where the SPIFFE CSI driver publishes the agent socket.
config.spiffeCsi.enabled false Gates the csi.spiffe.io volume on api/engine pods. Off by default so deploys without SPIRE are unaffected — the volume would otherwise wedge pod creation with no driver or registration.

norviq/config.py carries roughly a hundred tunables and the chart deliberately surfaces only the ones an operator normally needs. config.extraEnv is the supported way to set the rest: every key is rendered verbatim into norviq-config before the named keys, so a named value still wins where both are set.

config:
extraEnv:
NRVQ_HTTP_RATE_LIMIT_REDTEAM_PER_WINDOW: "60"
NRVQ_OPA_TIMEOUT_MS: "400"

It is not present in values.yaml (it defaults to an empty dict) and it is not in the values schema, so nothing validates the keys or values you put here — a typo is silently ignored by the settings model. Use it to raise a limit for a test environment, or to try a setting before it earns a named value; patching the ConfigMap by hand instead is reverted by the next helm upgrade.

Separate from the evaluation throttle above, the API runs a Redis-backed fixed-window HTTP throttle in front of every route (shared correctly across HA replicas). It is on by default with a 60s window, keyed per identity with a per-client-IP fallback for unauthenticated requests.

Route Requests per 60s window
/api/v1/evaluate 3000 — the hot enforcement path, given a high ceiling so it is never the bottleneck
/api/v1/auth/login 20, per IP (pre-auth route)
Policy dry-run 20
Red team 15
Everything else 300
/healthz, /readyz, /metrics never throttled

Only the two trusted-proxy knobs are exposed as Helm values; the ceilings above are settable through config.extraEnv (NRVQ_HTTP_RATE_LIMIT_EVALUATE_PER_WINDOW and friends).

X-Forwarded-For is client-writable, so trusting its left-most entry would let a caller rotate the header and never fill a bucket — a throttle bypass, worst on the pre-auth login route. Ignoring it entirely would collapse every caller behind a proxy into one bucket, so a single abuser throttles everyone. The header is believed only when both the TCP peer is a trusted proxy and the chain is long enough.

  • config.httpRateLimitTrustedProxyCidrs (["127.0.0.0/8", "::1/128"]) — which peers may be believed. The default is exactly the in-pod nginx that config.internalTls runs in front of the API. Widen it only to proxies you operate: anything in this list can set any client’s rate-limit identity.
  • config.httpRateLimitTrustedProxyHops (1) — the Nth entry from the right to use. 1 matches the shipped tls-proxy, which replaces the header (proxy_set_header X-Forwarded-For $remote_addr), leaving exactly one unforgeable entry. Raise it only if you add more appending proxies in front, and widen the CIDR list to match. 0 disables XFF entirely.

A workload that bypasses the proxy and hits the API port directly is not loopback, so its XFF is ignored and the forgeable path is unreachable for it.

config.inprocCacheTtlS is an opt-in per-pod L1 cache on the enforcement hot path, in seconds. 0 (the default) disables it; the recommended production value is 5. Measured on a 2-node AKS cluster: warm read p50 21.9ms → 3.2ms (−84%), floor 14.5ms → 1.4ms.

What it caches, precisely:

  • namespace posture, the stored trust score, and the trust calculator’s history/profile reads — these use the TTL as-is, so this value is their convergence bound;
  • the pre-override base policy decision — additionally clamped to the Redis eval TTL (5s) and cleared eagerly on any policy change, so it can never be staler than the Redis eval cache.

Never cached: the admin freeze (agent_frozen) and the trust cap (agent_trust_override). Those are read fresh on every call, so an incident-response freeze takes effect on the very next call regardless of this setting — the kill switch is not on the caching path.

What the TTL costs you: a posture or threshold change made from the console, and any trust-input change, is not observed by an already-warm pod until the entry expires. Enable deliberately.

The ConfigMap reads this key with dig, not default, so an explicit 0 genuinely disables the cache rather than being treated as “unset” and silently re-enabled.

One hourly background pruner enforces every window below. 0 (or negative) on any window disables that window — keep forever. The console’s Settings page shows the effective values, read-only.

Data type Default Helm value What 0 means
Audit log 30 days auditRetentionDays keep forever
Compliance coverage trend points + export events 30 days coverageSnapshotRetentionDays keep forever
Asset-graph snapshots newest 10 per namespace graphSnapshotKeepPerNamespace keep all
Agent registry entries removed 90 days after last_seen agentRegistryRetentionDays never removed
API keys (newly created) 90-day expiry apiKeyDefaultTtlDays new keys never expire
Policy-intent drafts 14 days real / 24 h test-class / cap 50 per namespace draftTtlDays, draftTtlTestHours, draftCapPerNamespace keep forever / uncapped
Drafts endpoint page size 15 draftsPageSize
Policy versions last 20, plus anything newer than 90 days policyVersionKeepCount, policyVersionKeepDays keep forever
Red-team detail newest 1 run, or any within 7 days redteamDetailKeepRuns, redteamDetailKeepDays keep forever
Red-team summaries newest 20 runs, or any within 30 days redteamSummaryKeepRuns, redteamSummaryKeepDays keep forever
Red-team history page size 20 redteamHistoryPageSize
Enforcing policies never expire
  • Audit log — the console never displays a window longer than 30 days, which is what the default matches. For SOC 2 / ISO evidence windows raise auditRetentionDays to 90–365, and use the audit-evidence export packs for durable point-in-time evidence regardless of the live window.
  • Asset-graph snapshots — readers only ever load the newest snapshot; keeping 10 per namespace just stops unbounded growth.
  • Agent registry — an admin can remove one immediately with DELETE /api/v1/agents/{spiffe_id}.
  • API keys — the 90-day default applies to newly created keys only, with a per-key override at creation (including never-expires). Pre-existing keys are untouched. Revocation is a soft revoke — revoked keys are kept, not deleted.
  • Red team — the latest run per namespace is never pruned.
  • Policy versions — pruning never touches the current enforcing version.
  • Enforcing policies never expire, deliberately. An expiring security control would be silent un-protection, so policy expiry is not a retention knob.

The primary no-IdP login path: username/password against a local, bcrypt-hashed account.

Key Default What it does
auth.enabled true Set false to disable local login entirely (SSO/CLI-only).
auth.adminUsername admin Seeded admin username.
auth.adminPassword norviq (sentinel) Left at the sentinel the chart auto-generates a strong random first password, persisted across upgrades. Retrieve it with kubectl get secret norviq-secrets -o jsonpath='{.data.NRVQ_AUTH_ADMIN_PASSWORD}' | base64 -d. You are forced to change it on first login either way. With config.requireStrongSecret: true the API refuses to start while the password is still the literal norviq.
auth.sessionTtlSeconds 3600 Session token TTL.
auth.loginMaxAttempts / loginWindowSeconds 5 / 300 Brute-force lockout: after N failed logins for a username within the window, further attempts 429 until it passes.
auth.minPasswordLength 12 Enforced on a new password at change time.
auth.requireBoundAgentIdentity true Every non-admin caller must present a credential bound to an agent class, or the API returns 403.

Default-off; the API keeps validating legacy HS256 tokens until an IdP is wired in. Enabling adds RS256/ES256 validation alongside HS256 — set legacyHs256Enabled: false at cutover.

Key Default What it does
oidc.enabled false Enabling requires issuer, jwksUrl and audience, or the render fails.
oidc.issuer / oidc.audience "" Token iss / the API’s client-audience id.
oidc.consoleClientId "" The public (browser/SPA) client id the console uses for Auth Code + PKCE. Register a public client with redirect URI <console>/auth/callback. When set, the UI renders “Sign in with SSO” with no rebuild — it is injected at runtime.
oidc.providerName "" Human-readable IdP name shown in login copy, e.g. Okta.
oidc.jwksUrl "" IdP JWKS endpoint.
oidc.groupClaim groups Claim holding the user’s group list.
oidc.legacyHs256Enabled true Keep validating local HS256 tokens alongside OIDC.
oidc.groupMappings {} Map IdP groups to Norviq (role, namespace), e.g. {"norviq-admins":{"role":"admin"},"team-a":{"role":"viewer","namespace":"team-a"}}.

The chart ships the norviq-admin / norviq-policy-editor / norviq-viewer ClusterRoles but no subject bindings. Map them to your IdP groups or ServiceAccounts with rbac.exampleBindings.enabled (false) and rbac.bindings ([]). Each binding takes role (one of the three ClusterRoles), kind (Group | User | ServiceAccount), name, and namespace for a ServiceAccount:

rbac:
exampleBindings:
enabled: true
bindings:
- role: norviq-admin
kind: Group
name: norviq-platform-admins
- role: norviq-policy-editor
kind: ServiceAccount
name: ci-policy-bot
namespace: norviq
Key Default What it does
siem.enabled false Outbound audit forwarder.
siem.webhookUrl "" Where new audit rows are streamed.
siem.format ndjson ndjson | syslog.
siem.pollIntervalSeconds 30 How often the API drains new rows to the webhook.

The authenticated GET /api/v1/audit/export endpoint is always available regardless of this setting, so a forwarder is a convenience, not the only export path.

Prometheus metrics are always served at /metrics on the norviq-api Service port (api.port), regardless of otel.enabled. There is no separate metrics port. The knobs below only control how your Prometheus discovers that endpoint.

Key Default What it does
otel.enabled false OTLP trace export to a collector over gRPC. No collector ships with Norviq.
otel.endpoint "" OTLP gRPC endpoint, e.g. http://otel-collector:4317. Required when otel.enabled — the render fails otherwise, so spans are never silently dropped into a void.
otel.metrics.scrapeAnnotations false Add prometheus.io/scrape annotations to the norviq-api Service.
otel.metrics.serviceMonitor.enabled false Render a Prometheus-Operator ServiceMonitor. Requires the monitoring.coreos.com CRDs.
otel.metrics.serviceMonitor.interval / scrapeTimeout 30s / 10s Scrape cadence.
otel.metrics.serviceMonitor.additionalLabels {} Extra labels so your Prometheus’s serviceMonitorSelector picks this up, e.g. {release: kube-prometheus-stack}.

Off by default, following the Grafana/Istio convention: the out-of-the-box access path is kubectl port-forward svc/norviq-ui. Because the console’s nginx already proxies /api/* and /ws/* to norviq-api, a single host serves both the UI and the API — no second host, no path rewrite.

Key Default What it does
ingress.enabled false Render an Ingress for the console Service.
ingress.className nginx ingressClassName — the controller that fulfils the Ingress.
ingress.host norviq.example.com Hostname routed to the console.
ingress.tls false Serve HTTPS. The chart deliberately does not generate an ingress certificate — a self-signed cert is useless for real HTTPS and hides a false “TLS works”. Set true and either pre-create the secret named in tlsSecretName or issue it via a cert-manager annotation; with tls: true and no such cert the ingress falls back to the controller’s default self-signed cert.
ingress.tlsSecretName norviq-ingress-tls TLS Secret the Ingress terminates with.
ingress.annotations {} Extra annotations — cert-manager.io/cluster-issuer, proxy body size, timeouts.

Read-only. Everything is off by default — a single-cluster install renders zero fleet resources and behaves exactly as a standalone install. The relay is fire-and-forget: a hub outage never affects local enforcement on any spoke.

Spoke side (fleet.enabled: false) runs an in-process relay pushing agent and audit rollups to the hub.

Key Default
fleet.clusterId / clusterName / region ""
fleet.apiUrl "" — hub base URL the relay posts to
fleet.relayIntervalSeconds 60
fleet.pullIntervalSeconds 60 — how often the spoke pulls, verifies and applies the signed policy bundle
fleet.staleAfterSeconds 180 — hub-side heartbeat staleness threshold
fleet.residency false — keep raw audit in-cluster; rollups still leave, hub drill-down is blocked
fleet.bundlePubkey "" — the fleet signing public key (trust root). Empty means the spoke applies no bundle, which is fail-closed, not fail-open
fleet.oidc.tokenUrl / clientId / clientSecret "" — relay→hub auth, with an HS256 break-glass fallback if unset

Two further spoke keys are read by the ConfigMap but are not listed in values.yaml, so they only exist if you set them: fleet.clusterEndpoint (a console URL for hub deep-links) and fleet.labels (a map rendered as NRVQ_FLEET_CLUSTER_LABELS). Both default to empty.

Hub side (fleet.hub.enabled: false) renders the fleet-api control plane plus a dedicated fleet-postgresql, only on the cluster hosting the control plane.

Key Default
fleet.hub.pgUrl postgresql://norviq:norviq_dev@fleet-postgresql:5432/norviq_fleetfails the render with config.requireStrongSecret: true while it carries the shipped norviq_dev password
fleet.hub.signingKey / signingKeySecretName "" — the fleet signing private key PEM, hub only, distinct from api.secretKey. Prefer the pre-created Secret over inlining
fleet.hub.bundleTtlSeconds 900
fleet.hub.replicas / storage 1 / 5Gi
fleet.hub.resources req 50m/128Mi, lim 500m/512Mi
fleet.hub.pdb / autoscaling / spread all off (pdb.minAvailable 1; HPA 26 @ 70%) — values-prod.yaml turns them on when the hub is enabled (3 replicas, PDB minAvailable: 2, HPA 3–6, spread on)
fleet.hub.postgresql.* database norviq_fleet, username norviq, password norviq_dev, port 5432, storage 5Gi, ha.enabled false (instances 3, serviceName fleet-postgresql-ha-rw), resources req 50m/128Mi, lim 500m/512Mi