Skip to main content

fastapi-has-permissions

license test codecov pypi downloads

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 = 401 and default_exc_headers = {"WWW-Authenticate": "Bearer"} (the built-in IsAuthenticated does 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. a 404 that 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_headers class variables or the corresponding message / status_code / code / headers init parameters
  • Skip / Fail helpers -- call skip() or fail() inside check_permissions() for explicit control flow
  • Wrappers -- AllowSkipped / DenySkipped / Advisory rewrite a check's result, WithError gives a whole subtree one error config, FailOnExc / SkipOnExc keep a broken check from turning into a 500, Undocumented keeps a check out of the OpenAPI schema
  • Built-in common permissions -- IsAuthenticated, HasScope, HasRole ready to use with your auth dependencies
  • Policies -- Policy groups a resource's rules and dispatches on the request method; Requires checks 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 / DepFactory are 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

fastapi_has_permissions-0.4.0.tar.gz (137.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

fastapi_has_permissions-0.4.0-py3-none-any.whl (24.4 kB view details)

Uploaded Python 3

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

Hashes for fastapi_has_permissions-0.4.0.tar.gz
Algorithm Hash digest
SHA256 00e14f781d6be1cea76fcaddafbd3addbd38ebf09068c756d599a708704f3f3e
MD5 dbac2fddcfaea44afe109c310463c08a
BLAKE2b-256 0b30d344c467643b72ce791f23996456589c8cab326a7fb95a2167fbf40bd76b

See more details on using hashes here.

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

Hashes for fastapi_has_permissions-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 f622850d358c9e6b59f93353bf4b6b1a5914531ae53d4fce1c343cb4f1a06edf
MD5 6338a5dfa2912a136066d51938d23c6f
BLAKE2b-256 231d813efa3d2b604f37f6ebd3a53ffb96084e95568fa796101124ee30c7fc01

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.4.0 This release

2 files

0.3.0

2 files

0.2.0

2 files

0.1.5

2 files

0.1.4

2 files

0.1.3

2 files

0.1.2

2 files

0.1.1

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page