Actor Context
Representing the authenticated caller without coupling to HTTP or JWT infrastructure.
Related: 15-security/01-authentication-patterns.md · 15-security/02-authorization-strategies.md
Framework implementation: For FastAPI see 18-framework-implementations/fastapi/07-request-and-actor-context.md.
The Problem
Application handlers and domain services need to know who is performing an action — to enforce permissions, scope queries, and record audit trails. But they must not depend on HTTP request objects, JWT libraries, or any authentication infrastructure.
The solution is ActorContext: a plain, framework-agnostic dataclass populated once at the API boundary and passed explicitly through the call stack.
ActorContext Structure
from dataclasses import dataclass, field
from enum import Enum
class ActorType(str, Enum):
USER = "user" # authenticated user (web, mobile)
SYSTEM = "system" # internal service-to-service call
WEBHOOK = "webhook" # inbound webhook from external system (Stripe, WhatsApp)
@dataclass
class ActorContext:
actor_type: ActorType
user_id: str | None = None # set for USER actors
roles: list[str] = field(default_factory=list)
account_ids: list[str] = field(default_factory=list)
# Add domain-specific fields as needed
def is_admin(self) -> bool:
return "admin" in self.roles
def can_access_account(self, account_id: str) -> bool:
return self.is_admin() or account_id in self.account_idsActorContext lives in the application layer (or domain layer if authorization is a domain concern). It has no imports from infrastructure, HTTP frameworks, or auth libraries.
Population — API Layer Only
ActorContext is populated exactly once, in API middleware, before the request reaches any handler:
JWT / headers / webhook token
↓
API middleware
↓
ActorContext constructed
↓
stored in request.state / request scope
↓
route handler retrieves it from state
↓
passed explicitly to application handler
Route handlers extract ActorContext from request state and pass it as a parameter. They do not make authorization decisions themselves.
Application handlers receive actor: ActorContext as a parameter. They never read from request.state directly.
Actor Types
| Type | Identity source | Typical use |
|---|---|---|
USER | JWT token claims | Authenticated web or mobile user |
SYSTEM | Internal service header | Service-to-service calls within the system |
WEBHOOK | Shared secret header | External webhooks (Stripe, WhatsApp, etc.) |
Authorization in Application Handlers
Authorization decisions live in the application layer (or domain layer for invariants). The API layer only populates and forwards the actor:
async def handle(
self,
command: GetAccountCommand,
actor: ActorContext,
) -> Account:
if not actor.can_access_account(command.account_id):
raise NotAuthorizedError("Access denied")
return await self.uow.accounts.get_by_id(command.account_id)Testing
In tests, construct ActorContext directly — no JWT generation needed:
def admin_actor() -> ActorContext:
return ActorContext(actor_type=ActorType.USER, user_id="u-1", roles=["admin"])
def user_actor(account_id: str) -> ActorContext:
return ActorContext(actor_type=ActorType.USER, user_id="u-2", account_ids=[account_id])This is one of the key benefits of keeping ActorContext framework-free: test setup is trivial.
Rules
ActorContextis a plain dataclass — no framework or auth library imports.- Population happens in middleware only — never in route handlers or application code.
- Application handlers receive
actor: ActorContextas an explicit parameter — never from a global, thread-local, or context variable. - Authorization helper methods (
is_admin(),can_access_account()) live onActorContext— not scattered across handlers. - Domain entities are never passed
ActorContext— they enforce invariants, not permissions. - Extend
ActorContextwith domain-specific fields (e.g.tenant_id) as needed — keep the base minimal.
Framework implementations
Request context is the set of per-request metadata (request ID, actor identity, timing, path) that flows through the entire request lifecycle — middleware → route handler → application handler — without being passed explicitly as function parameters.
The Context Dict
RequestContextMiddleware creates request.state.ctx at the start of every request:
request.state.ctx = {
"request_id": "550e8400-e29b-41d4-a716-446655440000", # UUID, set by middleware
"path": "/api/v1/accounts",
"method": "POST",
"start_time": 1234567890.123456, # time.perf_counter() for duration calc
"client_ip": "10.0.0.1",
"actor": { # populated as anonymous; overwritten by JWT middleware
"actor_id": None,
"actor_type": "anonymous",
"roles": (),
"account_ids": (),
},
"jwt": {}, # raw JWT claims, set by JWTAuthMiddleware
}How Context Is Populated (Sequence)
Incoming request
│
▼
RequestContextMiddleware
├── generates request_id (UUID)
├── records start_time
└── sets actor = anonymous
│
▼
RequestLoggingMiddleware
└── reads request_id, path, method → logs request.start
│
▼
JWTAuthMiddleware
├── validates Bearer token
└── if valid: overwrites actor with authenticated identity
│
▼
Route handler
└── reads ctx via get_request_context(request)
│
▼
Response unwinding
├── RequestLoggingMiddleware → logs request.end (status, duration_ms)
└── RequestContextMiddleware → adds X-Request-ID header
Accessing Context in Routes
# api/dependencies.py
from fastapi import Request
def get_request_context(request: Request) -> dict:
return getattr(request.state, "ctx", {})
def get_request_id(request: Request) -> str:
return get_request_context(request).get("request_id", "")# In a route handler:
@router.post("/accounts")
async def create_account(
body: CreateAccountRequest,
request_id: str = Depends(get_request_id),
handler=Depends(get_create_account_handler),
):
result = await handler.execute(body.to_command(), request_id=request_id)
return AccountResponse.from_domain(result)Passing Context to Application Handlers
Application handlers receive the request ID so they can include it in any logs or events they emit. They do not receive the full request.state.ctx dict — they receive only what they need:
# application/account/create.py
class CreateAccountHandler:
async def execute(self, command: CreateAccountCommand, request_id: str = "") -> Account:
# Use request_id in logs and emitted events
await self._event_publisher.publish(
AccountCreatedEvent(account_id=account.id, request_id=request_id)
)Actor Context in Application Handlers
Application handlers receive ActorContext explicitly — they do not reach into request state:
# Route passes actor explicitly:
@router.put("/{account_id}")
async def update_account(
account_id: UUID,
body: UpdateAccountRequest,
actor: ActorContext = Depends(require_authenticated),
handler=Depends(get_update_account_handler),
):
await handler.execute(
command=body.to_command(account_id),
actor=actor,
)
# Handler uses actor for business-rule authorisation:
class UpdateAccountHandler:
async def execute(self, command: UpdateAccountCommand, actor: ActorContext) -> None:
if not actor.can_access_account(str(command.account_id)):
raise NotAuthorizedError("Cannot update account")
...Rules
request.state.ctxis the single shared context dict for a request. Do not create parallel context dicts.- Context is read-only from the route handler's perspective — middleware sets it, routes consume it.
- Application handlers receive
request_idandActorContextas explicit parameters, notrequest.state. This keeps the application layer independent of the HTTP framework. - Never pass the full
Requestobject into application handlers.