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, Request
from fastapi_has_permissions import Permission, add_permissions
class HasAuthorizationHeader(Permission):
async def check_permissions(self, request: Request) -> bool:
return "Authorization" in request.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, request: Request) -> bool:
return request.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
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file fastapi_has_permissions-0.4.0.tar.gz.
File metadata
- Download URL: fastapi_has_permissions-0.4.0.tar.gz
- Upload date:
- Size: 137.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","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}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
00e14f781d6be1cea76fcaddafbd3addbd38ebf09068c756d599a708704f3f3e
|
|
| MD5 |
dbac2fddcfaea44afe109c310463c08a
|
|
| BLAKE2b-256 |
0b30d344c467643b72ce791f23996456589c8cab326a7fb95a2167fbf40bd76b
|
File details
Details for the file fastapi_has_permissions-0.4.0-py3-none-any.whl.
File metadata
- Download URL: fastapi_has_permissions-0.4.0-py3-none-any.whl
- Upload date:
- Size: 24.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","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}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f622850d358c9e6b59f93353bf4b6b1a5914531ae53d4fce1c343cb4f1a06edf
|
|
| MD5 |
6338a5dfa2912a136066d51938d23c6f
|
|
| BLAKE2b-256 |
231d813efa3d2b604f37f6ebd3a53ffb96084e95568fa796101124ee30c7fc01
|