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",
)
Providers ship as integrations — see OIDC and authentication. The external IdP recipe wires a third-party identity provider end to end.
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 a failed-login event is emitted after the verifier's uniform
error, so the Argon2 timing parity between unknown-login and wrong-password failures is
untouched. Events carry login_digest (a SHA-256 of the login), never the raw login —
pseudonymization to keep logins out of logs and counter key spaces, not secrecy.
Lockout is a fixed window over CounterPort: after threshold failed attempts
within the window, further attempts raise throttled (code="login_locked", HTTP 429)
before password verification, and unlock when the window rolls over. It counts
login strings, not accounts, so 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).
Identity settles who the caller is; scoping which data they may reach is Multi-tenancy.