altaforge.auth¶
Shared FastAPI authentication for AltaForge services: middleware, pluggable backends, the trusted-subsystem (mint) model, Casbin RBAC, and the organization registry. For the design and adoption guide, see Trusted-subsystem auth.
altaforge.auth ¶
Shared FastAPI authentication middleware for AltaForge services.
ClientCredentialsAuthBackend ¶
ClientCredentialsAuthBackend(org_resolver=None, *, expected_tenant_id=None)
Validates an Entra service-account (client-credentials / M2M) token.
The machine-to-machine counterpart of :class:JwtAuthBackend, for headless
callers with no interactive user (a backend service authenticating as
itself). Differences from the user backend:
- Audience is the target service's API app-registration id
(
AZURE_API_APP_CLIENT_ID), distinct from the user-facingAZURE_APP_CLIENT_IDa delegated-user token carries. - Identity is the calling service principal, taken from the
azp(orappid) claim — there is no user-identifier claim. The returneduser_idisservice:<app-id>. - Roles come from the
rolesclaim (Entra application roles).
Organizations are resolved via the injected OrgResolver keyed on that
service identity — exactly as for a user — so tenancy enforcement downstream
is identical regardless of caller kind.
Initialise the backend.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
org_resolver
|
OrgResolver | None
|
Resolver invoked once per request to hydrate the service principal's organizations. When omitted, organizations is always an empty list. |
None
|
expected_tenant_id
|
str | None
|
When set, the token's |
None
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If |
authenticate
async
¶
authenticate(request)
Validate the request's service-account Bearer token and return UserData.
CompositeAuthBackend ¶
CompositeAuthBackend(routes, default, *, claim='iss')
Routes each request to a backend by one unverified claim on the token.
The routing claim (iss by default) is read WITHOUT verifying the
signature — only to choose which backend validates the token. The selected
backend then performs the real cryptographic checks, so a forged claim merely
routes to a backend that rejects it. This is the seam that lets one endpoint
accept, e.g., delegated user tokens and service-account tokens side by side
without either backend knowing about the other.
Choose claim for what actually distinguishes the token kinds you serve:
iss— when the kinds come from different issuers (e.g. an Entra tenant vs a local IP-allowlist issuer).aud— when a delegated user token and a service-account (client-credentials) token come from the same Entra tenant (sameiss) but target different app registrations. This is the DocGen case: route the API app-registration id to aClientCredentialsAuthBackendand fall through to a userJwtAuthBackend.
Initialise with a claim-value→backend map and a fallback backend.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
routes
|
dict[str, AuthBackend]
|
Maps an exact value of the routing |
required |
default
|
AuthBackend
|
Backend used when the routing claim is absent or not in the map (so unknown tokens still get validated, and rejected, by a real backend rather than silently passing). |
required |
claim
|
str
|
The top-level token claim to route on. Defaults to |
'iss'
|
authenticate
async
¶
authenticate(request)
Pick a backend by the unverified routing claim, then delegate real validation to it.
FakeAuthBackend ¶
FakeAuthBackend(is_admin=False, org_resolver=None)
Returns a hardcoded local-dev user. Never use in production.
When an OrgResolver is supplied and returns a non-empty list, that list
is used; otherwise the value of FAKE_AUTH_ORGANIZATIONS is used (or the
single-element list ['local'] when the env var is unset). This keeps
the local-dev path consistent with whatever Casbin/RBAC policy the app
has wired up, while still providing a sensible default for the cold-start
case.
Build a fake user and log a startup warning.
InternalTokenAuthBackend ¶
InternalTokenAuthBackend(minter, org_resolver=None)
Validates the backend's own internal token from the Authorization header.
This is the adapter-agnostic seam: whatever upstream provider the BFF used
(Entra, Google, dev), it forwards a single internal token whose issuer is
this backend. Roles ride in the token (Casbin-sourced at mint); organizations
are resolved per request via the unchanged OrgResolver path, exactly
as :class:JwtAuthBackend does — only the token validation differs (own
key/issuer/audience instead of an IdP's JWKS). A token minted by a foreign
issuer therefore fails validation and is rejected.
Initialise the backend.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
minter
|
InternalTokenMinter
|
Validates the internal token (shares the backend's key, issuer, and audience with the mint endpoint). |
required |
org_resolver
|
OrgResolver | None
|
Resolver invoked once per authenticated request to hydrate the user's organizations. When omitted, organizations is always an empty list. |
None
|
authenticate
async
¶
authenticate(request)
Validate the request's internal Bearer token and return UserData.
JwtAuthBackend ¶
JwtAuthBackend(org_resolver=None, *, expected_tenant_id=None)
Validates Azure AD JWT tokens from the Authorization header.
Configuration is resolved once at construction time so misconfiguration
fails at app startup rather than on the first protected request.
JWKS and issuer URLs are derived from AZURE_TENANT_ID when
AZURE_JWKS_URL and AZURE_ISSUER_URL are not set explicitly.
A single :class:jwt.PyJWKClient is constructed and reused so the JWKS
response is cached across requests (defeating the cache by reconstructing
per-request would mean a JWKS HTTP fetch on every authenticated call).
Initialise the backend with an optional OrgResolver.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
org_resolver
|
OrgResolver | None
|
Resolver invoked once per authenticated request to hydrate the user's organizations. When omitted, organizations is always an empty list. |
None
|
expected_tenant_id
|
str | None
|
When set, the token's |
None
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If required Azure environment variables are not set. |
CasbinConfig ¶
Bases: BaseModel
Validated configuration for :class:~altaforge.auth.casbin.enforcer.ManagedCasbinEnforcer.
At least one of casbin_source_url or af_policy_file must be supplied. If both are provided, casbin_source_url takes priority and af_policy_file is ignored — the database is the source of truth in all deployed environments. af_policy_file exists solely as a local-development fallback when no database is available.
Pydantic raises a ValidationError at construction time if neither is
provided, so misconfiguration surfaces immediately on startup rather than
on the first authenticated request.
Example — database source (production)::
config = CasbinConfig(
casbin_source_url=settings.CASBIN_SOURCE_URL,
)
Example — file source (local dev)::
config = CasbinConfig(
af_policy_file='/app/policy.csv',
)
CasbinOrgResolver ¶
CasbinOrgResolver(registry_loader, enforcer)
Resolves the organizations a user may access by combining registry and Casbin.
Fetches all known organizations from a :class:~altaforge.auth.registry.protocols.RegistryLoader,
then filters them to those where Casbin grants the user access. The
registry fetch and enforcer refresh are issued concurrently via
:func:asyncio.gather to minimise latency.
Implements the :class:~altaforge.auth.protocols.OrgResolver protocol so it
can be passed directly to :class:~altaforge.auth.backends.JwtAuthBackend
and :class:~altaforge.auth.backends.FakeAuthBackend.
Example::
from altaforge.auth import AuthMiddleware, JwtAuthBackend, OrgResolver
from altaforge.auth.casbin import ManagedCasbinEnforcer, CasbinConfig
from altaforge.auth.registry import ManagedRegistryLoader, RegistryConfig
casbin_config = CasbinConfig(
casbin_source_url=settings.CASBIN_SOURCE_URL, # postgresql://...
casbin_require_ssl=True,
)
enforcer = ManagedCasbinEnforcer.from_config(casbin_config)
registry_config = RegistryConfig(
registry_source_url=settings.REGISTRY_SOURCE_URL,
registry_require_ssl=True,
)
registry_loader = ManagedRegistryLoader.from_config(registry_config)
resolver = CasbinOrgResolver(registry_loader=registry_loader, enforcer=enforcer)
app.add_middleware(AuthMiddleware, auth_backend=JwtAuthBackend(org_resolver=resolver))
Create the resolver.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
registry_loader
|
ManagedRegistryLoader
|
Source of all known organizations. |
required |
enforcer
|
ManagedEnforcer
|
Managed Casbin enforcer used for authorization checks. |
required |
organizations_for
async
¶
organizations_for(user_id)
Return the organization IDs user_id is permitted to access.
Performs a Casbin enforce(user_id, org_id, 'access') check for
each organization returned by the registry loader.
CasbinRoleResolver ¶
CasbinRoleResolver(enforcer, *, base_role=DEFAULT_BASE_ROLE, admin_role=DEFAULT_ADMIN_ROLE, admin_group=DEFAULT_ADMIN_GROUP)
Resolves a user's application roles from Casbin group membership.
Google (and other non-Entra providers) carry no app-role claim, so roles are
sourced here instead of from an IdP claim (T-decision-5). Every user gets
base_role; a user who belongs to the Casbin global-admin group
additionally gets admin_role.
Implements :class:~altaforge.auth.protocols.RoleResolver so it can be passed
directly to the internal-auth router.
Create the resolver.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
enforcer
|
ManagedEnforcer
|
Managed Casbin enforcer used to read group membership. |
required |
base_role
|
str
|
Role granted to every authenticated user. |
DEFAULT_BASE_ROLE
|
admin_role
|
str
|
Role additionally granted to global admins. |
DEFAULT_ADMIN_ROLE
|
admin_group
|
str
|
Casbin grouping subject that denotes a global admin. |
DEFAULT_ADMIN_GROUP
|
roles_for
async
¶
roles_for(user_id)
Return the application roles user_id holds, per Casbin membership.
DatabasePolicyLoader ¶
DatabasePolicyLoader(url, require_ssl=False)
Loads Casbin policy rules from a PostgreSQL casbin_rule table.
Owns its async SQLAlchemy engine for the duration of the process.
Call :meth:aclose (or use async with) on shutdown to dispose it.
The DSN is passed directly — the caller is responsible for sourcing it (e.g. from their own environment or secrets manager).
require_ssl=True emits psycopg-only SSL connect args — see
:mod:altaforge.auth.pg_ssl for the driver/SSL contract.
FilePolicyLoader ¶
FilePolicyLoader(policy_file)
Loads Casbin policy rules from a CSV policy file on disk.
The file path is passed directly — the caller is responsible for sourcing it. The read is offloaded to a thread so the event loop is not blocked.
ManagedCasbinEnforcer ¶
ManagedCasbinEnforcer(policy_loader, model_path=DEFAULT_MODEL_PATH)
Casbin enforcer that loads fresh policy on every :meth:get_enforcer call.
Wraps a :class:~altaforge.auth.casbin.protocols.PolicyLoader and builds a
new casbin.Enforcer on each call, ensuring policy is always current with
no staleness window.
Wildcard role matching (glob_match) is registered automatically so
patterns like *@altaml.com work as expected in policy assignments.
The preferred way to construct this in production is via :meth:from_config,
which accepts a validated :class:~altaforge.auth.casbin.config.CasbinConfig
and selects the correct loader automatically. The direct constructor is
available for testing and advanced use cases where the loader is injected.
Usage (production)::
from altaforge.auth.casbin import CasbinConfig, ManagedCasbinEnforcer
config = CasbinConfig(
casbin_source_url=settings.CASBIN_SOURCE_URL,
)
enforcer = ManagedCasbinEnforcer.from_config(config)
# At app startup:
await enforcer.initialize()
# In a request handler:
e = await enforcer.get_enforcer()
allowed = e.enforce(user_id, resource, 'access')
# At app shutdown:
await enforcer.aclose()
Create the enforcer with an explicit loader.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
policy_loader
|
PolicyLoader
|
Strategy that supplies raw Casbin policy text. |
required |
model_path
|
str
|
Path to a Casbin model |
DEFAULT_MODEL_PATH
|
from_config
classmethod
¶
from_config(config, model_path=DEFAULT_MODEL_PATH)
Construct from a validated :class:~altaforge.auth.casbin.config.CasbinConfig.
Selects :class:~altaforge.auth.casbin.loaders.DatabasePolicyLoader when
config.casbin_source_url is set, otherwise
:class:~altaforge.auth.casbin.loaders.FilePolicyLoader.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
CasbinConfig
|
Validated Casbin configuration provided by the client app. |
required |
model_path
|
str
|
Override the Casbin model path (optional). |
DEFAULT_MODEL_PATH
|
initialize
async
¶
initialize()
Eagerly verify the policy source at application startup.
Call this from your ASGI lifespan startup handler so that policy load failures surface immediately rather than on the first authenticated request.
ManagedEnforcer ¶
PolicyLoader ¶
Bases: Protocol
Loads raw Casbin policy rules from a backing store.
load
async
¶
load()
Return all policy rules as a newline-delimited string.
Each line must be a valid Casbin CSV rule, e.g.::
p, alice, data1, read
g, bob, admin
Returns an empty string when no rules are defined.
DevKeyPair ¶
DevKeyPair(private_key, kid=DEFAULT_DEV_KID)
An RSA keypair used to sign (client) and verify (server) dev tokens.
Wraps a single RSA private key plus a stable kid (key id). The kid is
written into every token header and into the exported JWK so a
:class:jwt.PyJWKClient can match the token to its verifying key.
Wrap an existing RSA private key.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
private_key
|
RSAPrivateKey
|
The RSA private key used for signing. |
required |
kid
|
str
|
Key id stamped into token headers and the exported JWK. |
DEFAULT_DEV_KID
|
generate
classmethod
¶
generate(kid=DEFAULT_DEV_KID)
Generate a fresh ephemeral RSA keypair.
The key lives only in memory for the life of the process — fine for a
single client that also configures the verifying server from
:meth:jwks, but two independently-started processes will NOT share it.
Use :meth:from_pem with a committed dev key when both sides must agree.
from_pem
classmethod
¶
from_pem(private_key_pem, kid=DEFAULT_DEV_KID)
Load a keypair from a PKCS#8/PKCS#1 PEM string (no passphrase).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
private_key_pem
|
str
|
PEM-encoded RSA private key. |
required |
kid
|
str
|
Key id stamped into token headers and the exported JWK. |
DEFAULT_DEV_KID
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If the PEM does not decode to an RSA private key. |
private_pem ¶
private_pem()
Return the private key as an unencrypted PKCS#8 PEM string.
Useful to capture a generated ephemeral key so another process (a dev
server) can load the same key via :meth:from_pem.
jwks ¶
jwks()
Return the public key wrapped as a JWKS document ({"keys": [...]}).
Point a JwtAuthBackend at this — serve it at the URL named by
AZURE_JWKS_URL for a running dev server, or feed it to the backend's
:class:jwt.PyJWKClient directly in a test.
DevTokenConfig ¶
Bases: BaseModel
Parameters for minting a locally-signed RS256 dev token.
issuer / audience MUST match what the target JwtAuthBackend is
configured to accept (AZURE_ISSUER_URL / AZURE_APP_CLIENT_ID), and the
backend must resolve this keypair's public JWK (serve :meth:DevKeyPair.jwks
at AZURE_JWKS_URL). user_identifier_claim must match the backend's
USER_IDENTIFIER_PROPERTY (default unique_name) or the backend rejects
the token for a missing identifier.
M2MTokenConfig ¶
Bases: BaseModel
Azure AD client-credentials parameters for the production m2m path.
All four fields are required when mode='m2m'; the provider raises a clear
error naming any that are missing before making the network call.
The acquisition is synchronous: a blocking client-credentials request
made inside ServiceTokenProvider.token() (which keeps its str return
type — no async variant), so consumers never migrate their call sites to
await. See :meth:ServiceTokenProvider.token.
ServiceTokenConfig ¶
Bases: BaseModel
Top-level provider config: a mode plus the matching mode's parameters.
from_env
classmethod
¶
from_env()
Build a config from SERVICE_TOKEN_* environment variables.
Mode is selected by SERVICE_TOKEN_MODE (default dev). In dev mode
the private key comes from SERVICE_TOKEN_DEV_PRIVATE_KEY (inline PEM),
else SERVICE_TOKEN_DEV_PRIVATE_KEY_FILE (path to a PEM), else an
ephemeral key is generated. Missing optional values fall back to the
model defaults.
For m2m mode, SERVICE_TOKEN_M2M_AUTHORITY overrides the Azure
authority host (default: the commercial cloud) for sovereign/national
clouds; the other SERVICE_TOKEN_M2M_* vars supply tenant/client/
secret/scope.
ServiceTokenProvider ¶
ServiceTokenProvider(config, *, key_pair=None, m2m_transport=None)
Returns a Bearer token string for calling an altaforge-auth service.
One interface, mode-selected by :class:ServiceTokenConfig. Call
:meth:token to get a currently-valid token; the provider handles caching
and refresh-before-expiry internally.
Build a provider from validated config.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
ServiceTokenConfig
|
The provider configuration (mode + mode parameters). |
required |
key_pair
|
DevKeyPair | None
|
Dev keypair override (tests / a shared committed key). When
omitted in dev mode, one is built from |
None
|
m2m_transport
|
BaseTransport | None
|
httpx transport used for the m2m client-credentials
request. Injected in tests ( |
None
|
jwks ¶
jwks()
Return the dev JWKS a JwtAuthBackend must verify against (dev mode).
Serve this at the URL named by AZURE_JWKS_URL on the target service,
or feed it to a backend's :class:jwt.PyJWKClient in a test.
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If the provider is not in dev mode. |
token ¶
token()
Return a currently-valid Bearer token, minting/refreshing as needed.
Returns the cached token while it is still comfortably valid (more than
refresh_leeway_seconds from expiry); otherwise acquires a fresh one.
Synchronous and blocking in every mode, now and after #352. In
dev mode a refresh signs a JWT locally; in m2m mode (once #352
lands) a refresh performs a blocking Azure AD client-credentials call
inside this method. Either way the return type stays str — this never
becomes a coroutine, so async callers keep the same call site. Because a
refresh happens at most once per TTL (not per request), async consumers
should offload just that occasional blocking call off the event loop,
e.g. await asyncio.to_thread(provider.token).
AdmissionGate ¶
AdmissionGate(org_resolver, require_org_access=False)
Decides whether an authenticated email may be minted a session.
When require_org_access is False (the default) every authenticated email
is admitted — preserving today's "logged in, empty data" behaviour, so
enabling the gate is a deliberate, non-breaking opt-in. When True, the email
must resolve to at least one accessible organization in Casbin.
The email is lowercased before it reaches Casbin so a mixed-case address
cannot silently resolve to zero orgs (a silent lock-out — conv:T-q-3).
Create the gate.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
org_resolver
|
OrgResolver
|
Resolver used to count a user's accessible orgs. |
required |
require_org_access
|
bool
|
Whether to enforce the gate (default False). |
False
|
InternalAuthConfig ¶
Bases: BaseModel
Validated configuration for the internal-token minter and backend.
The backend is the sole minter and validator of the internal token, so a
symmetric HS256 secret suffices in v1 (T-q-1); move to RS256 only if a
second, independent verifier appears.
Example::
config = InternalAuthConfig(
signing_key=settings.INTERNAL_AUTH_SIGNING_KEY,
service_credential=settings.INTERNAL_AUTH_SERVICE_CREDENTIAL,
require_org_access=True,
)
InternalTokenMinter ¶
InternalTokenMinter(config)
Signs and validates internal identity tokens with the backend-held key.
Email is lowercased before it becomes the sub claim, preserving the
load-bearing invariant that Casbin is always queried with a lowercased
identifier (conv:T-q-3).
Create the minter from validated internal-auth configuration.
mint ¶
mint(email, roles)
Mint an access + refresh token pair for email carrying roles.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
email
|
str
|
The BFF-asserted, already-verified email. Lowercased here. |
required |
roles
|
list[str]
|
Application roles to embed (sourced from Casbin, not the IdP). |
required |
decode_access ¶
decode_access(token)
Validate an access token (own key/iss/aud) and return its identity claims.
Raises:
| Type | Description |
|---|---|
ExpiredTokenError
|
If the token has expired. |
InvalidTokenError
|
If the signature, issuer, audience, or token type is wrong — including any token minted by a foreign issuer. |
decode_refresh ¶
decode_refresh(token)
Validate a refresh token and return the email (sub) it was minted for.
Raises:
| Type | Description |
|---|---|
ExpiredTokenError
|
If the refresh token has expired. |
InvalidTokenError
|
If it is not a valid refresh token. |
MintedTokens
dataclass
¶
MintedTokens(token, refresh_token, expires_at, roles=list())
The result of a mint: an access token, its refresh token, and metadata.
MintRequest ¶
Bases: ApiModel
Body for POST /internal/auth/mint.
MintResponse ¶
Bases: ApiModel
Response body for both mint and refresh.
RefreshRequest ¶
Bases: ApiModel
Body for POST /internal/auth/refresh.
AuthMiddleware ¶
AuthMiddleware(app, auth_backend, excluded_paths=None)
Bases: BaseHTTPMiddleware
Authenticates each request via a pluggable AuthBackend.
Excluded paths pass through without authentication. All other paths return
401 if authentication fails. On success, request.state.user is set to
the UserData returned by the backend.
When an authenticated request carries ?organization_id=<id> as a query
parameter, the middleware additionally checks that <id> is in the
user's organizations. If it is not, the request is rejected with a
generic 404 so callers cannot use the endpoint to enumerate organizations
they don't belong to. Routes whose organization id comes from a path
parameter or request body must call :func:check_organization_access
explicitly — the middleware can only inspect the query string.
Failure detail is logged server-side; the 401 response carries a generic message so the API does not leak token-validation internals to a caller probing for valid token shapes.
Initialise the middleware with the given backend and excluded paths.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
app
|
ASGIApp
|
The ASGI application to wrap. |
required |
auth_backend
|
AuthBackend
|
The authentication strategy to delegate to. |
required |
excluded_paths
|
Iterable[str] | None
|
Paths that bypass authentication. When |
None
|
MintedTokenAuthBackend ¶
MintedTokenAuthBackend(minters, *, audience)
Validates a minted product token against a small, fixed trusted-minter registry.
Constructed once with the full registry (issuer -> TrustedMinter);
the registry never changes per-request. See the module docstring for the
trust model. audience is this service's own identity (the aud
claim every minted token must carry) — fixed for the whole backend, not
per-issuer.
Initialise the backend with a trusted-minter registry.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
minters
|
dict[str, TrustedMinter]
|
Maps an issuer ( |
required |
audience
|
str
|
This service's own identity — every minted token
must carry it as |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
authenticate
async
¶
authenticate(request)
Validate the request's minted Bearer token and return UserData.
Raises:
| Type | Description |
|---|---|
MissingTokenError
|
No |
InvalidTokenError
|
Unknown issuer, signature/audience/issuer
mismatch, or an |
ExpiredTokenError
|
A structurally valid but expired token. |
TrustedMinter
dataclass
¶
TrustedMinter(issuer, allowed_orgs, public_key_pem=None, jwks_url=None)
One trusted product's token-verification config.
Exactly one of public_key_pem/jwks_url must be set:
public_key_pem verifies offline against a fixed key (the common case
— a small, static set of products); jwks_url supports a product that
rotates its signing key behind a hosted JWKS endpoint instead.
Raises:
| Type | Description |
|---|---|
ValueError
|
If zero or both of |
AuthBackend ¶
Bases: Protocol
Pluggable authentication strategy used by AuthMiddleware.
authenticate
async
¶
authenticate(request)
Authenticate the request and return user data.
Raises:
| Type | Description |
|---|---|
MissingTokenError
|
If no token is present. |
ExpiredTokenError
|
If the token has expired. |
InvalidTokenError
|
If the token is invalid. |
AuthError ¶
Bases: Exception
Base class for authentication failures raised by AuthBackend implementations.
OrgResolver ¶
Bases: Protocol
Resolves the list of organization IDs a user is permitted to access.
organizations_for
async
¶
organizations_for(user_id)
Return the organization IDs user_id is permitted to access.
ResourceAuthorizer ¶
Bases: Protocol
Resolves a user's role on a single resource — the extension seam for
resource-scoped RBAC (see :func:~altaforge.auth.resource_auth.require_permission).
Unlike :class:RoleResolver (global app roles), this answers the per-resource
question "what role, if any, does this user hold on this resource". Each app
implements it over its own membership store (e.g. a user_sites join table);
the library never stores membership itself.
role_for
async
¶
role_for(user_id, resource_id)
Return user_id's role on resource_id, or None if they hold none.
RoleResolver ¶
Bases: Protocol
Resolves the application roles a user holds.
The internal-token minter uses this to source roles from the authorization
store (Casbin) rather than from an upstream IdP claim — see
:class:~altaforge.auth.casbin.role_resolver.CasbinRoleResolver.
UserData ¶
Bases: TypedDict
Authenticated user identity returned by any AuthBackend.
DatabaseRegistryLoader ¶
DatabaseRegistryLoader(url, require_ssl=False)
Loads organizations from a PostgreSQL organizations table.
Owns its async SQLAlchemy engine. Call :meth:aclose (or use
async with) on application shutdown to dispose the engine.
The DSN and SSL flag are passed directly — the caller (client app) is responsible for sourcing them.
require_ssl=True emits psycopg-only SSL connect args — see
:mod:altaforge.auth.pg_ssl for the driver/SSL contract.
load_organizations
async
¶
load_organizations(app=None)
Query the organizations table, optionally filtering by app.
FileRegistryLoader ¶
FileRegistryLoader(registry_file)
Loads organizations from a JSON or YAML file on disk.
The file is re-read on each :meth:load_organizations call.
Wrap with :class:~altaforge.auth.registry.managed.ManagedRegistryLoader
to add TTL-based caching so the file is not re-read on every request.
The file path is passed directly — the caller is responsible for sourcing it. The read is offloaded to a thread so the event loop is not blocked.
load_organizations
async
¶
load_organizations(app=None)
Read and parse the registry file, optionally filtering by app.
ManagedRegistryLoader ¶
ManagedRegistryLoader(registry_loader, app=None)
Wrapper around any :class:~altaforge.auth.registry.protocols.RegistryLoader
that fetches fresh organizations on every :meth:load_organizations call.
Accepts a concrete loader (Database, File, or Text) that satisfies the
:class:~altaforge.auth.registry.protocols.RegistryLoader protocol, and exposes
a no-arg :meth:load_organizations interface for use by
:class:~altaforge.auth.casbin.org_resolver.CasbinOrgResolver.
The preferred way to construct this in production is via :meth:from_config.
The direct constructor is available for testing and DI.
Usage::
from altaforge.auth.registry import ManagedRegistryLoader, RegistryConfig
config = RegistryConfig(
registry_source_url=settings.REGISTRY_SOURCE_URL, # postgresql://...
registry_require_ssl=True,
)
registry = ManagedRegistryLoader.from_config(config)
# At app startup:
await registry.initialize()
# Pass to CasbinOrgResolver (or any other consumer):
resolver = CasbinOrgResolver(registry_loader=registry, enforcer=enforcer)
# At app shutdown:
await registry.aclose()
Create the managed loader.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
registry_loader
|
RegistryLoader
|
Underlying loader that fetches organizations. |
required |
app
|
str | None
|
The application to which the organization belongs. |
None
|
from_config
classmethod
¶
from_config(config)
Construct from a validated :class:~altaforge.auth.registry.config.RegistryConfig.
Selects the correct loader based on which source field is set.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
RegistryConfig
|
Validated registry configuration provided by the client app. |
required |
Raises:
| Type | Description |
|---|---|
ValidationError
|
Propagated from |
load_organizations
async
¶
load_organizations()
Fetch and return organizations fresh from the underlying loader.
initialize
async
¶
initialize()
Eagerly verify the registry source at application startup.
Call this from your ASGI lifespan startup handler so that registry load failures surface immediately rather than on the first authenticated request.
MinimalOrganizationManifest ¶
Bases: BaseModel
Lightweight organization record used for authentication and authorization.
Intentionally minimal — it carries only the fields needed to make auth decisions. Consuming services that need richer metadata (solutions, dashboards, agents, etc.) should maintain their own full registry models.
RegistryConfig ¶
Bases: BaseModel
Validated configuration for :class:~altaforge.auth.registry.managed.ManagedRegistryLoader.
At least one of registry_source_url, af_registry_file, or
af_registry_text must be supplied. If multiple are provided, the first
match in the resolution order below wins and the rest are ignored — the
database is the source of truth in all deployed environments.
af_registry_file and af_registry_text exist as local-development and
test fallbacks only. Pydantic raises a ValidationError at construction
time if none is provided.
Resolution order (first match wins) in :func:~altaforge.auth.registry.loaders.create_registry_loader:
registry_source_url→ :class:~altaforge.auth.registry.loaders.DatabaseRegistryLoader(production)af_registry_file→ :class:~altaforge.auth.registry.loaders.FileRegistryLoader(local dev)af_registry_text→ :class:~altaforge.auth.registry.loaders.TextRegistryLoader(tests)
Example — database source::
config = RegistryConfig(
registry_source_url=settings.REGISTRY_SOURCE_URL,
registry_require_ssl=True,
)
Example — file source (local dev)::
config = RegistryConfig(af_registry_file='/app/registry.json')
Example — inline text (tests)::
config = RegistryConfig(
af_registry_text='{"organizations": [{"id": "org-1", "display_name": "Org One"}]}'
)
RegistryLoader ¶
Bases: Protocol
Loads organization records from a backing store.
Concrete implementations are selected by
:func:~altaforge.auth.registry.loaders.create_registry_loader based on
which source field is set in
:class:~altaforge.auth.registry.config.RegistryConfig.
:class:~altaforge.auth.registry.managed.ManagedRegistryLoader wraps a
concrete implementation and applies app from its construction-time
config so callers do not pass it per-call.
TextRegistryLoader ¶
TextRegistryLoader(text)
Loads organizations from an inline JSON or YAML string.
The text is parsed once on the first call and the result is cached for the lifetime of the loader, since the text is immutable after construction.
Useful for tests and embedded configuration.
load_organizations
async
¶
load_organizations(app=None)
Parse the registry text (cached after the first call), optionally filtering by app.
create_policy_loader ¶
create_policy_loader(config)
Instantiate the appropriate PolicyLoader from a validated :class:~altaforge.auth.casbin.config.CasbinConfig.
Resolution order matches the config validator:
casbin_source_urlis set → :class:DatabasePolicyLoaderaf_policy_fileis set → :class:FilePolicyLoader
Because :class:~altaforge.auth.casbin.config.CasbinConfig already enforces
that at least one source is present, no further validation is needed here.
mint_trusted_subsystem_token ¶
mint_trusted_subsystem_token(user_id, org, roles, *, issuer, private_key_pem, kid, audience, ttl_seconds=DEFAULT_MINT_TTL_SECONDS)
Mint a short-lived RS256 JWT for calling a trusted-subsystem service.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
user_id
|
str
|
The already-authenticated end user's id (becomes |
required |
org
|
str
|
The org/tenant slug this call is scoped to (becomes |
required |
roles
|
list[str]
|
Role strings to carry (may be empty). |
required |
issuer
|
str
|
This product's slug (becomes |
required |
private_key_pem
|
str
|
This product's RSA private signing key (PEM, PKCS#1 or PKCS#8, no passphrase). |
required |
kid
|
str
|
This product's key id, stamped into the token header so the validator (when configured via a JWKS url rather than a fixed key) can select the matching public key. |
required |
audience
|
str
|
The downstream service's identity (the |
required |
ttl_seconds
|
int
|
Token lifetime in seconds. Keep short — see module docstring. |
DEFAULT_MINT_TTL_SECONDS
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
scoped_org_context ¶
scoped_org_context(organization_id)
Bind organization_id_var for the duration of the block, then reset it.
Uses the token returned by :meth:ContextVar.set so the previous value is
restored exactly on exit — safe under nesting and concurrent tasks.
create_internal_auth_router ¶
create_internal_auth_router(*, minter, admission_gate, role_resolver, config)
Build the internal-auth router from injected collaborators.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
minter
|
InternalTokenMinter
|
Signs/validates the internal token. |
required |
admission_gate
|
AdmissionGate
|
The opt-in Casbin org-access login gate. |
required |
role_resolver
|
RoleResolver
|
Sources the user's roles from Casbin. |
required |
config
|
InternalAuthConfig
|
Supplies the service credential the endpoints require. |
required |
make_service_credential_dependency ¶
make_service_credential_dependency(config)
Build a FastAPI dependency that rejects callers without the service credential.
Returns a dependency that raises 401 unauthorized when the
X-AltaForge-Service-Credential header is missing or does not match, using
a constant-time comparison so the endpoint does not leak the secret via
response timing.
check_organization_access ¶
check_organization_access(request, organization_id)
Raise HTTP 404 if the authenticated user may not access organization_id.
Routes that receive organization_id as a ?organization_id=<id>
query parameter do not need to call this — :class:AuthMiddleware
performs the same check on the query string. Call this helper for routes
whose organization id comes from a path parameter or request body.
The denial is reported as 404 rather than 403 so callers cannot use this endpoint to enumerate organizations they don't belong to — "exists but forbidden" and "does not exist" are indistinguishable from the client.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request
|
Request
|
The current FastAPI request (must have |
required |
organization_id
|
str
|
The organization ID being requested. |
required |
Raises:
| Type | Description |
|---|---|
HTTPException
|
404 if the user is not permitted. |
custom_openapi ¶
custom_openapi(app, original_openapi, excluded_paths=None)
Tag non-excluded routes with BearerAuth in the OpenAPI schema.
Caches the modified schema on app.openapi_schema so subsequent calls
return it directly.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
app
|
FastAPI
|
The FastAPI app whose schema is being customized. |
required |
original_openapi
|
Callable[[], dict[str, Any]]
|
The original |
required |
excluded_paths
|
Iterable[str] | None
|
Paths to leave unauthenticated. Defaults to
|
None
|
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
The modified OpenAPI schema. |
create_registry_loader ¶
create_registry_loader(config)
Instantiate the appropriate loader from a validated :class:~altaforge.auth.registry.config.RegistryConfig.
Resolution order:
registry_source_url→ :class:DatabaseRegistryLoaderaf_registry_file→ :class:FileRegistryLoaderaf_registry_text→ :class:TextRegistryLoader
Because :class:~altaforge.auth.registry.config.RegistryConfig already
enforces that at least one source is present, no further validation is
needed here.
require_permission ¶
require_permission(authorizer, enforcer, *, domain, action, resource_param='resource_id', obj='*')
Dependency factory: allow only if the caller's role on the route's resource
permits action in domain (per the app's Casbin policy). Returns the
caller's role on success.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
authorizer
|
AuthorizerDependency
|
dependency yielding the app's ResourceAuthorizer. |
required |
enforcer
|
ManagedCasbinEnforcer
|
the app's enforcer loaded with |
required |
domain
|
str
|
the permission group being acted on (e.g. |
required |
action
|
str
|
the action (e.g. |
required |
resource_param
|
str
|
the path param naming the resource (default |
'resource_id'
|
obj
|
str
|
the object within the domain; |
'*'
|
require_resource_role ¶
require_resource_role(authorizer, *roles, resource_param='resource_id')
Dependency factory: allow only if the caller's role on the route's resource
is one of roles. A lightweight, Casbin-free gate for routes that need a
role rather than a specific action (e.g. "any editorial_lead"). Returns the role.