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(mockby default, orworkload-api). - Authorization — how the control plane decides whether to believe that name. Governed by
the credential the caller presents to
/api/v1/evaluateand byauth.requireBoundAgentIdentity(defaulttrue).
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
Identity shape
Section titled “Identity shape”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 norviq — it 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.
mock — the default
Section titled “mock — the default”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 default — the 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.
workload-api — attested SVIDs
Section titled “workload-api — attested SVIDs”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:
namespaceandservice_accountcome from the SVID only. A forgedNRVQ_NAMESPACEon 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 raiseSpiffeResolutionErrorand block the call. There is no fallback to an env identity. (mockmode, by contrast, falls back tospiffe://norviq/ns/unknown/sa/unknownon an internal error and logsNRVQ-IDT-10003.) - Reversible with no code change. Set
config.spiffeMode: mockand redeploy.
What workload-api does not attest
Section titled “What workload-api does not attest”Two fields are outside the SVID and stay platform-asserted in both modes:
agent_class— read fromNRVQ_AGENT_CLASS, which the injector sets from the pod’snorviq.io/agent-classlabel. This is the field that selects the policy, so it matters. It is protected by credential binding rather than by attestation.workload— read fromNRVQ_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.
Credential kinds and how they rotate
Section titled “Credential kinds and how they rotate”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. |
Where the sidecar’s credential lives
Section titled “Where the sidecar’s credential lives”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 stalenorviq.io/minted-atannotation 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 runsfailurePolicy: Fail, so a transient API hiccup would otherwise become a cluster-wide “no pods may be created” outage. Setwebhook.injection.credentialSecret.required: trueto fail closed instead.
Issuing a service key for an agent
Section titled “Issuing a service key for an agent”A role=service key is a workload credential, so the API refuses to issue one that could choose its
own policy:
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_classis always required forrole=service— 422 without it.spiffe_idis required too unless the deployment runsspiffeMode: 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=viewerandrole=adminkeys 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.
Enabling workload-api
Section titled “Enabling workload-api”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.
Prerequisites
Section titled “Prerequisites”- 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.
1. Install SPIRE + the SPIFFE CSI driver
Section titled “1. Install SPIRE + the SPIFFE CSI driver”helm repo add spiffe https://spiffe.github.io/helm-charts-hardened/helm repo update spiffe
# CRDs firsthelm upgrade --install spire-crds spiffe/spire-crds -n spire-system --create-namespace
# SPIRE server + agent + SPIFFE CSI driver + controller-managerhelm 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):
kubectl -n spire-system get pods -l app.kubernetes.io/name=agentkubectl -n spire-system get pods -l app.kubernetes.io/name=spiffe-csi-driver2. Register your agents (ClusterSPIFFEID)
Section titled “2. Register your agents (ClusterSPIFFEID)”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/v1alpha1kind: ClusterSPIFFEIDmetadata: name: norviq-agentsspec: # 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: enabledThe 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/v1alpha1kind: ClusterSPIFFEIDmetadata: name: norviq-control-planespec: 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.
3. Switch Norviq to attested SVIDs
Section titled “3. Switch Norviq to attested SVIDs”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 thecsi.spiffe.iovolume into injected pods and setNRVQ_SPIFFE_MODE/NRVQ_SPIFFE_SOCKETon every container it patches. Without this the sidecars have no socket to resolve against.config.spiffeCsi.enabled: true— gates thecsi.spiffe.iovolume onto the api/engine pods. Leave it off unless SPIRE is installed: acsi.spiffe.iovolume with no driver wedges pod creation.
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=trueThe 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.
4. Verify a real SVID is flowing
Section titled “4. Verify a real SVID is flowing”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:
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: v1kind: Podmetadata: name: spiffe-probe namespace: <agent-ns> # a namespace covered by your ClusterSPIFFEIDspec: 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 }kubectl exec spiffe-probe -n <agent-ns> -- python -c "import asynciofrom norviq.engine.identity import SPIFFEResolverprint(asyncio.run(SPIFFEResolver().resolve()).spiffe_id)"# -> spiffe://norviq/ns/<agent-ns>/sa/<a-registered-sa>SVID rotation and the identity cache
Section titled “SVID rotation and the identity cache”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.
Who resolves identity
Section titled “Who resolves identity”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-apimode, since amockID is not bindable.
Packaging constraint
Section titled “Packaging constraint”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.