Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions doc/advanced/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ graph TD
A[Supervisor.call] --> M1[IPC Auth]
M1 --> M2[Tracing]
M2 --> M3[Rate Limit]
M3 --> M4[Permissions]
M4 --> M5[Retry]
M5 --> D[Final Dispatch]
M3 --> M4[Auth Resolver]
M4 --> M5[Permissions]
M5 --> M6[Retry]
M6 --> D[Final Dispatch]
D --> P[Plugin.handle]
```

Expand Down Expand Up @@ -50,12 +51,19 @@ Enforces the quotas defined in the plugin's `plugin.yaml`.
- Uses a sliding window algorithm.
- Blocks calls once the `calls` per `period_seconds` threshold is exceeded.

#### 4. Permissions (`PermissionMiddleware`)
#### 4. Auth Resolver (`AuthResolverMiddleware`)
Resolves the calling user (`principal`) via the registered `AuthBackend` — see [Auth: Resolving the current user on IPC calls](../sdk/api/auth.md#resolving-the-current-user-on-ipc-calls).
- If `principal` is already set in kwargs (propagated from a parent IPC call), passes it through unchanged — the backend is **not** contacted again.
- Otherwise, if a raw `token` kwarg is present and a backend is registered, calls `backend.decode_token(token)`.
- Best-effort only: never raises, never blocks a call. `principal` is simply `None` when no backend/token is available.

#### 5. Permissions (`PermissionMiddleware`)
Evaluates the `resource` and `action` against the plugin's `PolicySet`.
- Replaces the generic `execute` action with a specific resource string if provided.
- Raises `PermissionDenied` on failure.
- Separate system from the `principal` resolved above — this checks plugin-to-plugin resource ACLs, not user roles/permissions.

#### 5. Retry (`RetryMiddleware`)
#### 6. Retry (`RetryMiddleware`)
Automatically retries failed calls to **Sandboxed** plugins if the worker process crashes or the IPC channel times out.
- Configurable via the `retry:` block in `plugin.yaml`.

Expand Down Expand Up @@ -91,7 +99,7 @@ xcore.plugins.register_middleware(AuditMiddleware())
| `payload` | `dict` | Input data for the call. |
| `next_call` | `Callable`| The next step in the pipeline (must be awaited). |
| `handler` | `Handler` | The supervisor handler for the target plugin. |
| `**kwargs` | `dict` | Contextual data: `caller`, `tenant_id`, `resource`. |
| `**kwargs` | `dict` | Contextual data: `caller`, `tenant_id`, `resource`, `token`, `principal`. |

---

Expand Down
13 changes: 13 additions & 0 deletions doc/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.8.0] - 2026-10-06

### Added
- **IPC calls can now resolve the calling user via the registered `AuthBackend`**, separately from the existing HTTP-route RBAC (`RBACChecker`). A new `AuthResolverMiddleware` sits in the plugin-call pipeline just before `PermissionMiddleware`: it resolves a `principal` (`AuthPayload`) from a raw `token` by calling `AuthBackend.decode_token()`, or passes through an already-resolved `principal` without contacting the backend again. `PluginSupervisor._dispatch()` exposes it for the duration of the call via a new `ContextVar` (`kernel/api/auth.py`), read with `get_current_principal()` — never by mutating the plugin's shared `PluginContext`. The generic HTTP endpoint `POST /{plugin}/{action}` resolves it best-effort from the incoming request (`resolve_principal_from_request()`); `TrustedBase.call_plugin()` forwards the current principal automatically on nested plugin-to-plugin IPC calls, with no re-decoding. This is resolution only — nothing in the kernel enforces anything on it yet. See [Auth: Resolving the current user on IPC calls](sdk/api/auth.md#resolving-the-current-user-on-ipc-calls).
- **`@action(name, permissions=None)`** — optional `permissions` list, stored on `fn._xcore_action_permissions`, mirroring `@route`'s existing `permissions` param. Declarative only for now: the hook a future per-action permission check will read.
- `xcdk` (`>=0.2.0,<0.3.0`) and `xcorecli` (`>=2.3.0,<3.0.0`) added as dependencies — both now published on PyPI, moving `xcore` towards depending on the real `xcoreSDK`/`xcoreCli` packages instead of only the vendored `xcore/sdk/` fallback.

### Changed
- `makefile` trimmed: removed the plugin clone/symlink/deploy/service-management targets (`add-plugin`, `rm-plugin`, `link`, `unlink`, `deploy`, `start`/`stop`/`restart`, `poetry-ri`, …), now superseded by `xcorecli`.

### Removed
- Stale generated report files that shouldn't have been tracked: `reports/bandit.{json,txt}`, `reports/bench_report_data.json`, `reports/report_analysis.md`, `reports/technical_debt_remediation_2026-08-10.md`, `reports/technical_debt_remediation_verification_2026-08-11.md`.

## [2.7.1] - 2026-10-02

Maintenance release — **no runtime change**: `xcore/` and the dependency ranges in `pyproject.toml` are identical to 2.7.0, so the published wheel behaves exactly like 2.7.0. It ships the repository-side security refresh and test/CI fixes below.
Expand Down
206 changes: 134 additions & 72 deletions doc/sdk/api/auth.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
---
title: Auth
description: AuthBackend, AuthPayload, RBAC decorators, and auth backend registration.
description: AuthBackend, AuthPayload, RBAC decorators, and the IPC user-resolution flow.
icon: material/lock-open
---

# Auth

xcoreSDK exposes the kernel's authentication backend interface, allowing plugins to register custom authentication providers and query authentication state.
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.

```python
from xcore.sdk import (
Expand All @@ -23,126 +23,188 @@ from xcore.sdk import (

## AuthPayload

Dataclass representing an authenticated identity.
A `TypedDict` representing an authenticated identity. Returned by `AuthBackend.decode_token()`.

```python
from xcore.sdk import AuthPayload

payload = AuthPayload(
subject="user:abc123", # unique identity string
roles=["admin", "viewer"], # role list
claims={"org": "acme"}, # arbitrary key-value claims
)

payload.subject # str
payload.roles # list[str]
payload.claims # dict
from xcore.kernel.api.auth import AuthPayload

payload: AuthPayload = {
"sub": "user:abc123", # unique identity string — required
"roles": ["admin", "viewer"], # optional
"permissions": ["write:users"], # optional
"user": {"org": "acme"}, # optional, arbitrary extra data
}
```

Only `sub` is required; `roles`, `permissions`, and `user` are optional (`NotRequired`).

---

## AuthBackend

Abstract base class for implementing custom authentication backends.
`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.

```python
from xcore.sdk import AuthBackend, AuthPayload
from xcore.kernel.api.auth import AuthBackend, AuthPayload

class JWTAuthBackend(AuthBackend):
class JWTAuthBackend:
def __init__(self, secret: str) -> None:
self.secret = secret

async def authenticate(self, token: str) -> AuthPayload | None:
"""Validate token and return identity, or None if invalid."""
async def decode_token(self, token: str) -> AuthPayload | None:
"""Validate a token and return the identity, or None if invalid."""
try:
data = jwt.decode(token, self.secret, algorithms=["HS256"])
return AuthPayload(
subject=data["sub"],
roles=data.get("roles", []),
claims=data,
)
return {"sub": data["sub"], "roles": data.get("roles", [])}
except jwt.InvalidTokenError:
return None

async def refresh(self, payload: AuthPayload) -> str:
"""Issue a new token from an existing valid payload."""
...
```
async def extract_token(self, request) -> str | None:
"""Pull the raw token out of an incoming HTTP request."""
header = request.headers.get("authorization", "")
if header.startswith("Bearer "):
return header[len("Bearer "):]
return None

### Required methods
async def has_permission(self, payload: AuthPayload, permission: str) -> bool:
return permission in payload.get("roles", []) or permission in payload.get("permissions", [])
```

| Method | Signature | Description |
|--------|-----------|-------------|
| `authenticate` | `(token: str) → AuthPayload \| None` | Validate credential, return identity or `None` |
| `refresh` | `(payload: AuthPayload) → str` | Issue a refreshed credential |
|--------|-----------|--------------|
| `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
## register_auth_backend / unregister_auth_backend

Registers an `AuthBackend` instance with the kernel.
Registers the single active backend instance. There is no name/registry — one backend at a time per process.

```python
from xcore.sdk import register_auth_backend
from xcore.sdk import register_auth_backend, unregister_auth_backend

class Plugin(AutoMixin):
class Plugin(TrustedBase):
async def on_load(self) -> None:
await super().on_load()
register_auth_backend("jwt", JWTAuthBackend(secret=self.ctx.env["JWT_SECRET"]))
```
self._backend = JWTAuthBackend(secret=self.ctx.env["JWT_SECRET"])
register_auth_backend(self._backend)

**Parameters**
async def on_unload(self) -> None:
unregister_auth_backend()
```

| Name | Type | Description |
|------|------|-------------|
| `name` | `str` | Backend identifier |
| `backend` | `AuthBackend` | Backend instance |
`register_auth_backend` raises `TypeError` if the object doesn't structurally satisfy `AuthBackend` (missing one of the three methods).

---

## unregister_auth_backend

Removes a previously registered backend. Call from `on_unload` to avoid dangling references.
## get_auth_backend / has_auth_backend

```python
async def on_unload(self) -> None:
await super().on_unload()
unregister_auth_backend("jwt")
from xcore.sdk import get_auth_backend, has_auth_backend

if has_auth_backend():
backend = get_auth_backend()
payload = await backend.decode_token(token)
```

`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.

---

## get_auth_backend
## RBAC on HTTP routes

Retrieves a registered backend by name. Raises `KeyError` if not registered.
For role/permission checks on `@route`-declared HTTP endpoints:

```python
backend = get_auth_backend("jwt")
payload = await backend.authenticate(token)
from xcore.kernel.api.rbac import RBACChecker, require_permission, require_role, get_current_user
```

---

## has_auth_backend

Checks whether a backend is registered without raising.
| 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 |

```python
if has_auth_backend("jwt"):
backend = get_auth_backend("jwt")
```
`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()`](./decorators.md#route).

---

## RBAC
## 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](./decorators.md#action)), while leaving existing behavior (requests with no backend registered, or no token) completely unaffected.

### How a token becomes a principal

1. The generic HTTP endpoint `POST /{plugin}/{action}` ([`router.py`](../../advanced/middleware.md)) calls `resolve_principal_from_request(request)`, which does the only two calls into the backend that happen for a given incoming request:
```python
token = await backend.extract_token(request) # read headers/cookies
principal = await backend.decode_token(token) # token → AuthPayload
```
Best-effort: returns `None` without raising if there's no backend, no token, or an invalid token.
2. The resolved `principal` (an `AuthPayload` or `None`) travels as a kwarg through `supervisor.call(..., principal=principal)`.
3. Inside the middleware pipeline, `AuthResolverMiddleware` sits just before `PermissionMiddleware`. If `principal` is already set, it passes through unchanged — **the backend is never contacted twice for the same call**. It only calls `decode_token()` itself when it receives a raw `token=` kwarg instead of an already-resolved `principal`.
4. `PluginSupervisor._dispatch()` stores `principal` in a `ContextVar` (`_current_principal`, same pattern as the tenant-id ContextVar) for the duration of the call — never by mutating the plugin's shared `PluginContext`.
5. Plugin code reads it with `get_current_principal()`.
6. 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:
```python
# TrustedBase.call_plugin() — automatic, nothing to do in plugin code
principal=get_current_principal()
```

### Diagram

```mermaid
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
```

For role-based access control, see the RBAC decorators exposed from the kernel:
### Reading the principal in a plugin

```python
from xcore.sdk import RBACChecker, require_permission, require_role, PermissionDenied
from xcore.kernel.api.auth import get_current_principal

class Plugin(TrustedBase):
async def handle(self, action, payload):
principal = get_current_principal()
if principal is None:
# no backend registered, or no token on this call — not an error
self.logger.debug("appel anonyme", action=action)
else:
self.logger.info("appel authentifié", sub=principal.get("sub"), action=action)
...
```

| Export | Description |
|--------|-------------|
| `RBACChecker` | Evaluates role/permission rules against an `AuthPayload` |
| `require_permission` | Decorator: raises `PermissionDenied` if permission absent |
| `require_role` | Decorator: raises `PermissionDenied` if role absent |
| `PermissionDenied` | Exception raised by RBAC checks |
### What this does *not* do (yet)

- No action is blocked based on `principal` today — `@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](../../plugins/permissions.md)) 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(...)`, every `principal` is always `None`, silently — no error, no behavior change versus before this feature existed.
21 changes: 16 additions & 5 deletions doc/sdk/api/decorators.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ All action and route decorators are importable from `xcore.sdk`.

## @action

Registers an async method as a dispatchable action.
Registers an async method as a dispatchable action, resolved by `AutoDispatchMixin.handle(action_name, payload)`.

```python
from xcore.sdk import action
Expand All @@ -24,11 +24,20 @@ async def get_user(self, payload: dict) -> dict:

**Parameters**

| Name | Type | Description |
|------|------|-------------|
| `name` | `str` | Action identifier used in `handle(name, payload)` |
| Name | Type | Default | Description |
|------|------|---------|-------------|
| `name` | `str` | — | Action identifier used in `handle(name, payload)` |
| `permissions` | `list[str] \| None` | `None` | Roles/permissions required to invoke this action — stored on `fn._xcore_action_permissions`. Declarative only today: nothing in the kernel enforces it yet (see [Auth](./auth.md#resolving-the-current-user-on-ipc-calls)); it's the hook a future per-action RBAC middleware will read. |

Just these two parameters — nothing else. The decorated method must be `async`, accept `self` and `payload: dict`, and return a `dict`.

The decorated method must be `async`, accept `self` and `payload: dict`, and return a `dict`.
`@action` and `@schema` are fully independent decorators — stack them (in either order) when an action also needs a versioned schema:

```python
@action("create_user", permissions=["admin"])
@schema(version="2.0", input={"email": (str, ...)})
async def create_user(self, payload: dict) -> dict: ...
```

---

Expand Down Expand Up @@ -69,6 +78,8 @@ async def create_item(self, body: dict):

Declares a versioned schema for an action — stores it on `fn._xcore_schema` for the SchemaRegistry — and optionally applies `@validate_payload` automatically.

Independent from `@action`: it can be stacked under `@action` in either order, or used alone on a method that isn't a dispatchable action at all (the schema metadata is just attached to the function; `AutoDispatchMixin` only registers it in the `SchemaRegistry` when `_xcore_action` is also present).

```python
from xcore.sdk import action, schema

Expand Down
Loading
Loading