Identity & access
Identity answers two questions, in order: is this credential real
(authentication), and may this principal do what they're asking
(authorization). Forze keeps both behind contracts in a separate plane,
forze_identity, so swapping an identity provider never reaches your handlers.
Authentication: verify, then resolve¶
Proving a credential is valid and deciding who it represents are two separate jobs. They meet at a single value object — and that's the whole design.
- Verify — a verifier proves the credential against its issuer (a JWT
signature, an API-key hash, OIDC JWKS) and emits a
VerifiedAssertion: vendor-flavoured proof carrying the issuer, subject, and claims. - Resolve — a
PrincipalResolverPortmaps that assertion to a canonicalAuthnIdentitywith aUUIDprincipal_id.
The verifier never invents a principal; the resolver never re-checks a
signature. The VerifiedAssertion is the entire seam between them — which is
why several verifiers (first-party JWT, OIDC, API keys) can sit behind one
orchestrator and feed the same resolver.
This is what keeps the rest of Forze provider-agnostic: your domain, tenancy,
and authorization code only ever see a UUID principal. Switching from Google
OIDC to internal SSO is a new verifier/resolver pair — not a change to a single
handler.
Resolving to a principal¶
Three first-party resolvers cover the common shapes — the choice is about whether you need stored accounts:
| Resolver | Maps subject → principal by… | Storage |
|---|---|---|
JwtNativeUuidResolver |
trusting a subject that's already a UUID (first-party Forze JWTs) | none |
DeterministicUuidResolver |
deriving a stable UUID from (issuer, subject) |
none |
MappingTableResolver |
looking up (issuer, subject) in a table, with optional just-in-time provisioning |
a mapping document |
Plugging in a provider¶
A route's AuthnSpec selects verifiers and a resolver by profile name. An
integration registers a verifier under a profile; the spec references it without
owning any vendor knowledge:
from forze.application.contracts.authn import AuthnSpec
api_authn = AuthnSpec(
name="api",
enabled_methods=frozenset({"token", "api_key"}),
token_profile="oidc",
resolver_profile="mapping",
)
Authorization: may they?¶
Once a request carries an AuthnIdentity, authorization decides what it may do.
Two questions, two ports:
| Question | Port | Resolved via |
|---|---|---|
| May this principal run this operation? | AuthzDecisionPort |
ctx.authz.decision(spec) |
| Which rows may they see? | AuthzScopePort |
ctx.authz.scope(spec) |
(A third slice — grant management — provisions the roles, permissions, and bindings those decisions read.)
Two permission keys are reserved by default: a principal holding admin
or <resource_type>.admin (e.g. invoice.admin) bypasses the owner_id
ownership check on resources. Don't reuse those names for unrelated app
permissions — or change the convention via
AuthzKernelConfig(owner_override_permissions=...): pass an empty set to
always enforce ownership, or your own keys (the literal {resource_type}
placeholder is substituted at evaluation time).
Enforcement belongs on the operation plan, not scattered across routes — so
it's authoritative for every caller, HTTP or not. Using the stage
hooks from the application layer: a BeforeStep
authorizes the operation, and a wrap step injects scope filters into
list/search queries. Both read the bound AuthnIdentity and TenantIdentity to
build the decision.
Authn events and login lockout¶
Authentication flows can narrate themselves. Wire an optional authn event
sink and every flow emits a structured AuthnEvent — login success/failure,
lockout, token refresh, refresh-reuse detection (the token-theft signal),
logout, password change, reset request/completion, principal deactivation:
from datetime import timedelta
from forze.application.integrations.authn import LockoutConfig
from forze_identity.authn import AuthnDepsModule, ConfigurableLoggingAuthnEventSink
authn_module = AuthnDepsModule(
kernel=kernel,
authn={"main": frozenset({"password", "token"})},
events=ConfigurableLoggingAuthnEventSink(), # one log line per event
lockout=LockoutConfig(threshold=5, window=timedelta(minutes=15)),
)
Emission is best-effort by contract: a sink failure (or no sink at all)
never fails the auth flow, and the failed-login event is emitted after the
verifier has produced its uniform error, so the Argon2 timing parity between
unknown-login and wrong-password failures is untouched. The shipped
LoggingAuthnEventSink logs failures, lockouts, and refresh reuse at WARNING
and everything else at INFO; in tests, MockDepsModule(authn_events=True)
records events onto state.authn_events for inspection.
Privacy: events carry a digest, never the login. AuthnEvent.login_digest
is sha256("lockout:" + login.lower()) — unpeppered pseudonymization, not
secrecy: it keeps raw logins out of logs and counter key spaces, while anyone
who can already read those stores could brute-force a known login anyway. The
same digest keys the lockout counters, so a locked login correlates with its
events.
Lockout is a fixed window over CounterPort. After threshold failed
attempts within the current window, further attempts raise a throttled error
(code="login_locked", HTTP 429 — retryable by kind) before password
verification, and unlock when the window rolls over. The window is fixed —
bucketed by floor(unix_now / window_seconds) — because CounterPort has no
TTL surface: without key expiry there is nothing to hang a sliding window or a
lock_for duration on (both are noted as future counter-port capabilities, as
is backend-side expiry of stale buckets, which today remain as dead value-only
keys). Lockout counts login strings, not accounts: a nonexistent login
locks exactly like a real one, preserving the no-enumeration posture.
The identity plane¶
All of this lives in forze_identity, separate from the core: authn, authz,
tenancy, oidc, and oauth subpackages, wired per route via the same deps
modules as any other integration.
For getting started, forze_identity.builtin ships presets — file/env API keys
(local) and Google / VK / Telegram Login over OIDC (idp). They're shipped-in
conveniences, not production defaults: adopt one only once you accept its trust
model (e.g. VK publishes no JWKS, so its preset verifies id_tokens by
server-side introspection against VK rather than a local signature check).