Skip to content

Authentication & Authorization

FastPluggy ships a minimal auth layer built on Starlette's AuthenticationMiddleware. Auth backends are provided by plugins (e.g. auth_user) and auto-wired at startup.

How it works

  1. No auth plugin installed → all routes are open, request.state.current_user is FPAnonymousUser
  2. Auth plugin installed (e.g. auth_user) → the plugin calls fast_pluggy.set_auth_manager(backend) during on_load_complete, which sets the backend that the always-installed AuthenticationMiddleware delegates to (populating request.user) and flips the auth_enable template global. It does NOT gate anything.

⚠ Installing an auth plugin gates NOTHING by itself. AuthenticationMiddleware identifies the caller; it never rejects one. Every protected surface must opt in with Depends(require_authentication) / require_role(...) — per route or per include_router. An app that loads auth_user and adds no dependency is fully open to anonymous users.

Verifying a gate — test it, don't inspect it

Since FastAPI >= 0.138 include_router(..., dependencies=[...]) no longer bakes the dependencies into the route objects; it wraps them in an _IncludedRouter and resolves them at request time. Walking router.routes / route.dependencies therefore shows the original dependency-free routes and cannot tell you what is gated — an inspection-based test reports "0 routes gated" against a gate that demonstrably works, and would just as happily mis-report a broken one. Assert behaviourally instead:

r = TestClient(app).get("/plugin/thing", follow_redirects=False)
assert r.status_code in (401, 403, 307)   # anonymous is refused

You can also pass an auth manager explicitly:

fast_pluggy = FastPluggy(app, auth_manager=my_custom_backend)

If set explicitly, plugins will not override it.

set_auth_manager(backend)

Sets the auth backend at runtime. Called automatically by auth plugins, but can also be used programmatically:

fast_pluggy.set_auth_manager(my_backend)

This: - Sets self.auth_manager - Updates the auth_enable template global

It adds no middleware. AuthenticationMiddleware is installed once, unconditionally, in FastPluggy.__init__, with a DelegatingAuthBackend that re-reads fastpluggy.auth_manager on every request — so set_auth_manager is a plain assignment.

That is load-bearing, not incidental. Starlette freezes app.add_middleware() after the first ASGI call, so the older "add the middleware once the backend arrives" shape could only ever work for a plugin that loaded before the app started serving. Because the middleware is now a fixed point and only the attribute it reads changes, set_auth_manager is safe to call at any time, more than once, and from a degraded-mode self-heal long after the app has been answering requests (fast_pluggy#22).

Dependencies

from fastpluggy.core.auth import require_authentication, require_role

require_authentication

Rejects unauthenticated requests (401) or redirects to login if the auth backend defines on_authenticate_error. When no auth manager is set, this is a no-op — all requests pass through.

# Protect an entire router
app.include_router(my_router, dependencies=[Depends(require_authentication)])

# Or a single route
@router.get("/secret", dependencies=[Depends(require_authentication)])
async def secret(request: Request): ...

require_role(role: str)

Requires the user to have a specific role (checked against request.auth.scopes). FastPluggy uses "fp_admin" to protect its own admin routes. No-op when auth manager is not set.

app.include_router(admin_router, dependencies=[Depends(require_role("fp_admin"))])

user_has_role(request, role)

The pluggable role-check seam on the auth backend. The menu renderer calls it for every item carrying a required_role; you can call it too for conditional rendering inside a page. Unlike require_role (a hard route guard that raises), this returns a bool and centralises how a role is resolved so you never hardcode a roles-array comparison.

Default policy on AuthInterface:

  1. Super-user is a master key. A user with is_admin, or holding any role in super_user_roles ({"fp_admin", "admin"} by default), passes every check — the admin sees every menu item regardless of required_role.
  2. Otherwise, scope membership: role in request.auth.scopes. Scopes are produced by your authenticate(), so each app already controls how a user's attributes (bool columns, DB roles, LDAP groups…) become roles.
class MyAuth(AuthInterface):
    # Tighten the master key, or set to frozenset() so nobody is a super-user.
    super_user_roles = frozenset({"fp_admin"})

    def user_has_role(self, request, role):
        # Example: role hierarchy — an editor is also a viewer.
        if role == "viewer" and "editor" in request.auth.scopes:
            return True
        return super().user_has_role(request, role)

Override just this one method to get role hierarchies, wildcards, or a permission→role map — no menu or route changes needed.

Current user

Set by CurrentUserMiddleware on every request:

user = request.state.current_user   # FPAnonymousUser if not authenticated
if user.is_authenticated:
    ...
if user.is_admin:
    ...

Available in templates as {{ request.state.current_user }}.

FPAnonymousUser extends Starlette's UnauthenticatedUser and is used as the fallback when no user is authenticated. It guarantees request.state.current_user is never None.

The user object shape depends on the auth backend. FastPluggy core expects:

Attribute Type Description
is_authenticated bool False for anonymous, True for logged-in users
display_name str Shown in the topbar user menu
is_admin bool Controls visibility of the admin sidebar section
profile_picture str\|None Optional; used for avatar display

Writing a custom auth backend

Implement AuthInterface (extends Starlette's AuthenticationBackend):

from fastpluggy.core.auth.auth_interface import AuthInterface

class MyAuthManager(AuthInterface):
    @property
    def user_model(self):
        return MyUserModel

    async def authenticate(self, conn):
        # Return (AuthCredentials, user) or None
        ...

    async def on_authenticate_error(self, request):
        # Return a Response (e.g. redirect) or raise HTTPException
        ...

Then either pass it to FastPluggy(app, auth_manager=MyAuthManager()) or call fast_pluggy.set_auth_manager(MyAuthManager()) from a plugin's on_load_complete.