fastapi-has-permissions
Introduction
Declarative permissions system for FastAPI. Define permission checks as classes or functions,
compose them with &, |, ~ operators, and plug them into FastAPI's dependency injection.
Installation
pip install fastapi-has-permissions
Usage
Setup
Call add_permissions() on your app — it binds an inject scope to every request, which
is what lets permissions and Evaluate resolve their dependencies at check time:
from fastapi import FastAPI
from fastapi_has_permissions import add_permissions
app = FastAPI()
add_permissions(app)
add_permissions() is idempotent, but it has to be called before the routes are
declared — FastAPI copies the router dependencies into every route at registration time.
Class-Based Permissions
Subclass Permission and implement check_permissions():
from fastapi import Depends, FastAPI
from starlette.requests import HTTPConnection
from fastapi_has_permissions import Permission, add_permissions
class HasAuthorizationHeader(Permission):
async def check_permissions(self, connection: HTTPConnection) -> bool:
return "Authorization" in connection.headers
app = FastAPI()
add_permissions(app)
@app.get(
"/protected",
dependencies=[Depends(HasAuthorizationHeader())],
)
async def protected():
return {"message": "You have access!"}
Permissions with parameters are automatically dataclasses:
class HasRole(Permission):
role: str
async def check_permissions(self, connection: HTTPConnection) -> bool:
return connection.headers.get("role") == self.role
Boolean Composition
Combine permissions with & (AND), | (OR), and ~ (NOT):
# All must pass
Depends(HasAuthorizationHeader() & HasRole("admin"))
# Any must pass
Depends(HasAuthorizationHeader() | HasRole("admin"))
# Negated
Depends(~HasAuthorizationHeader())
All three operators are lazy: each branch's dependencies are resolved at request
time, one branch at a time, and evaluation short-circuits as soon as the outcome is
decided — a failing or expensive dependency in a losing branch is never resolved.
Permissions still document themselves: add_permissions() teaches app.openapi() what
each route's permission tree requires, so headers, query params and OAuth2 scopes appear
in the schema without ever being resolved to produce it.
When a composed check fails, the failing permission's message, status code, error
code, and headers are propagated: & reports the first failing permission, and |
combines the failure reasons of all branches when none of them passed.
Skipped permissions (see skip()) are ignored by & and | — the remaining
permissions decide the outcome. A composite where every permission skipped is
itself skipped, and ~ passes a skip through unchanged:
| Expression | Result |
|---|---|
Pass & Skip |
pass |
Fail & Skip |
fail (with Fail's message/status) |
Skip & Skip |
skip |
Fail | Skip |
fail |
Pass | Skip |
pass |
Skip | Skip |
skip |
~Skip |
skip |
A skip is an abstention, not an approval: if it reaches the root of the tree — the
permission you passed to Depends() or to evaluator.require() — the request is
denied. Wrap the permission into AllowSkipped to state explicitly that an
abstention should grant access:
from fastapi_has_permissions import AllowSkipped
# skipped -> 403 Forbidden
Depends(IsArticleAuthor())
# skipped -> allowed
Depends(AllowSkipped(IsArticleAuthor()))
Note: every permission instance is a distinct FastAPI dependency (identity-based hashing), so two equal instances are resolved and checked independently within one request. Reuse the same instance when you want FastAPI's per-request dependency cache to apply.
Function-Based Permissions
Use the @permission decorator for a lightweight alternative:
from typing import Annotated
from fastapi import Header
from fastapi_has_permissions import permission
@permission
async def has_admin_role(role: Annotated[str, Header()]) -> bool:
return role == "admin"
@app.get("/admin", dependencies=[Depends(has_admin_role())])
async def admin_endpoint():
return {"message": "Admin access granted"}
Function-based permissions also support dependency arguments -- annotate them with Resolved[T],
which declares the resolved value the check receives:
from fastapi_has_permissions import DepFactory, Resolved, permission
async def get_admin_role() -> str:
return "admin"
AdminRoleDep = DepFactory[str, get_admin_role] # == Annotated[str, Depends(get_admin_role)]
@permission
async def has_role(admin_role: Resolved[str], /, role: Annotated[str, Header()]) -> bool:
return role == admin_role
@app.get("/admin", dependencies=[Depends(has_role(AdminRoleDep))])
async def admin_endpoint():
return {"message": "Admin access granted"}
Function-based permissions support the same &, |, ~ composition.
Deferred Resolution
Every permission resolves its dependencies at check time, so a dependency is only paid for if its permission is actually reached. When a dependency may not resolve at all, wrap the permission to decide what that means:
from fastapi.exceptions import RequestValidationError
from fastapi_has_permissions import SkipOnExc
# Skip the check instead of failing if the "age" header is missing
Depends(SkipOnExc(AgeIsMoreThan(age=18), (RequestValidationError,)))
This works at any depth -- inside a composition, or nested in another wrapper.
Imperative Checks
Use the Evaluate dependency to check permissions inside a handler body without
raising — for branching logic, partial responses, or explicit control:
from fastapi_has_permissions import Evaluate
@app.get("/posts")
async def list_posts(evaluate: Evaluate):
if await evaluate.check(IsAdmin()):
return all_posts()
return public_posts()
evaluate(perm) returns the raw CheckResult, evaluate.check(perm) returns a
bool, and evaluate.require(perm) raises the permission's HTTP error on failure.
Outside a request (e.g. in unit tests), the module-level evaluate() resolves
dependencies against a fresh scope — combine it with
fastapi_injected.push_overrides to stub them:
from fastapi_has_permissions import evaluate
from fastapi_injected import push_overrides
with push_overrides({get_role: "admin"}):
assert await evaluate(HasAdminRole())
Error Model
- Permissions raise 403 by default; authentication-style permissions can set
default_exc_status_code = 401anddefault_exc_headers = {"WWW-Authenticate": "Bearer"}(the built-inIsAuthenticateddoes exactly this) - Set a machine-readable error code (
code=.../default_exc_code) to get a structured body:{"detail": {"code": "not_admin", "message": "Admin role required"}}; without a code the body stays a plain string - All of message, status code, code, and headers propagate through
&,|,~ - A permission's own error config applies when it is the root of the tree; use
WithError(...)to give a nested subtree one error (e.g. a404that does not admit the resource exists)
Policies
A Policy groups the rules for one resource, and the request method picks which one runs:
from fastapi_has_permissions import DepFactory, Policy, Requires
from fastapi_has_permissions.common import Allow, HasRole
PostDep = DepFactory[Post, get_post]
class PostPolicy(Policy[Post]):
read = Allow()
create = HasRole(RoleDep, "author")
update = HasRole(RoleDep, "author")
delete = HasRole(RoleDep, "admin")
__resource__ = PostDep
GET/QUERY/HEAD map to read, POST to create, PUT/PATCH to update, DELETE
to delete, and anything else to default. All five default to Deny(), so a policy
opens only what it declares.
Attach it once to a router and every route under it is covered by the action matching its verb -- which is what the method mapping is for:
posts = APIRouter(prefix="/posts", dependencies=[Depends(PostPolicy())])
@posts.get("") # -> read
async def list_posts() -> list[Post]: ...
@posts.post("") # -> create
async def create_post(body: PostIn) -> Post: ...
@posts.delete("/{post_id}") # -> delete
async def delete_post(post_id: int) -> None: ...
A route added later is covered the moment it exists, and a verb with no action falls to
default, which denies -- so you cannot leave a route on this router unprotected by
forgetting something. A router-level policy never resolves __resource__, so the collection
routes are never asked for a post_id they do not have.
or with Requires, which checks and injects the loaded resource:
@app.get("/posts/{post_id}")
async def read(post: Annotated[Post, Requires(PostPolicy())]) -> Post:
return post
The check runs before the resource is resolved and before the handler body, so it cannot be
skipped. Actions are ordinary permissions, so they compose with &/|/~, take the
wrappers, and propagate their own error config.
Actions are not limited to CRUD -- declare one and name it at the call site:
class PostPolicy(Policy[Post]):
publish = HasRole(RoleDep, "editor")
__resource__ = PostDep
@app.post("/posts/{post_id}/publish")
async def publish(post: Annotated[Post, Requires(PostPolicy(), PostPolicy.publish)]) -> Post:
return post
PostPolicy.bind(OtherDep) reuses the same rules against a different loader.
Other Features
- Custom error responses -- set
default_exc_message/default_exc_status_code/default_exc_code/default_exc_headersclass variables or the correspondingmessage/status_code/code/headersinit parameters - Skip / Fail helpers -- call
skip()orfail()insidecheck_permissions()for explicit control flow - Wrappers --
AllowSkipped/DenySkipped/Advisoryrewrite a check's result,WithErrorgives a whole subtree one error config,FailOnExc/SkipOnExckeep a broken check from turning into a 500,Undocumentedkeeps a check out of the OpenAPI schema - Built-in common permissions --
IsAuthenticated,HasScope,HasRoleready to use with your auth dependencies - Policies --
Policygroups a resource's rules and dispatches on the request method;Requireschecks and injects the loaded object for object-level permissions - Full FastAPI DI support --
check_permissions()accepts any FastAPI-injectable parameters - Built on fastapi-injected --
Dep/DepFactoryare re-exported from it, and permissions resolve through its inject scope, so they share the request's dependency cache with the route
Metadata
Release files for fastapi-has-permissions 0.4.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| fastapi_has_permissions-0.4.1.tar.gz | 139.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| fastapi_has_permissions-0.4.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 164.2 kB
Release files / fastapi_has_permissions-0.4.1.tar.gz
| Download URL | fastapi_has_permissions-0.4.1.tar.gz |
|---|---|
| Size | 139.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
75c4c887bef1aa60e1ae9176d19efa27f13dd80c2278b563b6fdf9f622ca7ee8
|
|
BLAKE2b-256 checksum How to use checksums |
b9331279597a78e4e93fffb4c6c73308170559c121770118f4bf06ba4203feb0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.18 {"installer":{"name":"uv","version":"0.12.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / fastapi_has_permissions-0.4.1-py3-none-any.whl
| Download URL | fastapi_has_permissions-0.4.1-py3-none-any.whl |
|---|---|
| Size | 24.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0210d58ce62966641a33de0c2d37b4488094a14d25d0eb897bfdb3788945f4d1
|
|
BLAKE2b-256 checksum How to use checksums |
8bb2939faad3f69489a6ecca9843415c87ce907638c81bdce29d2d42912e5fbc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.18 {"installer":{"name":"uv","version":"0.12.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|