Core
Core (FastAPI) is where authorization actually happens. It never writes a role and never asks Keycloak about one at request time — it validates the access token, reads the claims Keycloak put there, and decides. If a request is going to be refused, it is refused here.
Everything on this page describes machinery that works correctly where it is called. Whether a given route calls it is a separate question, and the answer is not always yes — see Enforcement gaps.
Token validation
Before core trusts a request, it checks the token that came with it. That check happens in core/app/core/security.py, in four steps:
- Look at which key the token claims to be signed with.
- Fetch that key from Keycloak. It's cached for an hour, and refreshed immediately if an unfamiliar key shows up — so when Keycloak rotates its signing key, core picks that up on its own, no restart needed.
- Confirm the signature is genuine.
- Confirm the token was issued for this app, by this realm, and hasn't expired.
That third check matters more than it sounds: a token that's otherwise perfectly valid, but was issued for a different app in the same Keycloak realm, is still rejected. Otherwise, logging into any app on the same Keycloak instance would be enough to get into core too.
Any failure results in a 401. In production, the caller only sees a generic "Token validation failed," so the real reason isn't leaked; anywhere else, the actual cause is included, which is what to look for in a staging error.
TokenUser
Core keeps no user table and never looks anyone up in a database to answer "who is this?" The token is the identity:
@dataclass
class TokenUser:
id: UUID # sub -- the FK used for user ownership everywhere
email: str
first_name: str
last_name: str
roles: list[str] # realm_access.roles, verbatim
raw_claims: dict
organizations: dict[str, OrgMembership] # keyed by org UUID
The token lists organizations by name; core converts that into a lookup by org ID instead, since that's how every check it runs is phrased. An entry missing an ID or a role is dropped, so a pending invitation never counts as real membership.
One thing worth knowing: core always treats the caller as active. The assumption is that Keycloak wouldn't have issued a token for a disabled account in the first place. In practice, that means deactivating someone in identity has no effect on core until their current token simply expires.
The two-value collapse
@property
def role(self) -> str:
if "sys_admin" in self.roles:
return "sys_admin"
return "sys_user"
Core only recognizes two system-level roles: platform admin, or everyone
else. Any other role Keycloak knows about collapses into "everyone else" as
far as core is concerned — its
UserRole
enum only defines those two values.
The three enforcement mechanisms
All three live in
core/app/api/deps.py,
and none of them runs automatically — a route has to call one on purpose.
They're not three independent siblings, though: require_admin() is a
shortcut for require_role(), and authorize_resource() is built on top of
verify_org_membership() rather than separate from it — see each below.
require_role() / require_admin()
Checks the platform dimension only: is this caller a sys_admin, or not?
@router.get("/", dependencies=[Depends(require_admin())])
require_admin() isn't a separate check — it's require_role(UserRole.SYS_ADMIN)
under a shorter name. Since core only recognizes those two system roles,
calling either is really just an "is this a platform admin" switch. Neither
has any concept of organizations at all.
verify_org_membership()
Checks the organization dimension. It's called by hand inside a handler rather than plugged in as a dependency, because it needs to know which organization is being acted on:
verify_org_membership(current_user, organization_id, require_role="org_admin")
It rejects the request if the caller isn't a member of that org. Pass
require_role and it also enforces a minimum, using this order:
org_guest < org_member < org_editor < org_admin < org_owner — so
require_role="org_editor" lets editors, admins and owners through.
This is a minimum-role check, not a permission check. Core has nothing like console's action list saying exactly which role can delete a knowledge base — each route just decides its own minimum, inline.
authorize_resource()
The one used most often, 38 call sites, and the easiest to misread:
authorize_resource(kb, current_user, require_role="org_admin")
It checks owner first, then org:
- If the caller created the resource, they're let in —
require_roleis never even consulted. - Otherwise, if the resource belongs to an organization, it calls
verify_org_membershipdirectly, passing that role along. This is a real function call, not a loose analogy — it's the one place in this trio where one of the three actually invokes another. - Otherwise, denied.
So require_role only ever limits other people's access, never the
creator's. A resource with no organization at all is owner-only, no matter
who else is around — which is how personal and org-owned resources can
coexist in the same database table.
Consequences of being token-only
Because core reads claims and nothing else:
- Role changes lag by one token refresh. Identity writes the attribute immediately, but core keeps honouring the old role until the user's access token is reissued.
- There is no revocation path. Removing a user from an organization does not invalidate tokens already issued to them.
- Deactivation is not enforced.
is_activeisTrueunconditionally.
None of this is a defect of the design so much as its cost, but any reasoning about "what happens when we demote someone" has to account for the window.