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 = NoneThe 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 authenticateRegister 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 projectcan_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.
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.