From 8749067da70944448445cb019fa34a3ab38066db Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Thu, 29 Jan 2026 21:11:29 +0000 Subject: [PATCH] Add support for perm=foo+bar to specify multiple scopes that are required. Multiple perm args are acceptable too. Pretty logging of permission denied errors. --- paskia/fastapi/api.py | 7 +++++-- paskia/fastapi/authz.py | 11 +++-------- paskia/fastapi/logging.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index ae09751..44aa314 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -78,7 +78,7 @@ async def validate_token( try: ctx = await authz.verify( auth, - perm, + " ".join(perm).split(), host=request.headers.get("host"), max_age=max_age, ) @@ -131,7 +131,10 @@ async def forward_authentication( """ try: ctx = await authz.verify( - auth, perm, host=request.headers.get("host"), max_age=max_age + auth, + " ".join(perm).split(), + host=request.headers.get("host"), + max_age=max_age, ) # Build permission scopes for Remote-Groups header role_permissions = ( diff --git a/paskia/fastapi/authz.py b/paskia/fastapi/authz.py index ce2426c..6f0d0e1 100644 --- a/paskia/fastapi/authz.py +++ b/paskia/fastapi/authz.py @@ -2,6 +2,7 @@ import logging from fastapi import HTTPException +from paskia.fastapi.logging import log_permission_denied from paskia.util import permutil, sessionutil logger = logging.getLogger(__name__) @@ -93,20 +94,14 @@ async def verify( logger.warning(f"Invalid max_age format '{max_age}': {e}") if not match(ctx, perm): - # Determine which permissions are missing for clearer diagnostics effective_scopes = ( {p.scope for p in (ctx.permissions or [])} if ctx.permissions else set(ctx.role.permissions or []) ) missing = sorted(set(perm) - effective_scopes) - logger.warning( - "Permission denied: user=%s role=%s missing=%s required=%s granted=%s", # noqa: E501 - getattr(ctx.user, "uuid", "?"), - getattr(ctx.role, "display_name", "?"), - missing, - perm, - list(effective_scopes), + log_permission_denied( + ctx, perm, missing, require_all=(match == permutil.has_all) ) raise AuthException( status_code=403, mode="forbidden", detail="Permission required" diff --git a/paskia/fastapi/logging.py b/paskia/fastapi/logging.py index 7922978..299dd56 100644 --- a/paskia/fastapi/logging.py +++ b/paskia/fastapi/logging.py @@ -4,8 +4,12 @@ import logging import sys import time from ipaddress import IPv6Address +from typing import TYPE_CHECKING from starlette.middleware.base import BaseHTTPMiddleware + +if TYPE_CHECKING: + from paskia.db.structs import SessionContext from starlette.requests import Request from starlette.responses import Response @@ -25,6 +29,12 @@ _TIMING = "\033[38;5;242m" # timing/devmode (dark grey) _WS_OPEN = "\033[1;93m" # WebSocket connect (bold bright yellow) _WS_CLOSE = "\033[33m" # WebSocket disconnect (yellow) _WS_STATUS = "\033[38;5;242m" # WebSocket close status (dark grey) +_AUTHZ_DENIED = "\033[0;31m" # Permission denied (red) +_AUTHZ_USER = "\033[1;34m" # User info (light blue) +_AUTHZ_ORG = "\033[34m" # User info (blue) +_AUTHZ_NEEDS = "\033[1;38;5;231m" # Needs (brightest white) +_AUTHZ_MISSING = "\033[1;31m" # Missing scope (bold red) +_AUTHZ_GRANTED = "\033[0;32m" # Granted scope (green) def format_ipv6_network(ip: str) -> str: @@ -198,6 +208,26 @@ def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None: logger.info(f"{' ' * 15} {prefix} {status_str} {timing_str}") +def log_permission_denied( + ctx: "SessionContext", required: list[str], missing: list[str], *, require_all: bool +) -> None: + """Log permission denied with org, role, user and highlighted missing scopes.""" + missing_set = set(missing) + scopes = " ".join( + f"{_AUTHZ_MISSING}{s}✗{_RESET}" + if s in missing_set + else f"{_AUTHZ_GRANTED}{s}✓{_RESET}" + for s in required + ) + n = "" if len(required) == 1 else " all" if require_all else " any" + logger.warning( + f"{_AUTHZ_DENIED}Permission denied{_RESET} " + f"{_AUTHZ_USER}{ctx.user.display_name}{_RESET} " + f"{_AUTHZ_ORG}({ctx.org.display_name} {ctx.role.display_name}){_RESET} " + f"{_AUTHZ_NEEDS}needs{n}:{_RESET} {scopes}" + ) + + class AccessLogMiddleware(BaseHTTPMiddleware): """Middleware that logs HTTP requests with custom format."""