Add an endpoint¶
A route that is guarded by declaration, reads its tenant from the context, and does its work in the tenant-bound unit of work.
1. Declare it¶
from fastapi import APIRouter
from jdlib.integrations.fastapi import get_uow, require
router = APIRouter()
@router.post("/api/invoices")
@require("invoice:create")
async def create_invoice(body: InvoiceIn, uow=Depends(get_uow)) -> InvoiceOut:
async with uow.transaction():
invoice = await uow.repositories.invoices.add(...)
return InvoiceOut.from_entity(invoice)
Three rules are visible in six lines:
- the permission is declared (
@require) rather than checked inside the handler — the guard enforces it before the handler is entered, which is what makes a denial non-execution; - the tenant is not a parameter — it comes from the bound context, and the unit of work is already tenant-bound;
- the failure path is not handled here — a refusal, a conflict and a dependency failure all leave as the library's envelope.
2. Take the tenant from the context¶
from jdlib import current_tenant
tenant_slug = current_tenant().slug # the resolved tenant, never the caller's claim
A route that reads a tenant from the path, the query or the body has created a second source for it.
The enterprise example's test_tenant_isolation.py exists to fail exactly that.
3. Do the work in the unit of work¶
get_uow() returns the request's tenant-bound unit of work. A transaction opened on it is scoped to
the tenant, and the database's policy applies as well.
4. Test it¶
| Test | What it proves |
|---|---|
| a guarded call with an allowing credential | the happy path, through the real guard |
| a call without the permission | 403, and the handler did not run |
| a call with a token claiming another tenant | the tenant in the response is the credential's, not the claim's |
| a call when the policy engine is unreachable | a refusal, not a pass |
The second row is the one that needs care: assert non-execution with a side effect the handler would have written, and keep an allowing case in the same test so a broken guard cannot make the test pass by refusing everything.
What goes wrong¶
| Symptom | The cause |
|---|---|
| a 500 where a 403 was expected | the handler raised instead of the guard refusing — the permission is not declared |
| the tenant in the response is the caller's claim | the handler read a request field instead of the context |
| a cross-tenant read returns an empty page | a reference resolved to another tenant — expect 409 INVALID_REFERENCE, and treat an empty page as a bug |