Skip to content

The Logto boundary

This page assumes you know what a scope is. The array that carries one is explained on Scope sets in depth, and this page only covers where the values in it come from.

Search the schema for a users table, an organizations table or a memberships table and you will not find one. Logto owns all three. src/aizk/store/identity/ holds two Pydantic models, User and OrganizationStanding, and neither is a SQLModel table. User is an rls.Context, so its only job is to be serialized into transaction-local PostgreSQL settings.

A mirror can disagree with its source. If aizk copied membership, somebody removed from a team in Logto would keep reading that team’s memory until a sync ran. With no copy there is no window.

Auth in src/aizk/auth.py is the entire boundary and it is a TokenVerifier.

Verification happens first. get_verifiers() reads the tenant’s OIDC discovery document once and builds one JWTVerifier per advertised signing algorithm, each pinned to the discovered issuer, to the audience settings.mcp_resource_id, and to the required scopes. That audience is {mcp_public_url}/mcp exactly, so a token minted for any other resource fails. The required scope set defaults to {"control"}.

Only then does identity resolution run. identify() validates the verified claims into Claims, which carries iss, sub, aud, exp, iat and three optional display names. A ValidationError there is logged and returns nothing, which fails closed.

Stable IDs are derived, never stored. Settings.subject_id and Settings.scope_id are both UUID5 over _IDENTITY_NAMESPACE, a frozen constant rather than a setting, because every stored scope id derives from it and a changed value matches nothing already written.

uuid.uuid5(uuid.NAMESPACE_URL, f"{namespace}/subjects/{subject}")
uuid.uuid5(uuid.NAMESPACE_URL, f"{namespace}/scopes/{external_id}")

The same Logto subject always lands on the same aizk user ID, and the same organization always lands on the same scope ID, on any machine, with no lookup table.

The token proves who the caller is. It does not say what they may read. LogtoClient.user_subject does that, reading the subject’s organizations, the public organization catalog and the tenant roles from the Management API, then calling User.authorized with three sets. Read is the caller plus every organization they see, write is the caller plus every organization whose permissions include logto_write_permission, which src/deploy/logto.conf sets to write:memory, and public is the public catalog.

Calling the Management API on every request would be slow and fragile, so LogtoClient wraps each read in alru_cache with ttl=settings.logto_cache_seconds, which defaults to 60 seconds.

Cache Key Holds
_cached_user_orgs, _cached_account, _cached_user_roles subject one person’s standing
_cached_organization_members organization id one member directory
_cached_organizations, _cached_public_orgs none the tenant catalog
_cached_organization_roles none the shared role template

SnapshotCaches groups them so a mutation evicts only what it invalidated, and invalidate_all() clears everything after a policy change reshapes roles tenant wide.

Every cached read goes through _closed, which catches transport and validation errors, logs a warning and returns the empty fallback. An empty read set means nothing is visible rather than everything, so a Logto outage degrades to no access rather than to open access. When Logto is configured and identity cannot be resolved at all, Auth.resolve falls back to client.anonymous(), which grants public read and nothing else.

Some decisions cannot tolerate a stale answer, so directory reads take fresh=True. That path bypasses the caches, raises instead of falling back, and screens the account. A deleted or suspended account, or one lacking the aizk-user role, raises LogtoAccessError.

MCP and the browser differ only at the edge

Section titled “MCP and the browser differ only at the edge”

The MCP server hands FastMCP a RemoteAuthProvider built by Auth.provider() and reads the verified Logto token through get_access_token(). The browser API declares an HTTPBearer dependency and calls Auth.bearer(token), which returns a Caller carrying both the resolved User and the raw Logto subject. The API needs that raw subject because organization management calls act as the person making them. Everything after those two lines is identical.

Client ──▶ Auth request carrying a bearer token
Auth verify issuer, audience, scopes
Auth ──▶ Logto read subject standing, cached 60s
Logto ──▶ Auth organizations, roles, public catalog
Auth derive uuid5 subject and scope ids
Auth ──▶ PostgreSQL SET LOCAL typed app.scopes.* and app.operator settings
PostgreSQL ──▶ Client only the rows the policies admit

src/deploy/logto.conf is the committed, nonsecret authorization policy. It names the API resource, the required scope control, the managed role prefix aizk-, the two global roles aizk-user and aizk-admin, the three organization roles admin, editor and viewer, and the permissions each one carries.

The two global roles differ in one flag. aizk-user is default, so Logto gives it to every new account. aizk-admin is granted by hand and is what the operator console requires. Both carry the same control API permission, so a token minted for either verifies against the AIZK resource. aizk admin auth roles prints both with the accounts assigned to them.

aizk admin auth audit reports drift between that file and the live tenant, and exits 1 when the report is not clean. aizk admin auth apply reconciles, replanning after each batch for up to eight passes before it gives up, and evicting every cached snapshot afterward. LogtoPolicy only touches the configured resource, its scopes, global roles under the managed prefix, and the named organization roles and permissions. Anything else in the tenant is left alone. A managed-prefix role the configuration does not name is deleted, which is why aizk-admin is configuration rather than something an operator creates by hand.