Authorization Strategies
Composite authorization pattern — role-based, payment-based, and webhook strategies chained together.
Status: Extracted
Purpose: Composite authorization pattern, authorization strategies, and authorization service implementation.
Composite Authorization Pattern
Overview
Pattern: Multiple authorization strategies work together
Benefits:
- Flexible authorization logic
- Easy to add new strategies
- Clear separation of concerns
- Testable individual strategies
Implementation:
CompositeAuthorizationServicechains multiple strategies- Each strategy implements
AuthorizationServiceinterface - AND Logic: All strategies must pass (if any raises PermissionError, authorization fails)
- Extensible: New authorization strategies can be added easily
Authorization Strategies
Role-Based Authorization
Strategy: RoleBasedAuthorizationService
Purpose: Checks roles (admin, etc.)
Pattern:
class RoleBasedAuthorizationService(AuthorizationService):
"""Role-based authorization strategy."""
async def check(self, action: str, resource: str, actor: ActorContext) -> None:
"""Check role-based permissions."""
# Check if actor has required role
if "admin" in actor.roles:
return # Admin has all permissions
# ... other role checksPayment-Based Authorization
Strategy: PaymentBasedAuthorizationService
Purpose: Checks payment status and tier (post-P0)
Pattern:
class PaymentBasedAuthorizationService(AuthorizationService):
"""Payment-based authorization strategy."""
async def check(self, action: str, resource: str, actor: ActorContext) -> None:
"""Check payment-based permissions."""
if actor.payment_status != "active":
raise PermissionError("Payment required")
# ... payment tier checksWebhook Authorization
Strategy: WebhookAuthorizationService
Purpose: Checks system/webhook actors
Pattern:
class WebhookAuthorizationService(AuthorizationService):
"""Webhook authorization strategy."""
async def check(self, action: str, resource: str, actor: ActorContext) -> None:
"""Check webhook permissions."""
if actor.actor_type == "system":
return # System actors have permissions
# ... webhook-specific checksComposite Authorization Service
Implementation
Pattern:
class CompositeAuthorizationService(AuthorizationService):
"""Runs multiple authorization strategies in order."""
def __init__(self, strategies: list[AuthorizationService]) -> None:
self._strategies = strategies
async def check(self, action: str, resource: str, actor: ActorContext) -> None:
"""Check authorization using all strategies."""
for strategy in self._strategies:
await strategy.check(action, resource, actor)
return NoneUsage:
strategies = [
RoleBasedAuthorizationService(),
PaymentBasedAuthorizationService(),
WebhookAuthorizationService(),
]
authorization_service = CompositeAuthorizationService(strategies)Authorization Service Interface
Port Definition
Location: application/security/authorization.py
Pattern:
class AuthorizationService(ABC):
"""Authorization port."""
@abstractmethod
async def check(self, action: str, resource: str, actor: ActorContext) -> None:
"""
Check if actor is authorized to perform action on resource.
Raises:
PermissionError if not authorized
"""
raise NotImplementedErrorActor Context
Actor Context Structure
Pattern:
@dataclass(frozen=True)
class ActorContext:
"""Represents the actor context for authorization checks."""
actor_id: Optional[str] = None # user_id or machine_id
actor_type: str = "user" # "user" | "system" | "service"
account_ids: tuple[str, ...] = () # accounts the actor is associated with
roles: tuple[str, ...] = () # roles (e.g., admin)
payment_status: Optional[str] = None # optional, for payment-based authz
payment_tier: Optional[str] = None # optional, for payment-based authzRelated Documents
- Authentication Patterns - Authentication approaches
- Permission Models - Permission checks
- Security Practices - Security best practices
Reference implementation: infrastructure/security/authorization_composite.py
Framework implementations
Abstract reference: 15-security/
Authentication is handled in JWTAuthMiddleware. Authorisation is handled via Depends() in route definitions. Application and domain layers are auth-unaware — they receive an ActorContext and apply business rules.
JWT Service Interface (application/authentication/jwt_service.py)
from abc import ABC, abstractmethod
from uuid import UUID
class JWTService(ABC):
@abstractmethod
def generate_token(self, account_id: UUID, account_type: str, roles: list[str]) -> str: ...
@abstractmethod
def refresh_token(self, token: str) -> str: ...
@abstractmethod
def validate_token(self, token: str) -> bool: ...
@abstractmethod
def extract_claims(self, token: str) -> dict: ...JWT Implementation (infrastructure/authentication/jwt_token.py)
import jwt
from datetime import datetime, timedelta, timezone
from uuid import UUID
from application.authentication.jwt_service import JWTService
class PyJWTService(JWTService):
def __init__(self, secret: str, algorithm: str = "HS256",
access_expire_minutes: int = 1440,
issuer: str = "",
audience: str = ""):
self._secret = secret
self._algorithm = algorithm
self._access_expire_minutes = access_expire_minutes
self._issuer = issuer # e.g. "https://api.your-service.com"
self._audience = audience # e.g. "your-service-api"
def generate_token(self, account_id: UUID, account_type: str, roles: list[str]) -> str:
now = datetime.now(timezone.utc)
payload = {
# Standard OAuth 2.0 / OIDC claims
"iss": self._issuer, # Issuer — required
"aud": self._audience, # Audience — required
"sub": str(account_id), # Subject (principal)
"iat": now, # Issued at
"exp": now + timedelta(minutes=self._access_expire_minutes), # Expiry
# Domain-specific claims
"account_type": account_type,
"roles": roles,
}
return jwt.encode(payload, self._secret, algorithm=self._algorithm)
def validate_token(self, token: str) -> bool:
try:
jwt.decode(
token,
self._secret,
algorithms=[self._algorithm],
audience=self._audience, # Validates aud claim
issuer=self._issuer, # Validates iss claim
)
return True
except jwt.PyJWTError:
return False
def extract_claims(self, token: str) -> dict:
return jwt.decode(
token,
self._secret,
algorithms=[self._algorithm],
audience=self._audience,
issuer=self._issuer,
)Actor Context (application/security/authorization.py)
The actor context is the auth-safe representation of the authenticated user, available to application handlers:
from dataclasses import dataclass
@dataclass(frozen=True)
class ActorContext:
actor_id: str | None
actor_type: str # "user" | "admin" | "system" | "anonymous"
roles: tuple[str, ...]
account_ids: tuple[str, ...] # IDs this actor can access
@property
def is_authenticated(self) -> bool:
return self.actor_id is not None
@property
def is_admin(self) -> bool:
return "admin" in self.roles
def can_access_account(self, account_id: str) -> bool:
return account_id in self.account_ids or self.is_adminThe actor context is populated by JWTAuthMiddleware into request.state.ctx["actor"]. See 07-request-and-actor-context.md for the full propagation flow.
Route-Level Authorisation (api/security.py)
from fastapi import Depends, Request, HTTPException
from application.security.authorization import ActorContext
def get_actor_context(request: Request) -> ActorContext:
"""Extracts actor from request context. Use in routes that need the current user."""
actor_data = getattr(request.state, "ctx", {}).get("actor", {})
return ActorContext(
actor_id=actor_data.get("actor_id"),
actor_type=actor_data.get("actor_type", "anonymous"),
roles=tuple(actor_data.get("roles", [])),
account_ids=tuple(actor_data.get("account_ids", [])),
)
def require_authenticated(actor: ActorContext = Depends(get_actor_context)) -> ActorContext:
if not actor.is_authenticated:
raise HTTPException(status_code=401, detail="Authentication required")
return actor
def require_admin(actor: ActorContext = Depends(get_actor_context)) -> ActorContext:
if not actor.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
return actor
def require_account_access(account_id: str):
"""Factory for route-specific account access check."""
def dependency(actor: ActorContext = Depends(require_authenticated)) -> ActorContext:
if not actor.can_access_account(account_id):
raise HTTPException(status_code=403, detail="Access denied")
return actor
return dependencyUsage in routes:
@router.get("/{account_id}")
async def get_account(
account_id: UUID,
actor: ActorContext = Depends(require_authenticated),
handler=Depends(get_account_handler),
):
if not actor.can_access_account(str(account_id)):
raise HTTPException(status_code=403)
return await handler.execute(str(account_id))
@router.get("/admin/all")
async def list_all_accounts(
actor: ActorContext = Depends(require_admin),
handler=Depends(get_list_accounts_handler),
):
return await handler.execute()Password Hashing (infrastructure/authentication/password_bcrypt.py)
import bcrypt
from application.authentication.password_service import PasswordService
class BcryptPasswordService(PasswordService):
def hash(self, plain: str) -> str:
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
def verify(self, plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode(), hashed.encode())Auth Factories (infrastructure/authentication/factories.py)
from config.settings import Settings
from infrastructure.authentication.jwt_token import PyJWTService
from infrastructure.authentication.password_bcrypt import BcryptPasswordService
def build_jwt_service(settings: Settings) -> PyJWTService:
return PyJWTService(
secret=settings.auth_jwt_secret,
algorithm=settings.auth_jwt_algorithm,
access_expire_minutes=settings.auth_jwt_access_token_expire_minutes,
issuer=settings.auth_jwt_issuer, # e.g. "https://api.your-service.com"
audience=settings.auth_jwt_audience, # e.g. "your-service-api"
)
def build_password_service(settings: Settings) -> BcryptPasswordService:
return BcryptPasswordService()Rules
- JWT validation happens only in middleware, never in route handlers or application handlers.
- Route handlers receive an
ActorContext— they do not decode tokens or read auth headers. - Application handlers apply business-rule-based authorisation (e.g. "can this actor modify this account?") using
ActorContext. They do not raise HTTP exceptions — they raise domain exceptions (NotAuthorizedError), which exception handlers map to 403 responses. - Public routes (health, docs, login, signup) are listed in
JWTAuthMiddleware.PUBLIC_PATHS— middleware skips validation for these.