Documentation

Actor Context

Representing the authenticated caller without coupling to HTTP or JWT infrastructure.

Show:

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_ids

ActorContext 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

TypeIdentity sourceTypical use
USERJWT token claimsAuthenticated web or mobile user
SYSTEMInternal service headerService-to-service calls within the system
WEBHOOKShared secret headerExternal 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

  • ActorContext is 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: ActorContext as an explicit parameter — never from a global, thread-local, or context variable.
  • Authorization helper methods (is_admin(), can_access_account()) live on ActorContext — not scattered across handlers.
  • Domain entities are never passed ActorContext — they enforce invariants, not permissions.
  • Extend ActorContext with 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.ctx is 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_id and ActorContext as explicit parameters, not request.state. This keeps the application layer independent of the HTTP framework.
  • Never pass the full Request object into application handlers.