Skip to content

Authentication and authorization

Authentication belongs at the HTTP boundary. Business authorization belongs in use cases and pure policy functions. Keeping them separate lets the same application rules run from HTTP, workers, scripts, and direct tests.

Carry optional identity

from dataclasses import dataclass


@dataclass(frozen=True, slots=True)
class AppContext:
    todos: TodoRepository
    user: User | None = None

The context begins without verified identity. A hook authenticates the request and returns an enriched copy.

Authenticate in a hook

from dataclasses import replace

from tenchi.errors import AppError
from tenchi.server import Hook, RequestInfo


def create_bearer_hook(tokens: TokenDirectory) -> Hook:
    async def authenticate(
        info: RequestInfo,
        context: AppContext,
    ) -> AppContext | None:
        if info.contract.public:
            return None

        scheme, _, token = info.headers.get("authorization", "").partition(" ")
        if scheme.lower() != "bearer" or not token:
            raise AppError(unauthorized)

        user = await tokens.lookup(token)
        if user is None:
            raise AppError(unauthorized)
        return replace(context, user=user)

    return authenticate

Register the hook at composition:

app = create_app(
    routes=routes,
    context_factory=create_context,
    hooks=(create_bearer_hook(tokens),),
)

Authentication hooks run before request input validation, so an unauthenticated caller cannot use validation responses to inspect an operation the hook protects.

Signed provider callbacks need the exact request bytes, not only headers. Use a webhook verifier binding for those endpoints. It runs after ordinary hooks and request size/media checks, before contract input validation, and may attach a service identity to the same context model.

Declare authentication failures

The server's error honesty rule applies to hooks. Declare the authentication error across the routes protected by the hook:

private_routes = route_group(
    project_routes,
    task_routes,
    errors=(unauthorized,),
)

Health and OpenAPI routes are public by default. Pass public=False if the application should protect them.

Authorize in the use case

async def get_project(
    params: GetProjectParams,
    context: AppContext,
) -> Project:
    user = require_user(context.user)
    project = await context.projects.get(params.project_id)
    return ensure_can_view_project(user, project, project_id=params.project_id)

The use case still asserts identity through an app-owned helper. This matters when a worker or test invokes it without the HTTP hook. It fetches the subject through a port, then delegates the authorization decision to the subject's policy module.

The policy keeps both the reusable ability and its declared failure mapping in one I/O-free place:

from app.shared.errors import project_not_found
from tenchi.errors import AppError


def can_view_project(user: User, project: Project | None) -> bool:
    if project is None:
        return False
    return project.owner_id == user.id or user.id in project.member_ids


def ensure_can_view_project(
    user: User,
    project: Project | None,
    *,
    project_id: str,
) -> Project:
    if project is None or not can_view_project(user, project):
        raise AppError(project_not_found, details={"project_id": project_id})
    return project

can_view_project() can be reused by other features. The ensure_* helper turns the same decision into the operation's public error semantics. On reads, missing and unviewable subjects both become not-found so callers cannot probe identifiers.

Public is metadata, not a guard

Setting public=True only gives hooks and OpenAPI one consistent exemption signal. The hook must still inspect it, and use cases remain responsible for business authorization.

For webhook contracts, public=True exempts only the ordinary authentication hook. webhook=True separately requires an exact-body verifier at application composition.