Authorization¶
Istos has two independent security layers, and it is worth being precise about which is which — they solve different problems and are configured in different places.
| Authentication | Authorization | |
|---|---|---|
| Question it answers | Who may join the fabric? | What may a joined peer invoke? |
| Configured with | IstosZenohConfig — username/password, TLS, mTLS |
authorizer= — on Istos(...) or a decorator |
| Enforced by | Zenoh, at the transport, before any Istos code runs | Istos, per request, inside the handler pipeline |
| Credential | Zenoh username / client certificate | an application token in the request attachment |
| Scope | the whole session / link | per key-expression / per handler |
Authentication is covered on the Security & TLS page. This page is
about the second layer: the authorizer.
The two layers do not know about each other
A peer's Zenoh username or mTLS certificate is not visible to an authorizer. Authorization sees only the token the caller re-sends in the request attachment. Use them together — TLS + credentials keep strangers off the fabric; the authorizer decides what an admitted peer may call. Neither substitutes for the other.
The mental model¶
An authorizer is any callable that receives an AuthContext
and returns a truthy value to allow or a falsy value (or raises
UnauthorizedError) to deny. Both sync and async are supported.
from istos import AuthContext, UnauthorizedError
def admins_only(ctx: AuthContext) -> bool:
return ctx.token in {"alice-key", "bob-key"} # truthy = allow
async def require_role(ctx: AuthContext) -> bool:
if ctx.token is None:
raise UnauthorizedError("missing token") # raising = deny
return await lookup_role(ctx.token) == "operator"
This is the same guard / policy-callback pattern you know from FastAPI
dependencies, Django permission classes, gRPC interceptors, or Envoy ext_authz.
Where it runs¶
Authorization is a network-boundary concern. It fires in on_query, before
parameter validation and before your handler body:
sequenceDiagram
participant C as Caller
participant H as Handler (on_query)
participant F as Your function
C->>H: query key + attachment(token)
H->>H: authorize(AuthContext)
alt denied
H-->>C: structured "unauthorized" reply (with correlation_id)
else allowed
H->>H: validate params
H->>F: run body
F-->>C: result
end
A denied request never reaches your function and never touches validation — the
caller gets a structured error reply (code: "unauthorized") carrying the
request's correlation_id, and the denial is logged. It is not a silent drop.
Subscribers are gated too
The same layered authorizer runs on @subscribe before the callback body: the
app-wide gate and a per-subscriber authorizer= both check the incoming
sample's attachment. Because pub/sub has no reply channel, a denied sample
is logged and dropped (not answered with an error). Public and
Depends(current_principal) work on subscribers exactly as on handlers.
In-process calls bypass authorization
The TestClient and calling a @query decorator locally go through the
in-process path (__call__), not on_query, so no authorizer runs. Local
calls are already inside your trust boundary; authorization guards the network
edge. Drive a request through the query path if you want to exercise the gate.
The AuthContext¶
Every authorizer receives one, describing the incoming request:
| Field | Type | Meaning |
|---|---|---|
prefix |
str |
The handler's registered key expression. |
key_expr |
str |
The actual key that was queried (useful with wildcards). |
params |
dict |
Parsed selector parameters (?robot_id=7 → {"robot_id": "7"}). |
attachment |
bytes \| None |
Raw bytes the caller attached to the request. |
.token |
str \| None |
Credential from the attachment. Bare UTF-8 string → token. JSON {"tok","cid","tp"} → envelope (see RequestEnvelope). |
Because the context carries prefix, key_expr, and params, you can authorize on
what is being touched, not only on who is calling.
Not the Zenoh username
ctx.token is whatever the caller put in the attachment — same idea as
Authorization: Bearer …. Unrelated to IstosZenohConfig credentials.
Send it with query_once / @query(..., token=…) /
publish_once(..., token=…), stream_query(..., token=…),
open_channel(..., token=…), or as Bearer on the gateway:
require_auth=True¶
Default construction stays open (you'll get an IstosSecurityWarning on insecure
transport). If you want construction itself to fail without an authorizer:
from istos import Istos, JWTAuthorizer
istos = Istos(
require_auth=True,
authorizer=JWTAuthorizer(secret=os.environ["JWT_SECRET"]),
)
# missing authorizer → IstosSecurityError
Public on a handler still opts that one endpoint out of the app-wide gate.
Layered resolution: app-wide and per-handler¶
Istos resolves a handler's effective authorizer by layering, exactly like a global middleware plus a per-route guard:
Handler authorizer= |
App-wide set? | Effective behavior |
|---|---|---|
| (unset) | yes | Inherit the app-wide authorizer. |
| (unset) | no | Open to any peer on the fabric. |
| a callable | yes | Both the app-wide and the handler authorizer must pass. |
| a callable | no | Only the handler authorizer applies. |
Public |
either | Bypass the app-wide gate for this one handler. |
The key property: a per-handler rule can only ever tighten access, never
silently loosen it. To open a single handler under an otherwise-protected app you
must say so explicitly with Public.
from istos import Istos, TokenAuthorizer, AuthContext, Public
istos = Istos(authorizer=TokenAuthorizer("app-token")) # baseline everywhere
@istos.handle("fleet/status") # inherits the baseline
async def status():
return {"ok": True}
def admins_only(ctx: AuthContext) -> bool:
return ctx.params.get("role") == "admin"
@istos.handle("fleet/shutdown", authorizer=admins_only) # baseline AND admins_only
async def shutdown(role: str):
return {"stopping": True}
@istos.handle("health/ping", authorizer=Public) # explicitly public
async def ping():
return {"pong": True}
For fleet/shutdown a request is allowed only when it carries a valid app-token
(app-wide layer) and presents role=admin (handler layer). Missing either one is
a denial.
TokenAuthorizer — the shared-secret gate¶
The built-in TokenAuthorizer allows a request only when its token matches one of an
accepted set. This is the classic service-to-service API key pattern.
from istos import Istos, TokenAuthorizer
istos = Istos(authorizer=TokenAuthorizer("super-secret-token"))
# Rotate keys with zero downtime by accepting old and new during rollover:
istos = Istos(authorizer=TokenAuthorizer({"old-token", "new-token"}))
A shared secret is not user identity
TokenAuthorizer is a bearer secret — no signature, expiry, or scopes. It is
right for authenticating services, not for authenticating users. For real
identity, use JWTAuthorizer (below).
JWTAuthorizer — verified identity + roles¶
For real user identity — signed, expiring, role-carrying tokens — use the
built-in JWTAuthorizer (the istos[jwt] extra, backed by PyJWT). It verifies
the token from the request attachment and maps it to a Principal:
import os
from istos import Istos, JWTAuthorizer
# HS256 shared secret:
istos = Istos(authorizer=JWTAuthorizer(secret=os.environ["JWT_SECRET"]))
# RS256 with an identity provider's public key + audience/issuer checks:
istos = Istos(authorizer=JWTAuthorizer(
public_key=PUBLIC_KEY_PEM,
algorithms=["RS256"],
audience="my-api",
issuer="https://idp.example.com",
))
It verifies the signature and exp (plus aud/iss when configured), then
builds Principal(id=<sub>, roles=<roles claim>, claims=<full payload>). The
none algorithm is always rejected. An absent, malformed, expired, or
wrong-audience token is denied (401). Tune the claim mapping with
id_claim= (default sub), roles_claim= (default roles; a space/comma
string like an OAuth2 scope is split into a set), leeway=, and
require_exp=.
require_roles — role-based authorization (RBAC)¶
Authenticate app-wide, then guard individual handlers with the roles they need.
require_roles reads the Principal the authenticator resolved and checks its
roles:
from istos import Istos, JWTAuthorizer, require_roles, Principal, Depends, current_principal
istos = Istos(authorizer=JWTAuthorizer(secret=SECRET)) # authenticate everything
@istos.handle("fleet/status") # any authenticated caller
async def status(): ...
@istos.handle("fleet/reset", authorizer=require_roles("admin"))
async def reset(user: Principal = Depends(current_principal)):
return {"reset_by": user.id}
@istos.handle("fleet/ops", authorizer=require_roles("admin", "operator", mode="any"))
async def ops(): ...
mode="all" (default) requires every listed role; mode="any" requires at least
one. The semantics follow HTTP: no authenticated identity → 401, identity
without the role → 403. This is the layered
model — the app-wide authenticator and the per-handler role guard both run.
If a handler needs auth without an app-wide authenticator, pass one inline:
@istos.handle("admin/op",
authorizer=require_roles("admin", authenticator=JWTAuthorizer(secret=SECRET)))
async def op(): ...
Combined with the HTTP gateway, this all works over HTTP too:
the Authorization: Bearer <jwt> header is forwarded as the token, so
JWTAuthorizer + require_roles gate HTTP callers exactly as they gate Zenoh peers.
Custom authorizers¶
Because an authorizer is just a callable, you can express any policy.
Role-based (RBAC) — verify the token and check a role:
async def require_operator(ctx: AuthContext) -> bool:
if ctx.token is None:
raise UnauthorizedError("missing token")
role = await lookup_role(ctx.token) # DB / cache / JWT decode
return role in {"operator", "admin"}
@istos.handle("fleet/shutdown", authorizer=require_operator)
async def shutdown(): ...
Attribute/resource-based (ABAC) — authorize on the request's target:
def owns_resource(ctx: AuthContext) -> bool:
caller = ctx.token # e.g. "robot-7"
target = ctx.params.get("robot_id") # from ?robot_id=...
return caller == target or ctx.prefix.startswith("public/")
@istos.handle("fleet/robot", authorizer=owns_resource)
async def robot_cmd(robot_id: str): ...
When you combine a custom authorizer with an app-wide one, remember they layer: the app-wide authorizer typically authenticates the caller and the per-handler authorizer authorizes what that authenticated caller may do.
Resolving an identity: the principal¶
The gate answers "is this request allowed?". Often it also, along the way, works
out "who is this?" — it decoded a token, looked up a role, verified a JWT. Throwing
that identity away and re-deriving it in the handler body is wasteful. So an authorizer
may return the identity instead of a bare True:
Any non-
booltruthy value an authorizer returns is treated as the principal.Truestill means "allowed, no identity"; a falsy value (or a raisedUnauthorizedError) still denies.
Istos stashes that principal on the request context, and you inject it into the handler
body with Depends(current_principal) — the gate resolves identity once, the body reuses
it:
from istos import Istos, Principal, AuthContext, Depends, current_principal
async def authenticate(ctx: AuthContext) -> Principal:
if ctx.token is None:
raise UnauthorizedError("missing token")
claims = await verify_jwt(ctx.token) # your identity source
return Principal(id=claims["sub"], roles=frozenset(claims["roles"]))
istos = Istos(authorizer=authenticate)
@istos.handle("fleet/status")
async def status(user: Principal = Depends(current_principal)) -> dict:
# `user` is the exact object `authenticate` returned — no re-parsing.
return {"requested_by": user.id, "is_admin": user.has_role("admin")}
Principal is a default shape (id, roles, claims) with a has_role()
helper — but the contract is structural, so you can return your own user object and
inject it the same way. It's just "whatever truthy thing the authorizer returned."
The gate decides, DI materializes¶
This is the clean division of labour that makes the authorizer not redundant with DI:
| Concern | Tool | When it runs |
|---|---|---|
| allow / deny (+ resolve identity) | the authorizer |
at the network boundary, before params are parsed |
| use the identity in your logic | Depends(current_principal) |
in the handler body, after the gate allowed the request |
You do not put the allow/deny decision in a Depends — that would run after untrusted
input is parsed, can't gate the built-in endpoints, and fires on in-process calls too.
Keep the decision in the authorizer; use DI only to consume what it resolved.
Related injectables (all from istos):
current_principal()→ the identity, orNoneif the request was allowed without one.current_token()→ the raw attachment decoded as a UTF-8 string, orNone.current_request()→ the fullRequestContext(correlation id, prefix, principal, …).
Layered identity. When both an app-wide and a per-handler authorizer run, the per-handler principal wins if it returns one, otherwise the app-wide principal is used — so a handler-specific policy can refine who the caller is, not just whether they pass.
In-process calls have no principal
current_principal() returns None under TestClient and local @query calls, because
the network gate never runs. When a handler under test needs an identity, override it:
app.dependency_overrides[current_principal] = lambda: Principal(id="test-user").
Protecting the built-in endpoints¶
.istos/health, .istos/ready, .istos/metrics, and serve_docs() are ordinary
handlers, so they inherit the app-wide authorizer — which is the only way to gate
them, since you don't register them yourself.
istos = Istos(authorizer=TokenAuthorizer("ops-token")) # gates health/ready/metrics
# A docs-specific authorizer layers on top of the app-wide one:
istos.serve_docs(web_port=8080, authorizer=TokenAuthorizer("docs-token"))
Istos emits an IstosSecurityWarning if you enable health/metrics or serve_docs()
with no authorizer at all — the docs endpoint in particular broadcasts your entire
API surface to every peer.
Guarantees — and honest limits¶
- Layered, never silently loosened. App-wide and per-handler authorizers both
apply; a per-handler rule can only add requirements. Opening a handler under an app
gate requires an explicit
Public. - Denials are structured, not silent. A denied request gets an
unauthorizederror reply with the request'scorrelation_id, and the event is logged. - Authorization is an application layer, not transport. It runs after a peer is already on the fabric. Always pair it with TLS + Zenoh credentials — an open, unencrypted transport with a great authorizer is still wide open at the link level.
- The attachment token is a bearer.
TokenAuthorizeris an API key. For signed identity useJWTAuthorizer(istos[jwt]). - One credential per request. The attachment carries a single token, so you can authenticate-then-authorize on one identity, but you cannot layer two independent shared secrets on the same request.
- The network edge only. In-process and
TestClientcalls are not authorized by design.
Next Steps¶
- Security & TLS — transport authentication and encryption
- Handlers & Queries (RPC) —
query_once, attachments, the request pipeline - Recipe: Secure RPC