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.

This commit is contained in:
Leo Vasanko
2026-01-29 21:11:29 +00:00
parent ac85379779
commit 8749067da7
3 changed files with 38 additions and 10 deletions
+5 -2
View File
@@ -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 = (
+3 -8
View File
@@ -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"
+30
View File
@@ -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."""