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.
The PEP/PDP split
Section titled “The PEP/PDP split”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) pluswebhook.injection.gateOnlyAgentPods: true(default) means a pod carryingnorviq.io/agent-classin anorviq-injection=enablednamespace 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(defaultfalse) renders a default-deny egress NetworkPolicy — or a CiliumNetworkPolicy whenagentEgressPolicy.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.
The request path
Section titled “The request path”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/<ns>/sa/<sa> → (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
1 · Intercept
Section titled “1 · Intercept”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”.
2 · Identify
Section titled “2 · Identify”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 stampsNRVQ_NAMESPACEfrom the pod’s real namespace andNRVQ_AGENT_CLASSfrom itsnorviq.io/agent-classlabel (plusNRVQ_WORKLOADwhen 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 injectsNRVQ_SERVICE_ACCOUNT, so the id is the deterministicspiffe://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.
3 · Evaluate
Section titled “3 · Evaluate”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.
4 · Enforce
Section titled “4 · Enforce”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:
- 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 — prefixingget_ontodelete_all_recordsdoes not buy an exemption. The chart’sconfig.rateLimitvalue is rendered into the ConfigMap asNRVQ_RATE_LIMIT, and as of 0.2.4 that name binds to this limiter, so tuning it invalues.yamlworks. On 0.2.3 and earlier nothing read the variable andextra="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. - Trust overrides —
frozenforcesblockwithrule_id=trust_frozen;lowtrust turns anallowintoescalatewithrule_id=escalate_low_trust. - Namespace posture — a namespace set to monitor softens
blockandescalatetoauditand rewrites the rule id tomonitor_would_block:<original>.trust_frozenis exempt, and so israte_limit_exceededwhileNRVQ_MONITOR_EXEMPT_RATE_LIMITstays at its defaulttrue— “do not block on policy” is a statement about policy judgements, not a request for unbounded call volume. - 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 |
Failure modes, as shipped
Section titled “Failure modes, as shipped”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 | allow — config.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 | allow — webhook.injection.fallbackMode: allow |
thin_proxy_fail_open |
| Same, from the SDK / MCP proxy | allow — NRVQ_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.
# 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.
Sidecar: proxy vs embedded
Section titled “Sidecar: proxy vs embedded”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 / 64Mi → 200m / 128Mi |
200m / 256Mi → 2000m / 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.7msembedded @ 500m p50 72.0ms p95 93.1ms 58.9% of CFS periods throttledembedded @ 2000m p50 30.7ms p95 58.0ms 0% throttledAt 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.
MCP: two gates
Section titled “MCP: two gates”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.
The evaluation cache
Section titled “The evaluation cache”/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
allowto a block. - Per-identity policy. If any candidate module reads
input.trust_score,input.trust_categoryorinput.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_exceededandescalate_low_trustare 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.
The audit trail
Section titled “The audit trail”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, defaulttrue;NRVQ_AUDIT_CAPTURE_MASKED_PARAMS, defaultfalse). The chart exposes no value for either and there is noapi.envlist, so changing them means patching thenorviq-configConfigMap 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.
What gets deployed
Section titled “What gets deployed”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 over127.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 --serverhas no--capabilitiesflag, so the real lockdown is at the engine layer: every pushed Rego module is re-compiled withopa 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,/metricsandPOST /v1/evaluateon:8282. Nothing the chart injects points at it: proxy-mode sidecars and the SDK talk tonorviq-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.enabledisfalseby default) and the CRD controller that watchesNrvqPolicy/NrvqClass/NrvqConfigand syncs them to the API. On a multi-node cluster setwebhook.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/*tonorviq-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.enableduses 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 sidecarWhen enabled, the chart wires up four things:
- Auto-minted CA + API cert. A
pre-install/pre-upgradehook Job mints a self-signed internal CA (Secretnorviq-internal-ca) and a CA-signed serving certificate for the API (Secretnorviq-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 listens8443 sslwithssl_verify_client optional, proxies to the app over loopback, and forwards the verified client identity asX-Nrvq-Client-Verify/X-Nrvq-Client-Subject. The app and its health probes stay on plain HTTP on localhost.:8443is a TLS-only listener, so a plaintext request to it is rejected. It also replacesX-Forwarded-Forwith$remote_addr, which is whyconfig.httpRateLimitTrustedProxyHopsdefaults to1andhttpRateLimitTrustedProxyCidrsto 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 itsX-Forwarded-Foris 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_URLtohttps://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 logsNRVQ-WHK-4047/NRVQ-WHK-4048and keeps the plaintext-plus-JWT path.
Set config.internalTls.enabled: false only for a throwaway plaintext dev cluster.
Where to go next
Section titled “Where to go next”- 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
allowtoblock. - 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.