Skip to content

SPIFFE/SPIRE identity

Every enforcement decision is keyed on who the caller is. namespace picks the tenant, agent_class selects which Rego program runs, and spiffe_id keys the trust score, the per-agent rate limit and the agent_frozen: admin kill switch. This page is about where those three values come from, how much of that is attested versus asserted, and how each credential rotates.

Two separate mechanisms are involved and it is worth keeping them apart:

  • Resolution — how a workload names itself. Governed by config.spiffeMode (mock by default, or workload-api).
  • Authorization — how the control plane decides whether to believe that name. Governed by the credential the caller presents to /api/v1/evaluate and by auth.requireBoundAgentIdentity (default true).

Turning on SPIFFE hardens the first. It does not automatically harden the second, and the interaction between them has a sharp edge documented in What workload-api does not attest.

flowchart LR
  subgraph pod["agent pod"]
    app["agent container"]
    sc["norviq-sidecar"]
  end
  svid["SPIRE agent<br/>(csi.spiffe.io socket)"]
  api["norviq-api /evaluate"]

  app -->|"tool call over<br/>unix socket"| sc
  sc -->|"resolve(): mock env vars<br/>or workload-api SVID"| svid
  sc -->|"POST + service JWT<br/>(claims bind the identity)"| api
  api -->|"allow / block / escalate"| sc

Norviq parses exactly one SPIFFE ID form (norviq/engine/identity.py, _parse_norviq_spiffe_id):

spiffe://norviq/ns/<namespace>/sa/<service-account>

The trust domain is the compile-time constant norviqit is not configurable. An SVID in any other trust domain, or with any other path shape, is rejected and the call fails closed. Anything that parses gives Norviq the workload’s namespace and service_account, and nothing else.

config.spiffeMode: mock is what a stock install runs. There is no SPIRE, no PKI, and no CSI driver. Identity is assembled from the pod’s environment:

Field Source in mock mode
namespace NRVQ_NAMESPACE (webhook injects the pod’s real namespace), else default
service_account NRVQ_SERVICE_ACCOUNT, else defaultthe webhook never injects this
agent_class NRVQ_AGENT_CLASS, from the pod’s norviq.io/agent-class label
workload NRVQ_WORKLOAD, derived at admission from the pod’s owner reference
spiffe_id composed: spiffe://norviq/ns/<namespace>/sa/<service_account>

Because the injector never sets NRVQ_SERVICE_ACCOUNT, an injected sidecar’s ID is deterministic: spiffe://norviq/ns/<ns>/sa/default. That determinism is load-bearing — it is exactly why the webhook can bind a spiffe_id claim into the sidecar’s token in this mode (see below).

mock is honest about what it is: the identity is platform-asserted, not cryptographically attested. What keeps it from being forgeable by the workload itself is the credential binding, not the resolver.

config.spiffeMode: workload-api replaces the env-var derivation with a real X.509 SVID fetched from the SPIFFE Workload API socket. In this mode:

  • namespace and service_account come from the SVID only. A forged NRVQ_NAMESPACE on the pod is ignored — the resolver never reads env for those two fields.
  • Fail-closed. An unreachable socket, a missing pyspiffe, a foreign trust domain or a malformed path all raise SpiffeResolutionError and block the call. There is no fallback to an env identity. (mock mode, by contrast, falls back to spiffe://norviq/ns/unknown/sa/unknown on an internal error and logs NRVQ-IDT-10003.)
  • Reversible with no code change. Set config.spiffeMode: mock and redeploy.

Two fields are outside the SVID and stay platform-asserted in both modes:

  • agent_class — read from NRVQ_AGENT_CLASS, which the injector sets from the pod’s norviq.io/agent-class label. This is the field that selects the policy, so it matters. It is protected by credential binding rather than by attestation.
  • workload — read from NRVQ_WORKLOAD, derived at admission from the pod’s owner reference. Absent when a pod has no resolvable owner, in which case the workload policy tier correctly does not apply.

Resolution says what a workload calls itself. The credential it presents to /api/v1/evaluate is what makes the control plane accept it. There are two machine credential kinds, and they rotate very differently.

Injected sidecar token Service API key
Issued by the admission webhook, at pod admission an admin, POST /api/v1/keys
Form HS256 JWT signed with NRVQ_API_SECRET_KEY opaque key, hash stored, secret returned once
Claims sub=norviq-sidecar, role=service, namespace, agent_class, workload (when resolvable), spiffe_id (mock mode only) role, namespace, agent_class, spiffe_id — set at creation
Lifetime NRVQ_SIDECAR_TOKEN_TTL_HOURS, default 720h (30 days). Not a chart value — set it on the webhook Deployment env if you need it shorter. expires_in_days at creation; server default config.retention.apiKeyDefaultTtlDays (90). 0 = never expires.
Can self-refresh No. The value is baked into the pod at admission. No.
Rotation Rotation is pod replacement. Every admission re-mints, so a rollout produces fresh material with an unchanged identity. Mint a new key, roll the consumer, then DELETE /api/v1/keys/{key_id} to revoke the old one. Revocation is immediate.
Revocation Delete the pod (and rotate NRVQ_API_SECRET_KEY if the token leaked). DELETE /api/v1/keys/{key_id}, admin-only, audited.

With webhook.injection.credentialSecret.enabled: true (the default) the injector writes NRVQ_API_TOKEN and the mTLS client cert/key into a namespaced Secret and patches the sidecar with valueFrom.secretKeyRef, instead of emitting them as literal value: entries in the pod spec.

This matters because Kubernetes deliberately excludes Secrets from the built-in view ClusterRole — view grants get pods but not get secrets. A credential in the pod spec is readable by anyone a read-only grant was considered safe for, and also lands in etcd, kubectl describe and any GitOps diff.

Two behaviours follow from that design:

  • The Secret is keyed per (namespace, workload, agent_class), not per pod — a pod’s name is not knowable at admission for anything created by a ReplicaSet. It is rewritten on every admission, which is what makes rotation-by-pod-replacement work. A stale norviq.io/minted-at annotation on the Secret means injection stopped happening.
  • If the webhook cannot write the Secret it falls back to literal pod env and logs NRVQ-WHK-4049, rather than refusing to schedule — this webhook runs failurePolicy: Fail, so a transient API hiccup would otherwise become a cluster-wide “no pods may be created” outage. Set webhook.injection.credentialSecret.required: true to fail closed instead.

A role=service key is a workload credential, so the API refuses to issue one that could choose its own policy:

Terminal window
curl -sS -X POST https://<norviq-api>/api/v1/keys \
-H "Authorization: Bearer $NRVQ_ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"name": "checkout-agent",
"role": "service",
"namespace": "agents",
"agent_class": "checkout",
"spiffe_id": "spiffe://norviq/ns/agents/sa/checkout-agent",
"expires_in_days": 90
}'
  • agent_class is always required for role=service — 422 without it.
  • spiffe_id is required too unless the deployment runs spiffeMode: workload-api, where the claim is not issuable (the create path reads _required_bound_fields(), the same function enforcement reads, so creation and enforcement cannot drift). A key minted without the fields this deployment requires would 403 on every call, including for its own class — so it is refused at creation instead.
  • role=viewer and role=admin keys are operator/CI credentials with no agent identity to bind; they take neither field.

The response includes key exactly once. There is no way to read it again.

This is a bring-your-own-SPIRE integration — Norviq consumes an existing SPIRE deployment plus the SPIFFE CSI driver; it does not bundle or install SPIRE. The flow below is validated end-to-end against SPIRE 1.14.5 on Kubernetes.

  • A Kubernetes cluster with SPIRE and the SPIFFE CSI driver installed (steps 1–2 below).
  • Norviq installed (Get started).
  • The SPIRE trust domain must be norviq. Anything else is rejected fail-closed.
Terminal window
helm repo add spiffe https://spiffe.github.io/helm-charts-hardened/
helm repo update spiffe
# CRDs first
helm upgrade --install spire-crds spiffe/spire-crds -n spire-system --create-namespace
# SPIRE server + agent + SPIFFE CSI driver + controller-manager
helm upgrade --install spire spiffe/spire -n spire-system \
--set global.spire.trustDomain=norviq \
--set global.spire.clusterName=<your-cluster-name>

Wait for the core components (the OIDC discovery provider is optional and not needed here):

Terminal window
kubectl -n spire-system get pods -l app.kubernetes.io/name=agent
kubectl -n spire-system get pods -l app.kubernetes.io/name=spiffe-csi-driver

The template must produce the exact 4-segment shape Norviq parses. Scope it to the namespaces you have labelled for injection:

apiVersion: spire.spiffe.io/v1alpha1
kind: ClusterSPIFFEID
metadata:
name: norviq-agents
spec:
# Produces spiffe://norviq/ns/<ns>/sa/<serviceAccount> — the exact shape Norviq parses.
spiffeIDTemplate: "spiffe://{{ .TrustDomain }}/ns/{{ .PodMeta.Namespace }}/sa/{{ .PodSpec.ServiceAccountName }}"
namespaceSelector:
matchLabels:
norviq-injection: enabled

The SPIRE controller-manager turns this into per-workload registration entries automatically — no manual spire-server entry create. Give your agent pods a distinct service account per identity you want to distinguish; the SVID’s sa component is the pod’s Kubernetes service account.

If you are also putting the control-plane pods on SVIDs (step 3), they live in the Norviq release namespace, which is not labelled for injection — so they need their own registration. The chart labels them norviq.io/svid: "true" for exactly this:

apiVersion: spire.spiffe.io/v1alpha1
kind: ClusterSPIFFEID
metadata:
name: norviq-control-plane
spec:
spiffeIDTemplate: "spiffe://{{ .TrustDomain }}/ns/{{ .PodMeta.Namespace }}/sa/{{ .PodSpec.ServiceAccountName }}"
podSelector:
matchLabels:
norviq.io/svid: "true"

With config.spiffeCsi.enabled: true the chart also creates dedicated norviq-api and norviq-engine ServiceAccounts and pins the deployments to them, so each pod’s SVID is self-describing (spiffe://norviq/ns/<release-ns>/sa/norviq-api). Without the flag those pods use the namespace default service account.

Three values, and they are independent:

  • config.spiffeMode: workload-api — the resolver mode. This is what makes any component resolve a real SVID. It is stamped into the API and engine ConfigMap, and the injector reuses it for the sidecars it injects.
  • webhook.spiffe.inject: true — tells the injector to mount the csi.spiffe.io volume into injected pods and set NRVQ_SPIFFE_MODE / NRVQ_SPIFFE_SOCKET on every container it patches. Without this the sidecars have no socket to resolve against.
  • config.spiffeCsi.enabled: true — gates the csi.spiffe.io volume onto the api/engine pods. Leave it off unless SPIRE is installed: a csi.spiffe.io volume with no driver wedges pod creation.
Terminal window
helm upgrade norviq oci://ghcr.io/norviq-dev/charts/norviq --version 0.2.5 -n norviq --reset-then-reuse-values \
--set config.spiffeMode=workload-api \
--set webhook.spiffe.inject=true \
--set config.spiffeCsi.enabled=true

The socket path defaults to /spiffe-workload-api/spire-agent.sock (config.spiffeSocket) — override it only if your CSI driver publishes elsewhere.

Existing service API keys minted with a spiffe_id keep working; that claim is simply no longer in the required set. Reverting to mock is the reverse — but note it makes spiffe_id required again for role=service keys, so any key minted without one will 403 until re-issued.

In the default proxy sidecar mode, identity is resolved on the agent’s first intercepted tool call, not at pod start, then cached. After the agent makes a call:

Terminal window
kubectl logs <agent-pod> -c norviq-sidecar -n <agent-ns> | grep NRVQ-IDT-10004
# nrvq.identity.workload_resolved code=NRVQ-IDT-10004 spiffe_id=spiffe://norviq/ns/<ns>/sa/<sa>
# (empty output before the first tool call is expected — trigger a call, or use the probe below)

Useful codes when it does not work:

Code Event Means
NRVQ-IDT-10004 workload_resolved an SVID was fetched and parsed — this is success
NRVQ-IDT-10006 socket_unreachable Workload API not reachable; call blocked
NRVQ-IDT-10005 svid_invalid SVID is outside trust domain norviq or the wrong path shape
NRVQ-IDT-10007 cache_ttl_clamped NRVQ_SPIFFE_CACHE_TTL_S above the 300s ceiling; clamped
NRVQ-AUTH-14022 identity_binding_partial startup warning: spiffe_id is not bound on this install

To confirm the whole chain independently, run a throwaway probe on the Norviq image (which ships the SPIFFE client — Dockerfile.api and Dockerfile.engine install the .[spiffe] extra) that mounts the CSI socket and resolves identity:

apiVersion: v1
kind: Pod
metadata:
name: spiffe-probe
namespace: <agent-ns> # a namespace covered by your ClusterSPIFFEID
spec:
serviceAccountName: <a-registered-sa>
containers:
- name: probe
image: ghcr.io/norviq-dev/norviq-engine:api-latest
command: ["sleep", "3600"]
env:
- { name: NRVQ_SPIFFE_MODE, value: "workload-api" }
- { name: NRVQ_SPIFFE_SOCKET, value: "/spiffe-workload-api/spire-agent.sock" }
volumeMounts:
- { name: spiffe-workload-api, mountPath: /spiffe-workload-api, readOnly: true }
volumes:
- name: spiffe-workload-api
csi: { driver: "csi.spiffe.io", readOnly: true }
Terminal window
kubectl exec spiffe-probe -n <agent-ns> -- python -c "
import asyncio
from norviq.engine.identity import SPIFFEResolver
print(asyncio.run(SPIFFEResolver().resolve()).spiffe_id)"
# -> spiffe://norviq/ns/<agent-ns>/sa/<a-registered-sa>

SPIRE rotates SVIDs on its own schedule (typically at half-life). Norviq caches a resolved identity per process to keep the Workload API off the hot path, and that cache is hard-capped at 300 seconds.

NRVQ_SPIFFE_CACHE_TTL_S (config.py, default 300) is clamped, not trusted. Raising it to an hour would not buy a faster cache — it would turn SVID rotation off, because the Workload API would never be consulted again inside the window and a workload whose SVID had rotated or been revoked would keep enforcing under its previous identity. A value above the ceiling is clamped and logged as NRVQ-IDT-10007; the resolver keeps running rather than failing the workload over a misconfiguration.

The cache also refuses to guess: if more than one identity is live in a process, it drops the expired entries and re-resolves rather than picking one by insertion order.

Every enforcement point uses the same SPIFFEResolver, so the mode applies uniformly:

  • the injected sidecar (proxy and embedded), on its first intercepted tool call;
  • the in-process SDK interceptor, the same way;
  • the MCP action firewall (python -m norviq.mcp), which resolves at start and never reads identity from any protocol field;
  • the fleet relay, which attaches its attested SPIFFE ID to a spoke heartbeat — but only in workload-api mode, since a mock ID is not bindable.

The spiffe extra and the agent-framework extras cannot co-resolve, and that is a property of their upstreams rather than a choice: spiffe>=0.3 requires protobuf>=6.31.1,<8, while every autogen-core>=0.4 requires protobuf 4.x or 5.x. Nothing in the shipped images installs both — the API and engine images take .[spiffe], and .[frameworks] remains a valid install for SDK users, who resolve identity through the sidecar rather than in-process. If you are embedding the SDK directly and want workload-api resolution in the same interpreter, you will hit this conflict.


See Concepts → Agent identity for how the resolved identity keys policy, and the Security model for where it sits in the trust boundaries.