Auth¶
xcore exposes a kernel-level authentication backend interface: plugins register a single AuthBackend implementation, and the kernel (HTTP routes, and now IPC calls) can query it to resolve the identity behind a call — without knowing anything about JWTs, sessions, or whatever credential scheme the backend actually implements.
AuthPayload¶
A TypedDict representing an authenticated identity. Returned by AuthBackend.decode_token().
Only sub is required; roles, permissions, and user are optional (NotRequired).
AuthBackend¶
Protocol that a plugin implements to become the active auth backend. No base class to inherit — any object with these three async methods satisfies it.
| Method | Signature | Description |
|---|---|---|
decode_token |
(token: str) → AuthPayload \| None |
Validate a raw token, return the identity or None |
extract_token |
(request) → str \| None |
Pull the raw token out of an HTTP request (headers/cookies/query params) |
has_permission |
(payload: AuthPayload, permission: str) → bool |
Check a single permission/role against a resolved identity |
register_auth_backend / unregister_auth_backend¶
Registers the single active backend instance. There is no name/registry — one backend at a time per process.
register_auth_backend raises TypeError if the object doesn't structurally satisfy AuthBackend (missing one of the three methods).
get_auth_backend / has_auth_backend¶
get_auth_backend() returns None (never raises) when no backend is registered — every consumer in the kernel (RBAC, IPC resolution) treats "no backend" as a no-op, not an error.
RBAC on HTTP routes¶
For role/permission checks on @route-declared HTTP endpoints:
| Export | Description |
|---|---|
RBACChecker |
FastAPI dependency — resolves the user via the registered backend and checks required roles/permissions |
require_permission(*perms) |
Shortcut: RBACChecker(list(perms)) |
require_role(*roles) |
Shortcut: RBACChecker(list(roles)) |
get_current_user |
FastAPI dependency — just resolves and returns the AuthPayload, no check |
RBACChecker raises HTTPException(503) if no backend is registered (or passes through with strict=False), 401 if no/invalid token, 403 if required roles/permissions are missing. This only applies to routes declared with @route(..., permissions=[...]) — see RoutedPlugin.get_router().
Resolving the current user on IPC calls¶
Separately from the HTTP-route RBAC above, the IPC call path (supervisor.call() / TrustedBase.call_plugin()) resolves the calling user and makes it available to plugin code, via the same registered AuthBackend — without requiring @route.
This is resolution only — it does not enforce anything by itself. The goal is to make "who is calling" available to plugin logic and to a future per-action permission check (@action(permissions=[...]), see @action), while leaving existing behavior (requests with no backend registered, or no token) completely unaffected.
How a token becomes a principal¶
- The generic HTTP endpoint
POST /{plugin}/{action}(router.py) callsresolve_principal_from_request(request), which does the only two calls into the backend that happen for a given incoming request:Best-effort: returnsNonewithout raising if there's no backend, no token, or an invalid token. - The resolved
principal(anAuthPayloadorNone) travels as a kwarg throughsupervisor.call(..., principal=principal). - Inside the middleware pipeline,
AuthResolverMiddlewaresits just beforePermissionMiddleware. Ifprincipalis already set, it passes through unchanged — the backend is never contacted twice for the same call. It only callsdecode_token()itself when it receives a rawtoken=kwarg instead of an already-resolvedprincipal. PluginSupervisor._dispatch()storesprincipalin aContextVar(_current_principal, same pattern as the tenant-id ContextVar) for the duration of the call — never by mutating the plugin's sharedPluginContext.- Plugin code reads it with
get_current_principal(). - When a plugin calls another plugin via
self.call_plugin(...), the current principal is forwarded automatically — the nested call does not re-contact the auth backend:
Diagram¶
sequenceDiagram
participant C as HTTP client
participant R as router.call_plugin()
participant AB as AuthBackend (plugin auth)
participant MW as AuthResolverMiddleware
participant S as supervisor._dispatch()
participant PA as Plugin A .handle()
participant PB as Plugin B .handle()
C->>R: POST /plugin_a/action (Authorization: Bearer xxx)
R->>AB: extract_token(request)
AB-->>R: "xxx"
R->>AB: decode_token("xxx")
AB-->>R: AuthPayload {sub, roles, permissions}
R->>S: supervisor.call(..., principal=AuthPayload)
S->>MW: pipeline.execute(..., principal=AuthPayload)
Note over MW: principal already resolved → backend NOT called again
MW->>S: next_call(..., principal=AuthPayload)
S->>S: _current_principal.set(AuthPayload)
S->>PA: handle(action, payload)
PA->>PA: get_current_principal() → AuthPayload
PA->>PB: call_plugin("plugin_b", "other_action")
Note over PA,PB: principal=get_current_principal() propagated automatically
PB->>MW: pipeline.execute(..., principal=AuthPayload)
MW->>S: next_call(...)
S->>PB: handle(...)
PB->>PB: get_current_principal() → same AuthPayload
Reading the principal in a plugin¶
What this does not do (yet)¶
- No action is blocked based on
principaltoday —@action(permissions=[...])only stores the requirement on the function (fn._xcore_action_permissions); nothing in the kernel reads it yet. PermissionEngine/PolicySet(plugin-to-plugin resource ACL, see Permissions & Policies) is a separate system and is not merged with this — it still answers "can plugin A call plugin B", not "does this user have this role".- If no plugin has called
register_auth_backend(...), everyprincipalis alwaysNone, silently — no error, no behavior change versus before this feature existed.