Identity & delegation
The whole attribution chain rests on one distinction: a log that traces back to “marie.dupont” without being able to substantiate it was really her proves nothing.
Proven or declared
Section titled “Proven or declared”| Proven (OIDC) | Declared (--insecure-declared-identity) | |
|---|---|---|
principal_issuer in the log | the real issuer | cli://declared |
| Expiry | from the exp claim | none |
| Verified | signature, iss, aud, exp, nbf | nothing |
| Use | production | development only |
Declared mode requires a flag whose name says what it is, and with no identity configuration at all the gateway refuses to start; there is no silent fallback to anonymous mode.
Four design choices worth knowing:
- No network calls. The JWKS lives inside a signed identity bundle, distributed by the control plane alongside the policy bundle. The gateway stays deployable air-gapped and adds no outbound surface.
- The identity bundle is signed, like the policy bundle. Whoever can write that file can mint an identity for themselves. That is the same threat as an unsigned rules file, and it deserves the same answer.
- The algorithm comes from the JWKS, never from the token header. The
classic JWT flaw, declaring
HS256and HMAC-signing with the IdP’s public key, is covered by a dedicated test. - Expiry is re-evaluated on every act. If the token was renewed on disk, the gateway records a new delegation; otherwise an act under a renewed token would appear authorized by an already-expired delegation.
The delegation chain
Section titled “The delegation chain”A token that says “marie.dupont” does not say who was acting in her name. RFC 8693 token exchange does, and Obsign records it:
| Token mode | Actor chain | principal_kind |
|---|---|---|
| User token | u:marie.dupont | human |
Token exchange (act claim) | support-copilot → u:marie.dupont | delegated_human |
client_credentials | batch-agent | machine |
The third case is the one nobody covers: a batch agent, with no human behind
it, deleting in production. It is recognised by the markers the target IdPs
actually emit (sub == client_id, Entra ID’s idtyp: "app", Keycloak’s
service-account- username prefix), and the policy can then express it:
@id("destructive_requires_human")forbid (principal, action == Action::"tool_call", resource)when { resource.destructive && !context.has_human_delegation };“No agent destroys anything without an identifiable human at the end of the
chain.” Cedar also receives context.actor_chain, context.delegation_depth
(to bound multi-agent topologies) and context.principal_kind.
Identity providers
Section titled “Identity providers”Nothing is provider-specific, and that is deliberate. Keycloak, Entra ID,
Okta and Ping are OIDC providers: they work as-is. What varies is where
each provider files the information, so the identity bundle describes the
claim paths, with * for client-dependent segments:
"claims": { "subject": "/sub", "scopes": ["/scope", "/scp"], "groups": ["/groups", "/roles", "/realm_access/roles", "/resource_access/*/roles"], "client_id": ["/client_id", "/azp"]}These defaults cover Keycloak and Entra ID. The Keycloak case is worth
flagging: roles are never flat, so a naive mapping returns empty groups
and no principal in Group::"dba" rule ever matches, with no visible error.
Machine markers follow the same path language and live inside the signed
bundle (format obsign-identity/2): widening what counts as human must be
signed like any other authorization change.
Two Keycloak realm-side points, done once: an audience mapper (default
access tokens carry aud: "account", which the gateway rightly refuses), and
token exchange enabled on the agent’s client for the act claim
(officially supported since Keycloak 26.2). For SAML, put Keycloak in front
as a SAML-to-OIDC bridge, so the gateway never has to know SAML exists.
Key rotation
Section titled “Key rotation”Providers rotate their signing keys routinely. The gateway makes no network calls, so rotation means reloading the signed identity bundle from disk, where the control plane published the new version.
- Triggered by an unknown
kid— the exact signal of a rotation, so reloading costs nothing in nominal operation. One retry only. - The signature is revalidated on every reload — otherwise writing the file would be enough to inject a JWKS.
- An invalid bundle never takes the gateway down — botched deployment, truncated file, bad signature: the previous configuration stays in force.
- Bounded frequency — one attempt per second, detected by content hash (mtime granularity can miss close writes).
- The reload is itself recorded — a
config_reloadrecord goes into the audit chain, rejected attempts included: dropping a rogue JWKS on disk is an attack, and the attempt is precisely what an investigation wants to see.