Python ยท Access control

Basic access control

Once requests are authenticated, the next step is authorization: deciding whether the authenticated user is allowed to perform the action. Authdog resolves who the user is; you decide what they can do based on the roles and permissions on the user object. This guide builds a small, reusable access-control layer for FastAPI on top of authdog.require_auth.

app.py
from authdog.fastapi import Authdog
ย 
authdog = Authdog(
public_key=os.environ["PK_AUTHDOG"]
)
ย 
@app.get("/me")
async def me(user):
return {"id": user.sub}

Where roles and permissions come from

require_auth returns the user object from the identity provider's userinfo response. Role and permission claims travel on that object โ€” for example:

{
  "id": "usr_123",
  "email": "[email protected]",
  "roles": ["admin"],
  "permissions": ["posts:read", "posts:write"]
}

The exact claim names depend on how your Authdog environment is configured. Adjust the accessors below to match your token's shape.

A `require_role` dependency

The pattern is a dependency factory: a function that takes the required role and returns a FastAPI dependency. It depends on require_auth first โ€” so authentication is always enforced โ€” then checks the claim:

from typing import Any
from fastapi import Depends, HTTPException, status
from authdog.fastapi import Authdog

authdog = Authdog(public_key=os.environ["PK_AUTHDOG"])

def require_role(*roles: str):
    """FastAPI dependency: 401 if unauthenticated, 403 if the role is missing."""
    async def _dependency(user: Any = Depends(authdog.require_auth)) -> Any:
        user_roles = set(user.get("roles", []))
        if not user_roles.intersection(roles):
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail="Insufficient role",
            )
        return user
    return _dependency

Use it on any route:

@app.get("/admin", dependencies=[Depends(require_role("admin"))])
async def admin_dashboard():
    return {"ok": True}

# Or capture the user when you need it:
@app.delete("/posts/{post_id}")
async def delete_post(post_id: str, user=Depends(require_role("admin", "editor"))):
    ...

Permission-based checks

Fine-grained authorization checks a permission rather than a role. Same factory shape:

def require_permission(*required: str):
    async def _dependency(user: Any = Depends(authdog.require_auth)) -> Any:
        granted = set(user.get("permissions", []))
        missing = set(required) - granted
        if missing:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f"Missing permission(s): {', '.join(sorted(missing))}",
            )
        return user
    return _dependency

@app.post("/posts", dependencies=[Depends(require_permission("posts:write"))])
async def create_post():
    ...

Resource-level (ownership) checks

Role and permission gates run before the handler. Ownership checks need the resource, so do them inside the handler once you have the authenticated user:

@app.patch("/posts/{post_id}")
async def update_post(post_id: str, user=Depends(authdog.require_auth)):
    post = await db.get_post(post_id)
    if post is None:
        raise HTTPException(status.HTTP_404_NOT_FOUND)
    is_owner = post.author_id == user.get("id")
    is_admin = "admin" in user.get("roles", [])
    if not (is_owner or is_admin):
        raise HTTPException(status.HTTP_403_FORBIDDEN, "Not your post")
    return await db.update_post(post_id, ...)

Choosing status codes

  • 401 Unauthorized โ€” no valid session. Raised for you by require_auth.
  • 403 Forbidden โ€” authenticated, but not allowed. Raised by your role / permission checks.

Keeping these distinct lets your frontend redirect to sign-in on a 401 and show an "access denied" message on a 403.

Next steps