Official Python client SDK for AXIAM — Access eXtended Identity and Authorization Management.
- Repository: github.com/ilpanich/axiam-python-sdk
- PyPI package:
axiam-sdk - Registry: pypi.org/project/axiam-sdk (reserved, not yet published)
- Version tags:
vX.Y.Z - API docs: ilpanich.github.io/axiam-python-sdk
- License: Apache-2.0
- Python:
>=3.10(D-11)
This SDK conforms to CONTRACT.md §1–§13 and §12.7, §14, §15, §17, §19 (including §6.1 mTLS and the §10.1 minimum local-verification set).
§12.7, §14 and §15 are named rather than folded into the range because they landed after this SDK already claimed §1–§13: widening the range silently would turn a statement that was true when written into a different claim without anyone editing it.
See CONTRACT.md for the full cross-language behavioral contract.
Implemented (Phase 19). AxiamClient (sync) and the dedicated
AsyncAxiamClient (async, SDK-Q08) each expose the same canonical operation
names — login, verify_mfa, refresh, logout, check_access, can,
batch_check, and the nine §12 OIDC/SSO relying-party operations (see below)
— as sync or async def methods respectively (never an async_*-prefixed
twin on the sync class). Each client owns its own session, cookie jar, and
single-flight refresh guard. gRPC (sync grpcio + async grpc.aio), AMQP
(async-only aio-pika), a FastAPI dependency plus an oidc_login_router,
and a Django middleware plus oidc_login_views, are all available. Seven
runnable examples live under examples/.
pip install axiam-sdkThe FastAPI dependency and Django middleware are optional extras — install only what you need, since a pure REST/gRPC/AMQP consumer should not be forced to pull in FastAPI or Django:
pip install "axiam-sdk[fastapi]"
pip install "axiam-sdk[django]"[speed] adds uvloop for async workloads — measured at −20% client CPU and
a materially tighter p95 on the check_access path. The SDK never installs a
loop policy for you; see PERFORMANCE.md, which also explains
why a single CPython process tops out around 310 checks/s and what to do about
it:
pip install "axiam-sdk[speed]"from axiam_sdk import AxiamClientAxiamClient (sync) and AsyncAxiamClient (async, SDK-Q08) are separate
classes, each with their own session — pick the one that matches your call
site's paradigm.
from axiam_sdk import AxiamClient
# tenant_slug is required — AXIAM is multi-tenant and there is no default
# tenant (§5). login/refresh also require organization context (§5.1) — a
# tenant slug is only unique within an org — so pass org_slug too. TLS is
# always verify=True (§6); the only escape hatch is an explicit custom_ca
# parameter, never a boolean bypass.
with AxiamClient(base_url="https://localhost:8443", tenant_slug="acme", org_slug="acme") as client:
result = client.login(email, password)
if result.mfa_required:
result = client.verify_mfa(result.mfa_token, totp_code)
print(result.session_id, result.expires_in)import asyncio
from axiam_sdk import AsyncAxiamClient
async def main() -> None:
async with AsyncAxiamClient(
base_url="https://localhost:8443", tenant_slug="acme", org_slug="acme"
) as client:
result = await client.login(email, password)
if result.mfa_required:
result = await client.verify_mfa(result.mfa_token, totp_code)
print(result.session_id, result.expires_in)
asyncio.run(main())result = client.check_access("resource:read", resource_id)
can_write = client.can("resource:write", resource_id)
from axiam_sdk import AccessCheck
results = client.batch_check(
[
AccessCheck(action="resource:read", resource_id=resource_id),
AccessCheck(action="resource:delete", resource_id=resource_id, scope="admin"),
]
)AsyncAxiamClient exposes the same check_access/can/batch_check names
as async def methods, each backed by that client's own session and
single-flight refresh guard (§9). See
examples/rest_authz.py.
AuthzGrpcClient (sync, grpcio) and AsyncAuthzGrpcClient (async,
grpc.aio) are both first-class transports — the async client is not a
thread-pool bridge over the sync one.
from axiam_sdk.grpc import AuthzGrpcClient
client = AuthzGrpcClient(
"localhost:9443",
token_fn=lambda: current_access_token, # non-blocking cache read
tenant_id=tenant_id,
refresh_fn=refresh_fn, # invoked exactly once on UNAUTHENTICATED, then one retry (§9.3)
)
decision = client.check_access(subject_id, "resource:read", resource_id)See examples/grpc_checkaccess.py.
get_user_info is the low-latency gRPC counterpart of the server's REST
GET /oauth2/userinfo endpoint (CONTRACT.md §1.1, contract 1.3). It has no
REST form in the SDK vocabulary. The request is empty — identity is derived
entirely server-side from the bearer token — and it returns a typed
UserInfo(sub, tenant_id, org_id, email, preferred_username). email is
populated only when the access token carries the email scope and
preferred_username only with the profile scope (both None otherwise);
sub/tenant_id/org_id are always present. Calling it with no token raises
AuthError client-side without a wire call, and a gRPC UNAUTHENTICATED
drives the same single-flight refresh-and-retry-once path as check_access
(§9). It is exposed as get_user_info() on both AuthzGrpcClient (sync) and
AsyncAuthzGrpcClient (async).
info = client.get_user_info()
print(info.sub, info.tenant_id, info.org_id, info.email, info.preferred_username)from axiam_sdk.amqp import ErrDrop, consume
async def handler(event: dict) -> None:
if "action" not in event:
raise ErrDrop("poison message") # nack without requeue
... # None return -> ack; any other exception -> nack with requeue
await consume(channel, "axiam.authz.request", signing_key, handler, prefetch=10)Every delivery's HMAC-SHA256 signature is verified BEFORE the handler is
ever invoked — an unverified message never reaches your code. See
examples/amqp_consumer.py.
Both framework guards below verify the access token locally and therefore
apply the complete CONTRACT.md §10.1 minimum local-verification set, through
the single entry point JwksVerifier.verify_access_token(...):
| # | Claim | What this SDK does |
|---|---|---|
| 1 | signature | alg pinned to EdDSA and checked before any JWKS lookup, so alg: none and HS-family confusion are rejected without ever consulting a key |
| 2 | exp |
Required and must be a JSON number — a token with no exp is a permanent credential and is rejected, and a numeric string exp (which PyJWT would coerce) is rejected too |
| 3 | nbf |
Honoured when present; absent is valid |
| 4 | tenant_id |
Required and asserted against the configured tenant; no configured tenant fails closed |
| 5 | iss |
Checked only when expected_issuer is configured (optional, unset by default — no issuer is ever assumed) |
| 6 | aud |
Checked only when expected_audience is configured; a user-facing resource server should pass RECOMMENDED_RESOURCE_SERVER_AUDIENCE ("axiam:user") |
| 7 | clock skew | DEFAULT_CLOCK_SKEW_SECONDS (60 s), bounded by MAX_CLOCK_SKEW_SECONDS — never settable to an unbounded value |
from axiam_sdk._jwks import (
DEFAULT_CLOCK_SKEW_SECONDS,
RECOMMENDED_RESOURCE_SERVER_AUDIENCE,
JwksVerifier,
)
verifier = JwksVerifier(
base_url,
expected_issuer="https://axiam.example.com", # optional
expected_audience=RECOMMENDED_RESOURCE_SERVER_AUDIENCE, # optional
clock_skew_seconds=DEFAULT_CLOCK_SKEW_SECONDS, # bounded
)JwksVerifier.verify_signature_only_unchecked(...) is the raw signature-only
primitive §10.1 permits for integrators implementing their own policy. Its
name states the omission: it checks no claims at all, and the SDK's own
guards never call it.
from fastapi import Depends, FastAPI
from axiam_sdk.fastapi import AxiamUser, JwksVerifier, require_authenticated_user
verifier = JwksVerifier(base_url)
authenticated_user = require_authenticated_user(verifier, "acme")
app = FastAPI()
@app.get("/protected")
async def protected(user: AxiamUser = Depends(authenticated_user)):
return {"user_id": user.user_id, "tenant_id": user.tenant_id, "roles": user.roles}See examples/fastapi_dependency.py.
# settings.py
MIDDLEWARE = [..., "axiam_sdk.django.middleware.AxiamAuthMiddleware"]
AXIAM_JWKS_BASE_URL = "https://localhost:8443"
AXIAM_TENANT_SLUG = "acme"
# Optional §10.1 rule 5-7 settings; all default to unset / the recommended value.
AXIAM_EXPECTED_ISSUER = "https://localhost:8443" # unset -> iss not checked
AXIAM_EXPECTED_AUDIENCE = "axiam:user" # unset -> aud not checked
AXIAM_CLOCK_SKEW_SECONDS = 60 # bounded by MAX_CLOCK_SKEW_SECONDS# views.py
def protected_view(request):
user = request.axiam_user
return JsonResponse({"user_id": user.user_id, "roles": user.roles})See examples/django_middleware.py.
Layered on top of the §10 authentication guards above, require_access /
require_role add a per-endpoint AXIAM authorization check without hand-
writing check_access(...) calls in every handler. They run strictly
after authentication (never a separate/duplicated token-verification
path) and check the request's authenticated caller (subject_id), never
the SDK client's own — typically service-account — identity. Error
mapping: unauthenticated -> 401; denied -> 403; an
unresolvable resource id -> 400; a transport failure while calling the
authz endpoint -> 503 (fail closed — never allow on a transport error). No
decision caching: every request is a fresh check_access round-trip.
require_role is a local, no-round-trip check against the verified
identity's roles — cheaper but coarser, and NOT a substitute for
require_access's authoritative, resource-level check.
FastAPI (axiam-sdk[fastapi]) — require_access takes the async
AsyncAxiamClient:
from fastapi import Depends, FastAPI
from axiam_sdk import AsyncAxiamClient
from axiam_sdk.fastapi import AxiamUser, JwksVerifier, require_access, require_role
verifier = JwksVerifier(base_url)
authz_client = AsyncAxiamClient(base_url=base_url, tenant_slug="acme")
app = FastAPI()
require_doc_read = require_access(
verifier, "acme", authz_client, "documents:read", resource_param="doc_id"
)
@app.get("/docs/{doc_id}")
async def get_doc(doc_id: str, user: AxiamUser = Depends(require_doc_read)):
return {"message": f"user {user.user_id} may read document {doc_id}"}
require_admin_role = require_role(verifier, "acme", "admin")
@app.delete("/admin/cache")
async def reset_cache(user: AxiamUser = Depends(require_admin_role)):
return {"message": f"cache reset by {user.user_id}"}The resource id is resolved, in precedence order, from a literal
resource_id= (singleton resources), a resource_param= path parameter
name, or a resolver=lambda request: ... callback (body fields, headers,
composite lookups) — exactly one must be supplied.
Django (axiam-sdk[django]) — require_access/require_role are view
decorators reading request.axiam_user (set by AxiamAuthMiddleware) and
take the sync AxiamClient:
from axiam_sdk import AxiamClient
from axiam_sdk.django.decorators import require_access, require_role
authz_client = AxiamClient(base_url="https://localhost:8443", tenant_slug="acme")
@require_access(authz_client, "documents:read", resource_param="doc_id")
def get_document(request, doc_id):
user = request.axiam_user
return JsonResponse({"message": f"user {user.user_id} may read document {doc_id}"})
@require_role("admin")
def reset_cache_view(request):
return JsonResponse({"message": f"cache reset by {request.axiam_user.user_id}"})Both async and sync Django views are supported (require_access/
require_role detect the wrapped view's dispatch mode automatically).
resource_param defaults to "pk", matching the view kwarg Django's own
URL path converters typically bind a captured resource identifier to.
See examples/fastapi_dependency.py and
examples/django_middleware.py.
AxiamClient/AsyncAxiamClient expose the nine canonical §12 operations
directly (this SDK has no browser-bundle constraint, so — unlike the
TypeScript SDK's dedicated OidcClient — the methods live on the same
client used for everything else). They let a backend application offer
"Login with AXIAM" (authorization-code + PKCE against AXIAM's own OIDC
provider), authenticate itself as a service account (client_credentials),
introspect/revoke tokens, and drive the server's upstream-IdP federation
endpoints:
| Operation | Purpose |
|---|---|
oidc_discover() |
GET /.well-known/openid-configuration — cached per origin, ≥5-minute TTL, single-flight |
oidc_begin(...) |
Build the authorization URL + PKCE verifier/state/nonce — pure local computation, no network I/O |
oidc_exchange(...) |
POST /oauth2/token (authorization_code) — validates the returned ID token in full (§12.4) before returning |
oidc_refresh(...) |
POST /oauth2/token (refresh_token) — a distinct operation from refresh(), under the same §9 single-flight guard |
login_client_credentials(...) |
POST /oauth2/token (client_credentials) — service-account M2M login, no id_token |
introspect(...) |
POST /oauth2/introspect (RFC 7662) — requires confidential-client credentials |
revoke(...) |
POST /oauth2/revoke (RFC 7009) — idempotent; any 200 (including for an unknown token) is success |
sso_start(...) |
POST /api/v1/auth/federation/oidc/start — step 1 of upstream-IdP SSO |
sso_complete(...) |
POST /api/v1/auth/federation/oidc/callback — step 2; the session arrives via Set-Cookie, no token in the body |
Both AxiamClient (sync) and AsyncAxiamClient (async, async def twins
under the same names, SDK-Q08) expose all nine — including oidc_begin,
which performs no I/O but is still async def on the async client, per
CONTRACT.md §12.2's Python naming table.
The caller owns the login state (§12.3 rule 1). oidc_begin returns
state, nonce, and code_verifier and stores none of them anywhere — no
process-global cache, no implicit session. Persist all three yourself
(typically in your own HTTP session) between the login redirect and the
callback, and pass nonce/code_verifier back into oidc_exchange
explicitly. MemoryOidcStateStore (single-use consume, 10-minute TTL) is
available for framework integrations that need somewhere to park that
triple across the two HTTP requests of a redirect flow — it is optional and
per-instance, never process-global.
from axiam_sdk import AxiamClient, OAuthProtocolError, AuthError
client = AxiamClient(
base_url="https://localhost:8443",
tenant_slug="acme",
client_id="my-backend-app",
client_secret="changeme", # omit for a public client
)
configuration = client.oidc_discover()
request = client.oidc_begin(
configuration=configuration,
redirect_uri="https://app.example.com/oidc/callback",
scope="openid profile email",
)
# ... persist request.state / request.nonce / request.code_verifier,
# redirect the browser to request.url, and receive the callback ...
try:
tokens = client.oidc_exchange(
code=callback_code,
code_verifier=request.code_verifier,
redirect_uri="https://app.example.com/oidc/callback",
nonce=request.nonce,
tenant_id="00000000-0000-0000-0000-000000000000",
)
except OAuthProtocolError as exc:
print(f"{exc.error}: {exc.error_description}")
except AuthError as exc:
print(f"login failed ({exc.reason}): {exc}")
else:
print(tokens.id_claims.sub if tokens.id_claims else "no id_token")OAuthProtocolError is a language-idiomatic sub-type of AuthError
(CONTRACT.md §2/§12.3 rule 3) — existing except AuthError: code keeps
matching it unchanged. It carries error/error_description and
str(exc) == "<error>: <error_description>". Every §12.4 ID-token
validation failure raises the plain AuthError with a stable
reason — one of invalid_alg, unknown_kid, invalid_signature,
invalid_issuer, invalid_audience, token_expired, nonce_mismatch.
access_token, refresh_token, id_token, client_secret, and
code_verifier are all pydantic.SecretStr (§7/§12.5) — never printed or
serialized in the clear; read the raw value via .get_secret_value().
state/nonce are plain strings (§12.3 rule 2 — not secrets).
Framework glue. axiam_sdk.fastapi.oidc_login_router(client, redirect_uri=...)
builds a two-route APIRouter (login redirect + callback); axiam_sdk.django.oidc.oidc_login_views(client, redirect_uri=...)
builds a (login_view, callback_view) pair sharing one state store. Both
delegate entirely to the operations above and to the existing session/cookie
machinery — see examples/oidc_login.py.
RFC 8628 — signing in a device that cannot show a browser: a TV, a CLI, a
headless commissioning tool. device_authorize, device_poll and the composed
device_login, on both AxiamClient and AsyncAxiamClient.
def show(auth: DeviceAuthorization) -> None:
# Called BEFORE the first poll. Display it however the device can —
# screen, QR code, e-ink panel. The SDK never prints it for you.
print(f"visit {auth.verification_uri} and enter {auth.user_code}")
tokens = client.device_login(show, scope="openid profile")The polling rules are where implementations go wrong, so they are worth stating:
slow_downraises the interval permanently. An SDK that backs off for one round and returns to the original interval will be told to slow down again, forever.access_deniedandexpired_tokenstay distinct. A human said no, versus nobody answered — the only information the device can act on.- Polling stops at
expires_in, even if the server has not yet saidexpired_token. - A
5xxmid-poll is not terminal. A server restart must not lose a grant the user has already approved.
device_code is a SecretStr; user_code deliberately is not — it exists to
be read aloud, and wrapping it would defeat the one thing it is for.
device_authorize sends no client_secret and does not refuse a client built
without one: a device that cannot show a browser cannot keep a secret either.
The async device_login awaits an async callback before polling, so a
device that needs to await a paint still satisfies §14.3 rule 2.
Per §14.3 rule 4, device_login returns the token set rather than adopting
it, matching this SDK's login_client_credentials posture. See
examples/device_login.py.
RFC 8693 — a service holding a user's token exchanging it for a narrower one before calling the next service.
exchanged = client.token_exchange(
subject_token=user_token,
scopes=["orders:read"],
audience="orders-service",
)Most of what this method does is refuse to be helpful, and each refusal is deliberate:
- No default
actor_token. Omitting it asks for impersonation; the SDK will not quietly substitute the client's own session token and turn that into a delegation. - No auto-narrowing after
invalid_scope. The server refuses rather than silently narrowing precisely so the caller finds out here. - No refresh token, ever —
ExchangedTokenhas no such field, so there is nothing to synthesise. Re-run the exchange. - No adoption. The issued token is handed onward in one call; adopting it
would silently re-privilege every later call this client makes. A MUST NOT,
where
login_client_credentialsadoption is a MAY.
See examples/token_exchange.py.
logout_url builds the redirect; verify_logout_token validates a token the
OP pushed to your back-channel endpoint.
url = client.logout_url(id_token=stored_id_token)
# …and at your registered backchannel_logout_uri:
verified = client.verify_logout_token(logout_token)
if verified.sid is not None:
end_session(verified.sid) # that session ONLYThe verifier is where the security weight sits — the input arrives unsolicited
and instructs you to terminate a session. It checks the signature (same JWKS
path, same kid-required discipline as §12.4), iss, aud, that events
carries the back-channel-logout key (the only thing separating a logout token
from an ID token), that nonce is absent (its presence is how an ID token
gets replayed as one), that something is named, and freshness.
It returns sid/sub/jti rather than a bare bool: you have to know
which session to end. Dedup on jti yourself — delivery is at-least-once,
so a valid token legitimately arrives twice; the SDK has no durable store and
an in-memory guard would silently drop a real second logout after a restart.
See examples/logout.py.
axiam_sdk.webhook.verify_webhook(secret, signature_header, body) verifies the
X-Axiam-Signature: t=<unix_seconds>,v1=<hex> header AXIAM sends on every webhook
delivery — HMAC-SHA256 over "<timestamp>.<raw_body>", compared in constant time,
with a two-sided freshness window (default 300s):
from axiam_sdk.webhook import WebhookVerifyError, verify_webhook
# Flask: request.get_data() is the RAW bytes off the wire. Do NOT verify
# against request.get_json() re-dumped — re-serializing changes key order/
# whitespace and breaks the MAC (CONTRACT.md §13.3 rule 1).
@app.post("/webhooks/axiam")
def axiam_webhook():
try:
event = verify_webhook(
secret=WEBHOOK_SECRET, # a pydantic.SecretStr or plain str
signature_header=request.headers["X-Axiam-Signature"],
body=request.get_data(), # raw bytes, NOT re-serialized JSON
)
except WebhookVerifyError:
return "invalid signature", 400
# X-Axiam-Delivery (event.delivery_id, if you pass it through — see
# below) is the at-least-once dedup key: retries replay a validly-
# signed delivery inside the freshness window, so keep a short-lived
# seen-set if double-processing an event would be unsafe.
...
return "", 200FastAPI is the same shape with await request.body() in place of
request.get_data() — both give you the exact raw bytes the server signed;
await request.json() does not, for the same re-serialization reason.
verify_webhook also accepts event_type/delivery_id (pass the raw
X-Axiam-Event/X-Axiam-Delivery header values straight through — neither
is covered by the MAC) so the returned WebhookEvent carries them, a
tolerance override (seconds, default 300), and a now injection seam for
tests. WebhookVerifyError's message never includes the expected/computed
signature or the secret.
pip install-ing this package does not require buf/protoc — the
generated gRPC stubs (src/axiam_sdk/grpc/gen/) are committed and shipped
in both the wheel and the sdist. Contributors regenerating them locally run:
bash scripts/gen_grpc.shCI regenerates the same way and fails the build on any drift
(git diff --exit-code) between the committed stubs and a fresh
regeneration from proto/axiam/v1/.
httpx clients are constructed with verify=True hardcoded; the only
escape hatch is an explicit custom_ca parameter (a CA bundle path or
ssl.SSLContext) — there is no boolean bypass anywhere in this SDK,
including the examples. CI enforces this with a dedicated grep gate.
For IoT devices and service accounts that authenticate by mutual TLS, pass
a PEM client-certificate chain plus its PEM private key (each str or
bytes). The same identity is applied to both the REST and gRPC transports of
the client, and presenting it never relaxes server verification — strict
TLS (§6) stays fully on.
from axiam_sdk import AxiamClient
with open("device-cert.pem", "rb") as f:
client_cert = f.read()
with open("device-key.pem", "rb") as f:
client_key = f.read()
client = AxiamClient(
base_url="https://axiam.example.com",
tenant_slug="acme",
custom_ca="/etc/axiam/org-ca.pem", # server trust (optional; system roots by default)
client_cert=client_cert, # PEM cert chain (str or bytes)
client_key=client_key, # PEM private key (str or bytes)
)
# AsyncAxiamClient(...) takes the identical client_cert=/client_key= parameters.client_cert and client_key must be supplied together (only one is a
construction-time error), and a non-PEM value is rejected at construction. The
private key is secret material: it is loaded straight into the TLS stack and is
never logged, stored as a public attribute, or exposed via a getter (§6.1
rule 3 / §7). The gRPC authorization clients accept the same
client_cert=/client_key= parameters.
pip install -e ".[dev,fastapi,django]"
pytest tests
mypy --strict src
ruff check .
ruff format --check .Coverage (as CI runs it, reported to Coveralls):
pytest --cov=axiam_sdk --cov-report=lcovRead-only authorization checks — check_access, can, batch_check, on both the sync and
async clients — retry transient failures under the contract's normative table: 3 attempts
(1 initial + 2 retries), 200 ms base, 5 s cap, full jitter (uniform over [0, backoff]),
and Retry-After honored as a floor.
This SDK had no §16 policy before — only §9.3's refresh-then-retry-once, which is a different mechanism. §11.2 rule 5 had been requiring one since it was written.
Only failures that could plausibly succeed on a second attempt are retried: transport errors,
408, 429, 5xx. A 401 or 403 is an answer, not a transport failure, and surfaces after
exactly one attempt. Nothing that changes server state is ever retried.
# Turn it off if you own your own retry layer — you know your deadline, this SDK doesn't.
client = AxiamClient(base_url=..., tenant_slug="acme", retry_enabled=False)There is deliberately no knob for the attempt cap, base delay or delay cap: §16.1 forbids raising them, and eleven SDKs agreeing on one table is the point.
client.close() (sync) and await client.aclose() (async) release local resources. Both are
idempotent, and any call afterwards raises NetworkError naming the cause rather than silently
reconnecting.
Neither logs out. They never reach the network. The server-side session deliberately
outlives the client object — that is what lets a process restart and resume — so a close()
that logged out would silently end every user's session on each deploy. Call logout() first
if ending the session is what you want.
Wire metrics without this package depending on any metrics library:
from axiam_sdk import AxiamClient, RequestEnd, Retry, TelemetryEvent
def sink(event: TelemetryEvent) -> None:
if isinstance(event, RequestEnd):
histogram.record(event.duration_ms, {"op": event.operation, "outcome": event.outcome})
elif isinstance(event, Retry):
counter.add(1, {"op": event.operation, "attempt": event.attempt})
client = AxiamClient(base_url=..., tenant_slug="acme", telemetry_hook=sink)- A hook that raises cannot fail the operation that fired it. Telemetry is not permitted to fail an authorization check.
- No event payload can carry a token. The event dataclasses are frozen with a fixed field set — this surface exists to be shipped to a metrics backend.
- Path templates, not URLs, so a metric label cannot become a cardinality bomb.
One RequestStart/RequestEnd pair is emitted per attempt, so you can count real wire
calls. See examples/telemetry_hook.py for the OpenTelemetry
mapping.
An optional TTL-bounded cache for check_access results. Disabled by default, because
§11.2 rule 6's ban on caching authorization decisions is still the default behaviour.
client = AxiamClient(base_url=..., tenant_slug="acme", decision_memo_ttl_ms=5000) # 0 = offWhat you are accepting. The staleness bound is the TTL, in both directions: a grant revoked on the server can still read as allowed for up to the TTL, and a grant just added can still read as denied for up to the TTL.
Reads-your-own-writes is not guaranteed. An admin UI that grants a role and immediately re-checks is the case that breaks, and it breaks silently. If that is your workload, leave this off.
The TTL is clamped to 5000 ms rather than rejected. Allows and denies are memoized identically
— asymmetric caching would leak which outcome occurred through latency. Failures are never
memoized: caching a transport error as a deny would turn a blip into a TTL-long outage. The
memo is cleared on login, verify_mfa, refresh and logout, since entries are keyed by
subject rather than by session. It is thread-safe.