Skip to content

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

ClassStorageSurvives restart?
CredentialBrokerIn-memory dict[token_id, SessionToken]No
PersistentCredentialBrokerSQLite 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

python
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 + 3600s

token_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

python
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 invalid

validate() 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:

python
# 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:

python
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 parent

The check fires loudly on misuse:

FailureRaises
Parent token invalid / expired / revokedValueError("Parent token '<id>' is invalid or expired")
requested_scopes is emptyValueError("Delegated child token must have at least one scope ...")
requested_scopes exceeds parentValueError("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

python
from kitelogik.anchor.credentials import PersistentCredentialBroker

broker = PersistentCredentialBroker(db_path="credentials.db")
# All issue/revoke/delegate calls write through to SQLite

On construction, the broker:

  1. Runs PRAGMA journal_mode=WAL + creates the session_tokens table if missing.
  2. 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:

python
PersistentCredentialBroker(":memory:")
# ValueError: ... use a file path (e.g. tempfile.mkdtemp() + '/credentials.db').

Where the broker fits in the request path

text
   ┌─────────────┐  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.

Released under the Apache 2.0 License.