Guards¶
Use Litestar guards directly in guards=[...] route declarations. For the runtime
contract and API reference, see Guards API.
Superuser guard¶
is_superuser requires an authenticated active user and then checks normalized
role membership. By default, the user must expose the "superuser" role in
roles; set LitestarAuthConfig.superuser_role_name when your deployment uses
another normalized role name such as "admin".
Organization membership guard¶
requires_organization_membership requires an authenticated active user and a verified
current-organization context for the request. It fails closed with
ErrorCode.AUTHORIZATION_DENIED when the request is anonymous, the user is inactive, the request
does not carry a tenant hint, the tenant hint does not resolve to an organization, or the
authenticated user has no membership in that organization.
from litestar import get
from litestar_auth.guards import requires_organization_membership
@get("/organization/projects", guards=[requires_organization_membership])
async def list_organization_projects() -> dict[str, bool]:
return {"ok": True}
Tenant hints from headers or subdomains are untrusted. The guard allows the request only after the authentication middleware has resolved the organization and verified the authenticated user's membership through the configured organization store.
Use the litestar_auth_current_organization dependency when a handler needs the verified
organization and membership objects:
from typing import Any
from litestar import get
from litestar_auth.guards import requires_organization_membership
@get("/organization/me", guards=[requires_organization_membership])
async def read_current_organization(
litestar_auth_current_organization: Any,
) -> dict[str, str]:
return {
"organization_id": str(litestar_auth_current_organization.organization.id),
"membership_id": str(litestar_auth_current_organization.membership.id),
}
This guard only verifies membership in the resolved current organization. Use the organization role and permission guards below when a route also requires membership roles or role-derived permissions. Application-owned tables still need explicit tenant foreign keys, query filters, and database isolation.
Organization role guards¶
has_organization_role() requires an authenticated active user, a verified current-organization
context, and all listed roles on that membership row. It does not read the user's global roles.
Without verified organization context it fails closed with
ErrorCode.INSUFFICIENT_ORGANIZATION_ROLES.
from litestar import get
from litestar_auth.guards import has_organization_role
@get("/organization/billing", guards=[has_organization_role("billing-admin")])
async def organization_billing() -> dict[str, bool]:
return {"ok": True}
Role names use the same trim, lowercase, deduplicate, and sort normalization as global role guards. At least one role is required, and empty role names are rejected when the guard is created.
Organization permission guards¶
has_organization_permission() requires an authenticated active role-capable user, a verified
current-organization context, and all listed effective permissions after organization-aware role
resolution. Without verified organization context it fails closed with
ErrorCode.INSUFFICIENT_ORGANIZATION_PERMISSIONS.
from litestar import get
from litestar_auth.guards import has_organization_permission
@get("/organization/posts", guards=[has_organization_permission("posts:write")])
async def write_organization_posts() -> dict[str, bool]:
return {"ok": True}
When the request is authenticated with an API key, organization permission guards keep the same least-privilege ceiling as general permission guards: the owning user's organization-scoped effective permissions must grant the route requirement, and the key scopes must delegate it.
Typed role guards¶
has_any_role() and has_all_roles() accept plain strings, but their Python 3.12
generic signatures also preserve narrower string subtypes. That means you can keep
role names IDE-discoverable and type-checkable instead of scattering ad-hoc string
literals through your codebase.
Plain strings¶
Plain strings remain valid and keep the existing runtime behavior:
from litestar import get
from litestar_auth.guards import has_any_role
@get("/reports", guards=[has_any_role("admin", "billing")])
async def reports_dashboard() -> dict[str, bool]:
return {"ok": True}
Literal role aliases¶
Use Literal[...] when your project has a small fixed role vocabulary and you want
type checkers to catch misspellings at call sites:
from typing import Literal
from litestar_auth.guards import has_any_role
type AppRole = Literal["admin", "billing", "support"]
ADMIN: AppRole = "admin"
BILLING: AppRole = "billing"
reports_guard = has_any_role(ADMIN, BILLING)
StrEnum role registries¶
Use StrEnum when you want a central registry that still behaves like strings at
runtime:
from enum import StrEnum
from litestar_auth.guards import has_all_roles
class AppRole(StrEnum):
ADMIN = "admin"
BILLING = "billing"
SUPPORT = "support"
finance_guard = has_all_roles(AppRole.ADMIN, AppRole.BILLING)
Normalization and rejection rules¶
Role guards normalize configured role names with trim, lowercase, deduplicate, and sort semantics before matching against the authenticated user's normalized flat role membership.
Internally, has_any_role() and has_all_roles() compare normalized role strings
with fixed-work loops rather than set-intersection or subset short-circuit
predicates. This preserves the same flat-role behavior while documenting the
library's defense-in-depth posture; it is not a cryptographic constant-time
guarantee for the full Python runtime or request path.
Invalid guard definitions fail fast during application setup instead of waiting for the first request:
- At least one role is required.
- Empty or whitespace-only role names are rejected with
ValueError.
That means has_any_role(" Admin ", "admin") collapses to one normalized
requirement, while has_any_role(" ") is rejected immediately.
Permission guards¶
has_permission(), has_all_permissions(), and has_any_permission() authorize
against the authenticated user's resolved effective permissions. They require the
same account shape as role guards: the request must have an authenticated active
user whose model exposes flat roles membership. Permission resolution then comes
from the plugin's request-scope PermissionResolver.
When the request is authenticated with an API key, permission guards apply the
key's scopes as a least-privilege ceiling: a requirement is satisfied only when the
owning user grants it and the key's own scopes delegate it. A scoped key can
therefore never exceed its delegation on a permission-guarded route, mirroring the
scope_subset_check ceiling used by scope guards — even for a superuser owner. Keys
whose scopes are legacy simple names (no resource:action grammar) or are empty
carry no permission-shaped authority and fail closed on permission guards; use
has_scope() for those keys, or requires_password_session to exclude API keys
from a route entirely.
from litestar import get
from litestar_auth.guards import has_any_permission, has_permission
@get("/posts", guards=[has_permission("posts:read")])
async def list_posts() -> dict[str, bool]:
return {"ok": True}
@get("/moderation", guards=[has_any_permission("posts:moderate", "comments:moderate")])
async def moderation_queue() -> dict[str, bool]:
return {"ok": True}
Use has_permission() or has_all_permissions() when every listed permission is
required. Use has_any_permission() when any one listed permission is enough.
When a verified current-organization context is present, the same permission guards become
organization-aware automatically. With the default OrganizationConfig.role_precedence="replace",
membership roles from the current organization replace the user's global roles for permission
resolution, so broad global roles do not leak into tenant-scoped requests. The configured
superuser_role_name remains a global grant: a user holding that global role resolves to "*" even
inside an organization. Set OrganizationConfig.role_precedence="merge" only when your application
intentionally wants global roles and organization membership roles to combine inside org context.
Set OrganizationConfig.require_authorization_context=True when permission guards should fail
closed unless the request has verified organization context.
The replace default is the safer tenant-isolation posture for most deployments: an organization
permission guard is satisfied only by roles on the verified membership row, except for the explicit
global superuser grant. With role_precedence="merge", every global role on the user is unioned with
the membership roles for each organization the user belongs to. That means a global permission such
as posts:write can satisfy has_organization_permission("posts:write") in every verified
organization context for that user, not just one tenant. Choose merge only when that cross-tenant
global-role behavior is intentional and understood by operators.
Permission strings normalize with the same trim, lowercase, deduplicate, and sort rules as role names, then must match one of these forms:
resource:actionfor ordinary route requirements and grants.resource:*for a grant that covers every action on one resource.*for the global grant.
Route requirements cannot be wildcards: has_permission("posts:*") and
has_permission("*") fail closed because a route should name the concrete action it
requires. Wildcards belong on the granted side through role_permissions or a
custom resolver. For example, posts:* grants posts:read and posts:write; *
grants every concrete permission requirement.
Configure static role permissions¶
For simple role-derived authorization, configure LitestarAuthConfig.role_permissions.
The field defaults to an empty mapping, so permission guards deny non-superuser
users until you configure grants or provide a custom resolver.
from uuid import UUID
from litestar_auth import LitestarAuthConfig
from litestar_auth.models import User
config = LitestarAuthConfig[User, UUID](
user_model=User,
user_manager_class=UserManager,
session_maker=session_maker,
backends=[jwt_backend],
role_permissions={
"editor": ("posts:read", "posts:write"),
"auditor": ("reports:read",),
"content-admin": ("posts:*", "comments:*"),
},
)
The configured superuser_role_name still acts as a global bypass. A user with that
normalized role resolves to the "*" grant, so permission guards allow every
concrete permission requirement without listing each permission in role_permissions.
Use resolved permissions as dependency data¶
The plugin also registers a litestar_auth_permissions dependency. It returns a
frozenset[str] from the same request-scope resolver that guards use, or an empty
set for anonymous requests.
from litestar import get
@get("/me/permissions")
async def read_my_permissions(litestar_auth_permissions: frozenset[str]) -> dict[str, list[str]]:
return {"permissions": sorted(litestar_auth_permissions)}
Use this dependency for response shaping or UI hints, not as a replacement for route guards on protected operations.
API-key scopes share the permission vocabulary¶
API-key scope guards still read scopes from request.auth, but permission-shaped
scopes now use the same resource:action, resource:*, and * semantics. With
scope_subset_check=True, a delegated API key must be covered by the owning user's
currently resolved permissions, so revoking the user's underlying permission also
removes the key's effective route access.
Legacy simple scopes without : keep the previous exact scopes-as-role-names
downscoping rule for migration. New deployments should prefer permission-shaped
API-key scopes and matching route requirements.