Add a custom permission¶
A tool or resource is guarded by its spec's permissions: kernel
PermissionCheck instances, asked of the principal the auth backend resolved,
before the target is looked up or the service runs. A custom permission is a
subclass with a has_permission(principal, spec) method. Every check on the
spec must allow, and a refusal is answered -32006 with HTTP 403.
Example: tenant-scoped access¶
A multi-tenant app might want to gate every tool by the requesting user's tenant matching a configured value:
from django_service_specs import PermissionCheck
class TenantMatches(PermissionCheck):
def __init__(self, tenant_id: int) -> None:
self.tenant_id = tenant_id
def has_permission(self, principal, spec) -> bool:
return getattr(principal, "tenant_id", None) == self.tenant_id
Stack it with a scopes= gate to keep both surfaces honest. The tenant_id is
captured when the spec is built — read it from settings or any other
process-wide source, never from a request (no request exists then):
from django.conf import settings
from django_service_specs import ServiceSpec
server.register_tool(
ServiceSpec(
service=refund_invoice,
permissions=[TenantMatches(tenant_id=settings.ACTIVE_TENANT_ID)],
),
name="invoices.refund",
description="Refund an invoice.",
scopes=("invoices:write",),
)
The scope gate is answered first, with the missing scopes in the challenge; a caller holding them whose tenant does not match gets a 403 whose challenge names no scope, because none would help. A check is the kernel's, so the same spec is guarded the same way on every transport that serves it.
A check that needs more than the principal declares it by keyword:
arguments for the call's shape-checked arguments, call_context for the
request (and the token on it, as request.mcp_token), or a registered pool
seed by its name. See Authentication.
Checks are synchronous, on both transports¶
has_permission, has_object_permission and has_scope_permission must be
plain defs. A check declaring an async def method is refused with
ImproperlyConfigured when the spec is built, under ASGI just as much as under
WSGI.
That is not an oversight of the async transport. The async path runs a check in
the executor, so an async def has_permission would answer with a coroutine
that nothing awaits — and an un-awaited coroutine is truthy, which means
every caller is granted. The refusal converts a silent, total bypass into a
misconfiguration you can see.
If a check genuinely needs to await something, do it inside the synchronous method:
from asgiref.sync import async_to_sync
from django_service_specs import PermissionCheck
class TenantMatches(PermissionCheck):
def has_permission(self, principal, spec) -> bool:
return async_to_sync(self._check)(principal)
async def _check(self, principal) -> bool: ...
The same rule holds for rate limiters (consume). Session
stores are the deliberate exception — see
Swap the session store — and may be async def, but
only when mounted under server.async_urls; the sync transport refuses those
too rather than treating the coroutine as an answer.
Gating a prompt or a view¶
A prompt and an interactive view are not specs, but they take the same kind of
check: permissions=[...] on register_prompt and register_ui_resource is a
list of kernel PermissionChecks. Each is asked
has_permission(principal, binding), with the prompt's or view's binding
standing where a spec would, so one check class can guard a spec and a prompt
alike, and can tell them apart by what it is handed.
from django_service_specs import PermissionCheck
class TenantMatches(PermissionCheck):
message = "This belongs to another tenant."
def __init__(self, tenant_id: int) -> None:
self._tenant_id = tenant_id
def has_permission(self, principal, spec) -> bool:
return getattr(principal, "tenant_id", None) == self._tenant_id
server.register_prompt(
name="tenant-report",
render=render_report,
permissions=[TenantMatches(tenant_id=7)],
)
Only the principal and the binding are passed: a check that requires
arguments or call_context cannot gate a prompt. has_permission must be a
plain def, as on a spec: an async def one is refused at registration,
because its answer would be a truthy coroutine nothing awaits. For OAuth
scopes, pass scopes= beside the checks rather than writing a check that reads
the token; a scope refusal is the one that names the scopes in
WWW-Authenticate.
Tips¶
- Cheap to construct, side-effect-free at evaluation. A check is built once,
with its spec, so any expensive lookup belongs in
has_permission(and even then, cache it). - A value passed to
__init__is read once, when the spec is built. That suits a fixed deployment value like the tenant above. For one a test overrides or that changes while the process runs, read it insidehas_permissioninstead, where every call sees the current value. - Surface scopes only when they're actually scope-shaped. A row-level rule
like
TenantMatchesbelongs on the spec, not inscopes=: the failing client can't fix it by obtaining a different scope.