Credential broker
CredentialBroker issues short-lived tokens scoped to a specific session and a specific scope list. Every governed call carries the token; the gate validates the token and the scopes before the policy even runs. Delegation is enforced as a scope-subset check at issue time, so a child session can never end up with more authority than its parent.
Two implementations, same API
| Class | Storage | Survives restart? |
|---|---|---|
CredentialBroker | In-memory dict[token_id, SessionToken] | No |
PersistentCredentialBroker | SQLite write-through (subclass of the above) | Yes |
Both expose the same public methods. Pick the in-memory one for short-lived demos, scripts, and tests; pick the persistent one for any process that handles real sessions.
Issue a token
from kitelogik.anchor.credentials import CredentialBroker
broker = CredentialBroker()
token = broker.issue(
session_id="sess_001",
scopes=["read_customer", "approve_refund"],
ttl_seconds=3600, # 1 hour, the default
)
# token.token_id -> "tok_<32 hex>"
# token.expires_at = issued_at + 3600stoken_id is tok_ + secrets.token_hex(16) (32 hex chars). Cryptographically random, not guessable.
Pass token.token_id into SessionContext.token_id; the gate will re-validate it on every call.
Validate
token = broker.validate(token_id)
if token is None:
raise PermissionError("token invalid, expired, or revoked")
# Or just check scopes
scopes = broker.get_scopes(token_id) # [] if invalidvalidate() returns None for any of: token doesn't exist, token has expired, token has been revoked. There's no way to distinguish the three cases from the public API — that's intentional, so callers fail with a single "no auth" path.
Revoke
Two granularities:
# Revoke a specific token
broker.revoke(token_id) # -> bool: was it found?
# Revoke every token for a session — useful on logout/timeout
count = broker.revoke_session("sess_001")Revocation is immediate — the next validate() call on a revoked token returns None. With the persistent broker, the revocation is written through to SQLite in the same call, so a process restart respects the revocation.
Delegate (the load-bearing safety primitive)
delegate() issues a child token whose scopes are a subset of the parent's. This is how multi-agent governance keeps a delegated worker from exceeding the orchestrator's authority:
parent_token = broker.issue(
session_id="sess_001",
scopes=["read_customer", "approve_refund", "send_email"],
)
# Child gets only read_customer — narrower than the parent
child = broker.delegate(
parent_token_id=parent_token.token_id,
requested_scopes=["read_customer"],
session_id="sess_001_child",
)
# child.delegation_depth = 1
# child.parent_token_id = parent_token.token_id
# child.expires_at = parent_token.expires_at ← child cannot outlive parentThe check fires loudly on misuse:
| Failure | Raises |
|---|---|
| Parent token invalid / expired / revoked | ValueError("Parent token '<id>' is invalid or expired") |
requested_scopes is empty | ValueError("Delegated child token must have at least one scope ...") |
requested_scopes exceeds parent | ValueError("Requested scopes exceed parent grant — forbidden: [...]") |
The empty-scope guard is deliberate — passing [] as a "narrowing" intent is nonsense (the child gets nothing, but it also bypasses any policy expecting at least one scope). Better to fail loudly.
The expiry inheritance (child.expires_at = parent.expires_at) is the other safety primitive — a child cannot outlive its parent, so revoking the parent invalidates the entire delegation chain on the next validate() call.
Persist tokens across restarts
from kitelogik.anchor.credentials import PersistentCredentialBroker
broker = PersistentCredentialBroker(db_path="credentials.db")
# All issue/revoke/delegate calls write through to SQLiteOn construction, the broker:
- Runs
PRAGMA journal_mode=WAL+ creates thesession_tokenstable if missing. - Loads all unexpired rows from SQLite into the in-memory cache.
Every subsequent issue(), revoke(), revoke_session(), and delegate() writes through to SQLite immediately. validate() only reads from the cache (no DB hit per call), so the hot path stays fast.
:memory: is rejected for the same reason as HITLQueue — silent state loss across thread hops:
PersistentCredentialBroker(":memory:")
# ValueError: ... use a file path (e.g. tempfile.mkdtemp() + '/credentials.db').Where the broker fits in the request path
┌─────────────┐ token_id ┌─────────────┐ ok ┌──────────┐
│ AgentSession│ ─────────► │CredentialBkr│ ────────►│ OPA │
│ │ │ validate() │ │ evaluate │
└─────────────┘ └─────────────┘ └──────────┘
│
▼ scopes[]
┌─────────────────┐
│ injected into │
│ SessionContext │
└─────────────────┘The broker sits before the policy gate. An invalid token short-circuits to deny without asking OPA — saves the round trip and gives a distinctive _INVALID_TOKEN_DECISION audit trail.
When to roll your own backend
PersistentCredentialBroker is the right tool for single-process deployments — one Python process, one SQLite file, lock contention handled by WAL.
For multi-process or distributed environments (a horizontally scaled API tier, a worker pool, multiple replicas behind a load balancer), SQLite is the wrong shape — the in-memory cache won't agree across processes, and write-through to the same file from multiple processes invites lock contention.
For those, the recommendation is:
- Vault if your platform team already runs it — Vault's leasing semantics map cleanly onto KL's TTL + delegation model.
- Redis if you want a simpler dependency — store the token rows with
EX <ttl>, use Redis pub/sub for revocation invalidation across replicas.
The CredentialBroker API surface is small — issue, revoke, revoke_session, validate, get_scopes, delegate — so a custom subclass is straightforward. Keep the delegation invariants (non-empty scopes, scope-subset, child-expiry-cap) intact and the rest of the runtime won't notice the swap.
What you'd build yourself
- Vault / Redis backends. The bundled broker is in-memory + SQLite. Multi-process production backends (Vault, Redis, your secret manager of choice) are a subclass exercise.
- Cross-tenant isolation. The bundled broker is single-tenant. Per-tenant token isolation lives in your backend.
- OAuth / OIDC bridge. No built-in translation from external identity providers — bring your own token-mint-on-login flow and call
issue()from there. - Token rotation policy. TTL is enforced; rotation is a runtime concern. Re-issue tokens on whatever schedule fits your trust model.
Related
- Architecture — where the broker sits in the Anchor layer
- Multi-agent governance — delegation is the primitive these patterns are built on
delegation.rego— policy module that enforces per-call constraints on delegated agents