Skip to content

How it works

Norviq puts a policy decision in front of every tool call an LLM agent makes. Something in the call path intercepts the call, the call is tied to the workload’s identity, the identity’s policy is evaluated by OPA, and the result — allow / block / escalate / audit — comes back before the tool body runs.

This page is the architecture: what sits where, what decides what, what is cached, what is recorded, and what happens on each failure path. For the mental model behind the policies themselves — agent classes, tiers, trust score — read Concepts. To install it and watch a decision flip, follow Get started.

Norviq separates the component that sits in the call path from the component that decides. The thing in the path is a policy enforcement point (PEP). The thing that decides is a policy decision point (PDP), and there is exactly one of it: POST /api/v1/evaluate on norviq-api.

Three PEPs ship, and all three call the same endpoint with the same contract:

PEP How it runs What the agent has to change
Injected sidecar The mutating admission webhook adds a container to agent pods. Tool calls reach it over a Unix domain socket at /var/run/norviq/norviq-proxy.sock, or over POST /v1/evaluate on 127.0.0.1:8282 for runtimes without UDS. Nothing — the pod is rewritten at admission. See Sidecar injection.
SDK interceptor norviq Python package, in-process. protect() wraps the tools; the interceptor raises NorviqBlockError / NorviqEscalateError instead of running the tool body. A wrapper call. See SDK integration.
MCP action firewall python -m norviq.mcp, a proxy between an MCP host and an MCP server — stdio (-- <server command>) or streamable HTTP (--http --upstream …). Nothing in the agent; the server command is fronted.
flowchart TB
    subgraph pods["Agent namespaces"]
        direction TB
        sdk["Agent process<br/>SDK protect() — in-process PEP"]
        subgraph pod2["Agent pod"]
            code["Agent container"]
            side["Injected sidecar (PEP)<br/>UDS + :8282 fallback"]
            code -->|"tool call"| side
        end
        subgraph pod3["Agent pod with an MCP server"]
            host["MCP host"]
            mcp["norviq.mcp firewall (PEP)<br/>Gate A + Gate B"]
            srv["MCP server"]
            host -->|"JSON-RPC"| mcp -->|"forwarded"| srv
        end
    end
    subgraph plane["norviq namespace"]
        pdp["norviq-api — the PDP<br/>evaluator in-process"]
        opa["OPA sidecar<br/>127.0.0.1:8181"]
        pg[("Postgres — policies, audit")]
        rd[("Redis — trust, eval cache")]
        pdp <-->|"query"| opa
        pdp --> pg
        pdp --> rd
    end
    sdk -->|"POST /api/v1/evaluate"| pdp
    side -->|"POST /api/v1/evaluate"| pdp
    mcp -->|"POST /api/v1/evaluate"| pdp

The PEPs are cooperative. The agent’s runtime asks for a decision and the agent’s runtime honours it; the sidecar does not execute tools on the agent’s behalf, and nothing intercepts a syscall. A process that never routes through a PEP is not governed. Two chart-level controls bound that:

  • webhook.injection.failurePolicy: Fail (default) plus webhook.injection.gateOnlyAgentPods: true (default) means a pod carrying norviq.io/agent-class in a norviq-injection=enabled namespace cannot be created while the injector is down — a routed pod can never start un-injected. A pod without that label is not routed and starts ungoverned, by design.
  • agentEgressPolicy.enabled (default false) renders a default-deny egress NetworkPolicy — or a CiliumNetworkPolicy when agentEgressPolicy.engine: cilium, which adds FQDN allowlisting — so an agent pod can only reach the Norviq API, DNS, and destinations you list. It needs a NetworkPolicy-enforcing CNI (kindnet ignores NetworkPolicy) and it does not replace the PEP: per-call parameter policy still requires the call to arrive at /evaluate.

A tool call moves through four stages.

flowchart TB
    i["1 · Intercept<br/>sidecar UDS · SDK protect() · MCP proxy<br/>→ POST /api/v1/evaluate"]
    id2["2 · Identify<br/>identity fields REWRITTEN from the caller's credential<br/>spiffe://norviq/ns/&lt;ns&gt;/sa/&lt;sa&gt; → (namespace, agent_class, workload)"]
    ev["3 · Evaluate<br/>collect every candidate module (class · floors · baselines · tiers · overlays)<br/>one OPA query each, 2s ceiling → highest priority wins, overlays tighten only"]
    en["4 · Enforce<br/>trust overrides → namespace posture → block attribution → PEP refusal folded in<br/>PolicyDecision{decision, rule_id, reason, trust_score}"]
    out["allow / audit → tool runs<br/>block / escalate → tool does not run"]
    rec["Audit row · trust update · agent registry · asset + attack graphs · metrics · /ws/audit"]
    i --> id2 --> ev --> en --> out
    en --> rec

All three PEPs post the same ToolCallEvent: tool name, tool params, session id, framework, call depth, the resolved identity, and — for MCP traffic — the discovery context. framework is the decision source and it is what the audit log filters on: the injected sidecar sends sidecar, the MCP firewall sends mcp, and the SDK adapters send their own name (langchain, langgraph, crewai, autogen, semantic-kernel).

Interception carries the call; it does not decide. The one exception is a PEP-side refusal (MCP Gate A, MCP schema conformance), which is reported to the PDP as pep_decision: "block" and folded into the recorded decision tighten-only — there is no value that means “allow”.

The engine resolves which agent this is from the caller’s credential, not from the request body. The API rewrites namespace, agent_class, spiffe_id and workload in the submitted identity with the values bound to the credential, and if the caller presents a Norviq SVID, the namespace attested by the SVID wins over both. Dropping a field is as powerful as substituting one — an omitted agent_class would silently fall back to the looser __baseline__ — so the credential’s values are written over the body’s rather than merely compared.

Identity has the shape spiffe://norviq/ns/<namespace>/sa/<service-account> and resolves to (namespace, agent_class), which keys every policy lookup and trust bucket.

Two resolution modes:

  • config.spiffeMode: mock (default) — identity comes from the pod environment. The injector stamps NRVQ_NAMESPACE from the pod’s real namespace and NRVQ_AGENT_CLASS from its norviq.io/agent-class label (plus NRVQ_WORKLOAD when the owning workload is resolvable), so an agent still cannot choose its own class — but the trust root is the admission webhook, not a cryptographic attestation. The webhook deliberately never injects NRVQ_SERVICE_ACCOUNT, so the id is the deterministic spiffe://norviq/ns/<ns>/sa/default.
  • config.spiffeMode: workload-api — a real X.509 SVID fetched from the SPIFFE Workload API socket. The SVID wins over the environment and the resolver fails closed on any socket or SVID error. Requires an existing SPIRE plus the SPIFFE CSI driver (config.spiffeCsi.enabled: true); Norviq consumes SPIRE, it does not bundle one. See SPIFFE/SPIRE identity.

auth.requireBoundAgentIdentity defaults to true — a credential that names no identity cannot borrow one from the request body.

A spiffe_id that does not start with spiffe:// is rejected before any trust operation and blocks with rule_id=invalid_spiffe_identity.

For one identity the engine does not run one policy. It collects every candidate module that applies and evaluates each as a separate OPA query with a 2-second ceiling:

Candidate Key Role
Agent-class policy <ns>:<class> the policy you authored for this class
Baseline controls <ns>:__controls__ the tuned baseline control set — a floor, not a tier
Egress rules <ns>:__egress__ compiled destination rules — floor
MCP server registry <ns>:__mcp__ compiled from your MCP server decisions — floor
Namespace baseline <ns>:__baseline__ namespace-wide
Cluster baseline __cluster__:__baseline__ the chart’s baselineClusterPolicy
Namespace tier <ns>:namespace:<ns> every call in the namespace
Workload tier <ns>:deployment:<workload> only when the caller names its workload
Sector pack <ns>:__pack__ overlay
Tool guardrail <ns>:__guardrail__ overlay
Pack override / weaken <ns>:__pack_override__, <ns>:__pack_weaken__ overlays
Compliance remediation <ns>:<class>__remediation__ overlay

Base tiers resolve by highest priority wins. Overlays are tighten-only: an overlay is taken only when it is stricter than the base winner, so a class policy authored at priority 100 cannot discard the controls floor sitting at priority 2. The one relaxation path is __pack_weaken__, and it is scoped to the pack family alone — it can never relax a guardrail, a remediation overlay, or a control floor. Overlay-ness is recorded when the candidate is constructed, never re-derived from the key string, so an agent class that happens to end in a reserved suffix cannot be misclassified.

Full precedence rules, priority bands and the two rules that are easy to get wrong are in Concepts → Policy tiers, floors and overlays.

Evaluation produces a PolicyDecision. Three fields carry the verdict: decision, rule_id (which rule fired — never blank on a block; an unattributed block is clamped and alarmed), and reason (the sentence the policy author wrote; the API refuses to save a policy without one). The decision also carries the trust score, category and dominant signal.

Post-resolution, in order:

  1. Rate-limit throttle — the per-identity policy limiter, code default 60 calls per 60 s window (evaluator_rate_limit_per_window). Read-classified tools are exempt, but only when the tool classifier agrees the tool is a read — prefixing get_ onto delete_all_records does not buy an exemption. The chart’s config.rateLimit value is rendered into the ConfigMap as NRVQ_RATE_LIMIT, and as of 0.2.4 that name binds to this limiter, so tuning it in values.yaml works. On 0.2.3 and earlier nothing read the variable and extra="ignore" dropped it, leaving the limiter pinned at 60/60s no matter what the chart said — and because the default was also 60, the value looked applied.
  2. Trust overridesfrozen forces block with rule_id=trust_frozen; low trust turns an allow into escalate with rule_id=escalate_low_trust.
  3. Namespace posture — a namespace set to monitor softens block and escalate to audit and rewrites the rule id to monitor_would_block:<original>. trust_frozen is exempt, and so is rate_limit_exceeded while NRVQ_MONITOR_EXEMPT_RATE_LIMIT stays at its default true — “do not block on policy” is a statement about policy judgements, not a request for unbounded call volume.
  4. PEP refusal fold — applied last, so monitor mode can never record a call that was refused as one that merely would have been.

What each verdict does at the PEP:

Decision Tool runs? Notes
allow yes
audit yes recorded, never interrupted — this is what monitor mode produces
escalate no the SDK raises NorviqEscalateError, the sidecar drops. Norviq ships no release-the-held-call workflow in 0.2.5: to a caller, escalate is a refusal recorded differently from a block.
block no the SDK raises NorviqBlockError, the sidecar drops

This is the section to read before you put Norviq in a production request path. Two shipped defaults choose availability over enforcement, deliberately, and both are visible rather than silent. When one of these paths fires for real, Troubleshooting is the diagnostic companion to this table — health states, which rule_ids a producer actually writes, and where the evidence lives.

Condition Shipped behaviour rule_id
Namespace has no policy loaded allowconfig.noPolicyDecision: allow (none — the call is not governed)
Set config.noPolicyDecision: deny block no_policy_loaded
Central API genuinely unreachable (5xx, timeout, connect error) from a proxy sidecar allowwebhook.injection.fallbackMode: allow thin_proxy_fail_open
Same, from the SDK / MCP proxy allowNRVQ_SDK_FALLBACK_MODE engine_unavailable_fallback
Central API answers 4xx (expired token, malformed request) block, always — the fallback mode does not apply engine_rejected_request (SDK), thin_proxy_fail_closed (sidecar)
OPA query times out (2 s) block evaluator_timeout
Unhandled evaluator error block evaluator_fallback
Malformed or spoofed SPIFFE id block invalid_spiffe_identity
Undecodable body at the sidecar’s HTTP fallback drop (sidecar-local)

Three things follow from that table and are worth stating plainly.

A namespace with no policy is not governed. Installing the chart into a namespace does not take its tool calls to zero. Deny-by-default is available — set config.noPolicyDecision: deny, or set the baseline controls for that namespace to deny — but it is an explicit decision, not the consequence of having installed the chart and not yet configured anything.

Fail-open during an outage is a real trade. For the duration of a Norviq outage, calls proceed unjudged. It is defaulted that way because a security control whose failure mode is a cluster-wide agent outage gets pulled out of the request path, which protects nobody. The mitigation is visibility: every such call carries a distinct rule_id, so you can count it, alert on it, and see exactly which calls went unjudged and for how long. Alert on thin_proxy_fail_open and engine_unavailable_fallback.

Terminal window
# Calls that went unjudged in the last 24h.
# -o is a GROUP option — it must come before the subcommand.
norviq -o json audit list --range 24h --limit 100 \
| jq '[.[] | select(.rule_id == "thin_proxy_fail_open"
or .rule_id == "engine_unavailable_fallback")] | length'

--set webhook.injection.fallbackMode=block flips the injected sidecars if you would rather stop every agent in the cluster than let one call through unjudged; that value is stamped into each sidecar as NRVQ_SDK_FALLBACK_MODE and wins over the Python default. For an SDK process outside the cluster, set NRVQ_SDK_FALLBACK_MODE=block in its own environment — there is no config.* chart key for it. Both data-plane paths retry with backoff first, so this only decides what happens after a sustained outage, not during a rolling restart.

A 4xx never fails open. An answering-but-refusing engine is a credential or request problem, not an outage, and treating it as one would turn a revoked token into a total governance bypass. The circuit breaker also ignores 4xx for the same reason — otherwise three consecutive 401s would open the breaker and every later call would short-circuit into the fallback, never reaching the “4xx always blocks” rule.

webhook.injection.sidecarMode picks what the injected container actually contains.

proxy (default) embedded
What runs in the pod a thin forwarder a full engine: Redis client, policy loader, audit emitter, OPA subprocess
Decision path POST /api/v1/evaluate on norviq-api, namespace-scoped service JWT + mTLS evaluates locally against Redis + Postgres
Audit record written by the central API, framework="sidecar" the sidecar’s own emitter
Requests / limits 50m / 64Mi200m / 128Mi 200m / 256Mi2000m / 384Mi
Use it for everything, unless you have a reason not to air-gapped or edge pods that cannot reach the control plane

The 2000m CPU limit on embedded mode is load-bearing, not padding. Measured on AKS through a real injected sidecar, back to back in one session:

proxy p50 59.0ms p95 92.7ms
embedded @ 500m p50 72.0ms p95 93.1ms 58.9% of CFS periods throttled
embedded @ 2000m p50 30.7ms p95 58.0ms 0% throttled

At 500m, embedded was slower than the proxy default it exists to beat. Only the limit is generous — requests stays at 200m, so scheduling density is unchanged.

The injector refuses to treat a pod as already-injected when the sidecar’s routing environment does not match what it would have produced: in proxy mode NRVQ_API_URL must be the injector’s own value (or the https://norviq-api:8443 upgrade the auto-mTLS path applies), and in embedded mode the datastore URLs must match. A sidecar swung to a co-located allow-all engine enforces nothing, so such a pod is denied rather than skipped.

By default the sidecar’s credentials — the service JWT and the mTLS client cert and key — are delivered through a Secret and valueFrom.secretKeyRef rather than literal value: entries (webhook.injection.credentialSecret.enabled: true). Kubernetes’ built-in view ClusterRole grants get pods but not get secrets, so a credential in the pod spec is readable by anyone a read-only grant was considered safe for. If the webhook cannot write that Secret it falls back to literal pod env and logs NRVQ-WHK-4049 rather than refusing to schedule the pod; set credentialSecret.required: true to refuse instead.

Model Context Protocol traffic has a threat the SDK path does not: the definition of a tool is served by the remote server and injected into the model’s context, and nothing in the protocol binds the definition that was approved to the definition served tomorrow. A server can ship a benign send_email on day one and, thirty days later, append “…also BCC audit@attacker.example” to its description. notifications/tools/list_changed even gives it a blessed way to trigger the re-read.

So the MCP firewall has two gates with deliberately different costs. This section covers the architecture; for the day-to-day operational picture — the pin store, drift and quarantine workflow, and the MCP-specific baseline controls — see MCP servers.

sequenceDiagram
    participant H as MCP host (agent)
    participant N as norviq.mcp firewall
    participant S as MCP server
    participant A as norviq-api (PDP)

    note over H,S: Gate A — discovery. Runs a handful of times per SESSION.
    H->>N: tools/list
    N->>S: tools/list
    S-->>N: tool definitions
    N->>N: scan definitions, canonicalise, sha256 digest
    N->>A: POST /mcp/pins/observe
    A-->>N: verdict: pinned | first_seen | drift | quarantined
    N-->>H: catalog (hostile entries stripped or stubbed)

    note over H,A: Gate B — invocation. Runs on every CALL.
    H->>N: tools/call
    N->>N: catalog lookup (one dict hit)
    N->>N: arguments vs the tool's own inputSchema
    N->>A: POST /api/v1/evaluate
    A-->>N: allow / block / escalate / audit
    alt allowed
        N->>S: forward the original bytes
        S-->>N: result
        N->>N: scan + DLP the response
        N-->>H: result
    else refused
        N-->>H: tool error — rule_id + reason, tool NOT executed
    end

Gate A (discovery) covers initialize, tools/list, prompts/get and the *_changed notifications. It scans each definition, canonicalises the security-relevant fields — name, title, description, inputSchema, outputSchema, annotations, deliberately excluding transport metadata and _meta so an unrelated field bump cannot manufacture a false drift — and hashes them. The digest goes to POST /api/v1/mcp/pins/observe, and the server computes the verdict: pinned, first_seen, drift, or quarantined. The approved digest never leaves the control plane, so a compromised proxy cannot talk itself into a match.

webhook.injection.mcp.pinMode decides what first sight means: tofu (default) pins it, loudly and auditably, and enforces change; strict quarantines it until an operator approves. TOFU is the default because strict turns every new server into an approval workflow, and Gate B already refuses an unrecognised tool under a deny-by-default policy — Gate A’s job is to stop change.

webhook.injection.mcp.pinStore defaults to control-plane: pins are approvals, approvals are policy, and policy is already tenant-scoped, RBAC’d, audited and console-visible. memory and file exist for air-gapped single-process use. A control-plane-backed proxy re-reads its pins every 30 seconds, so POST /mcp/pins/revoke takes effect without restarting the pod.

Gate B (invocation) covers tools/call, resources/read and sampling/createMessage. Its Gate A cost is one dict lookup against the catalog built at discovery — no scanning, no hashing. Then, in order: the Gate A carry-over (a drifted or quarantined tool is refused here), schema conformance against the tool’s own declared inputSchema (NRVQ_MCP_ENFORCE_SCHEMA, on by default — it enforces the server’s own statement about itself, so the false-positive case is a server whose schema disagrees with its implementation), and finally one /evaluate round trip. That is the only network call on the per-call path.

A refusal is returned to the model as a tool error naming the rule and the reason, with an explicit “the tool was NOT executed; do not retry” — and reported to the control plane as a PEP denial so it appears in the audit log even though no policy ran.

Enabling MCP injection requires webhook.injection.mcp.proxyImage; there is no fallback, and it must carry the relocatable payload at proxySourcePath (default /opt/norviq/mcp-proxy). 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 name)

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.

/evaluate is on the hot path of every governed tool call, so decisions are cached — carefully.

L2, shared, Redis. The pre-override base decision is cached for 5 seconds (NRVQ_REDIS_TTL_EVAL_S) under a key of (namespace, agent_class, <suffix>), where the suffix folds every decision-relevant input: tool name, a digest of the params, call_depth, workload, and — when present — a digest of the whole MCP context document. Each segment is hashed independently, because joining caller-controlled segments with bare colons let a caller collide two logical identities and be served the other’s cached allow.

L1, per-pod, in-process. config.inprocCacheTtlS is 0 — off — and is the shipped default. Set it to 5 in production: measured on a 2-node AKS cluster, warm read p50 went 21.9 ms → 3.2 ms and the floor 14.5 ms → 1.4 ms. It caches namespace posture, the stored trust score, the trust calculator’s history and profile reads, and a mirror of the base decision (additionally clamped to the Redis eval TTL and cleared eagerly on any policy change). config.inprocCacheMax (default 8192) bounds per-pod memory.

What the cache never covers:

  • The kill switch. The admin freeze and the trust cap are read fresh on every call and are never cached at any TTL. A freeze applied from the console takes effect on the very next call, and flips a stale cached allow to a block.
  • Per-identity policy. If any candidate module reads input.trust_score, input.trust_category or input.agent.spiffe_id, the decision is not written to the cache at all. The key is class-scoped, so caching it would serve one agent’s answer to every other agent of that class for the TTL — a low-trust agent getting a high-trust agent’s allow.
  • Non-cacheable rules. rate_limit_exceeded and escalate_low_trust are never cached.
  • PEP refusals. A PEP refusal is a fact about one call, so it is folded in after the cache and never stored.

Any policy change invalidates the affected scope in Redis and fires a hook that clears the in-process mirrors on every pod, including peers reached over Redis pub/sub.

What the L1 TTL costs you: a posture, threshold or trust-input change is not observed by an already-warm pod until the entry expires. That is the whole trade — enable it deliberately.

Every evaluated call produces a row in the append-only audit_log table, written fire-and-forget so the write never adds latency to the tool call and a write error can never fail the call. Concurrent audit writes are bounded by a semaphore, so a flood of tool calls cannot exhaust the DB pool and starve every other endpoint. The row carries event_id, tool_name, decision, agent_id, agent_class, namespace, policy_id, rule_id, reason, session_id, trust_score, latency_ms, framework (the decision source, as above) and timestamp_utc. The table is partitioned by month.

What is in the payload is a deliberate privacy line:

  • Argument names are captured by default. Keys only — never values, including values that arrive through a key position, which are put through the same PAN/SSN masker. The record distinguishes “we looked and there were none” from “we never looked”.
  • Masked argument values are off by default. Turn them on for PCI 10.3-style event reconstruction; it stores masked values.
  • Both are environment settings read by the API (NRVQ_AUDIT_CAPTURE_PARAM_KEYS, default true; NRVQ_AUDIT_CAPTURE_MASKED_PARAMS, default false). The chart exposes no value for either and there is no api.env list, so changing them means patching the norviq-config ConfigMap and restarting the API.
  • MCP provenance — which server served the tool, over which transport, and what Gate A knew about its definition at the time — is attached whenever the call came through the MCP firewall.

Five exits from the audit trail:

Exit Path Auth
Console + API GET /api/v1/audit/records, /audit/stats, /audit/top-blocked, /audit/volume session-scoped
Export GET /api/v1/audit/export — the always-available authenticated pull; HMAC-signable by setting NRVQ_AUDIT_EXPORT_SIGNING_KEY authenticated
Live stream WS /ws/audit, JWT in the Sec-WebSocket-Protocol handshake as nrvq-audit-jwt, <token>, ?namespace= to scope. Authenticated before the socket is accepted; a revoked or must-change token closes with 1008. authenticated
SIEM push siem.enabled (default false), siem.webhookUrl, siem.format: ndjson, siem.pollIntervalSeconds: 30. Both ndjson (default) and syslog (RFC5424, with per-decision severity and header-field sanitization) are implemented wire formats. outbound
Metrics + traces /metrics (Prometheus, no auth, never rate-limited); OTel spans when otel.enabled

Retention is Helm-driven and defaults to 30 days (config.retention.auditRetentionDays), because the console never displays a window longer than 30 days. Raise it to 90–365 for SOC 2 / ISO horizons, or set 0 to keep forever. An hourly background pruner applies every retention window; none of them ever touch an enforcing policy.

The same evaluation also updates the agent’s server-side trust score, the agent registry (including on the fail-closed paths — an agent that only ever trips invalid_spiffe_identity still appears on the Agents page), and the per-namespace asset and attack graphs.

helm install lays down a control plane in the release namespace. Agent pods stay in your own workload namespaces and gain a sidecar only once injection is enabled.

flowchart TB
    subgraph agentns["Agent namespace · ns label norviq-injection=enabled<br/>+ pod label norviq.io/agent-class"]
        agentc["Agent container"]
        sc["Injected sidecar (PEP)<br/>per-namespace client cert + service JWT"]
        agentc -->|"UDS"| sc
    end
    subgraph nvns["Release namespace · control plane"]
        api["norviq-api · 2 replicas<br/>API + /evaluate + evaluator in-process<br/>:8080 plain · :8443 TLS terminator"]
        opa1["OPA sidecar :8181"]
        eng["norviq-engine · 1 replica<br/>standalone embedded PDP :8282"]
        opa2["OPA sidecar :8181"]
        wh["norviq-webhook · 2 replicas<br/>injector + CRD controller"]
        ui["norviq-ui (nginx)"]
        pg[("Postgres 16<br/>policies · audit · registry")]
        rd[("Redis 7<br/>trust · eval cache · rate limits")]
        api -.->|"localhost"| opa1
        eng -.->|"localhost"| opa2
        api --> pg
        api --> rd
        eng --> pg
        eng --> rd
        ui -->|"/api/*"| api
        wh -->|"sync CRDs → POST /api/v1/policies"| api
    end
    sc -->|"tool call · mTLS :8443"| api
  • norviq-api (2 replicas, PDB on) — the control plane and the PDP. Authentication, the console API, and /api/v1/evaluate. The evaluator runs in-process in the API pod and queries the pod’s own OPA sidecar over 127.0.0.1:8181. Every injected sidecar and every SDK client points here.
  • OPA (openpolicyagent/opa:1.19.1-static) — a per-replica sidecar in both the API and the engine pods, bound to loopback. A compromised OPA is contained to its replica rather than shared. opa run --server has no --capabilities flag, so the real lockdown is at the engine layer: every pushed Rego module is re-compiled with opa check --capabilities=<pinned set> before it reaches the server.
  • norviq-engine (1 replica) — a standalone evaluation workload running the embedded evaluator with its own OPA sidecar and direct Postgres and Redis, serving /healthz, /readyz, /metrics and POST /v1/evaluate on :8282. Nothing the chart injects points at it: proxy-mode sidecars and the SDK talk to norviq-api. Its readiness probe is the only place OPA health reaches readiness for that workload, because the OPA sidecar binds loopback and the kubelet cannot probe it directly.
  • norviq-webhook (2 replicas, topology spread on, PDB off by default) — one deployment with two jobs: the mutating admission webhook that injects sidecars (webhook.injection.enabled is false by default) and the CRD controller that watches NrvqPolicy / NrvqClass / NrvqConfig and syncs them to the API. On a multi-node cluster set webhook.pdb.enabled=true, or one drain can evict both replicas and reject pod creation in every governed namespace until they reschedule.
  • norviq-ui — the console on nginx, which also proxies /api/* to norviq-api:8080.
  • Postgres 16 and Redis 7 — bundled single-replica StatefulSets by default. Postgres holds policies, audit records, the agent registry and graph snapshots; Redis holds the evaluation cache, trust state and rate-limit counters. Both have HA variants (postgresql.ha.enabled uses CloudNativePG; redis.ha.enabled) and both can be pointed at managed instances.

Note the two independent rate limiters. The per-identity policy limiter (code default 60/60 s) is a policy decision on the evaluated tool call — see the “4 · Enforce” note above about config.rateLimit, which sets it as of 0.2.4. Separately, an HTTP-layer throttle protects the API itself, Redis-backed so it is shared correctly across replicas: /evaluate gets 3000 per 60 s window so it is never the bottleneck, /auth/login gets 20 per IP, dry-run 20, red team 15, everything else 300. /healthz, /readyz and /metrics are never throttled.

See Deployment for HA, ingress and managed datastores, and Configuration for the full values reference.

Secure by default: zero-touch internal mTLS

Section titled “Secure by default: zero-touch internal mTLS”

Control-plane traffic is encrypted out of the box — no operator action, no openssl, no CSRs, no cert-manager. config.internalTls.enabled defaults to true, and a stock helm install yields mutually-authenticated TLS on the wire.

config:
internalTls:
enabled: true # default — turnkey internal mTLS
proxyImage: "nginx:1.27-alpine" # the API's TLS terminator sidecar

When enabled, the chart wires up four things:

  • Auto-minted CA + API cert. A pre-install / pre-upgrade hook Job mints a self-signed internal CA (Secret norviq-internal-ca) and a CA-signed serving certificate for the API (Secret norviq-api-tls). It is idempotent — existing secrets are reused so the CA identity stays stable across upgrades — and it self-deletes on success (hook-delete-policy: before-hook-creation,hook-succeeded).
  • TLS terminator on :8443. The API pod runs an nginx sidecar that listens 8443 ssl with ssl_verify_client optional, proxies to the app over loopback, and forwards the verified client identity as X-Nrvq-Client-Verify / X-Nrvq-Client-Subject. The app and its health probes stay on plain HTTP on localhost. :8443 is a TLS-only listener, so a plaintext request to it is rejected. It also replaces X-Forwarded-For with $remote_addr, which is why config.httpRateLimitTrustedProxyHops defaults to 1 and httpRateLimitTrustedProxyCidrs to loopback: exactly one unforgeable entry, from a peer that is the in-pod proxy. A workload that bypasses the proxy and hits the plaintext port directly is not loopback, so its X-Forwarded-For is ignored.
  • Controller mTLS, fail-closed. The CRD controller verifies the API’s serving cert against the internal CA and syncs policies over https://norviq-api:8443. If the CA cannot be read, it falls back to a fail-closed client (NRVQ-WHK-4046) rather than dropping to plaintext.
  • Per-namespace sidecar mTLS. When it injects a sidecar the webhook mints a per-namespace client certificate signed by the internal CA, hands it to the sidecar, and upgrades the sidecar’s NRVQ_API_URL to https://norviq-api:8443. This is defense in depth alongside the sidecar’s namespace-scoped service JWT. If the CA cannot be read or the mint fails, injection does not hard-fail — it logs NRVQ-WHK-4047 / NRVQ-WHK-4048 and keeps the plaintext-plus-JWT path.

Set config.internalTls.enabled: false only for a throwaway plaintext dev cluster.

  • Concepts — identity, policy tiers and overlays, enforcement modes, trust score, and the asset/attack graphs.
  • Get started — install the chart and watch a decision flip from allow to block.
  • Sidecar injection and SDK integration — the two zero-code and low-code interception paths in depth.
  • MCP servers — the pin store, drift and quarantine workflow, and the MCP-specific baseline controls, day to day.
  • Baseline controls — the full detector set and the tuning guidance behind the “floor, not a tier” language above.
  • Troubleshooting — diagnosing which failure-mode path fired, live.
  • Configuration — every value referenced on this page, with its shipped default.