From 76f24a755bf933de94aed39eb0a5c6edbf99ccb6 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 26 Apr 2026 05:45:59 +0000 Subject: [PATCH] Add GET /auth/api/check endpoint for unauthenticated user permission checks Checks permissions for a user given by ?user= query arg without requiring a session cookie. No cookie is read or written, no DB writes. - perm= query arg supported (same wildcard semantics as validate/forward) - Returns valid bool + minimal ctx (user/org/role/permissions) - Permissions are host-scoped via domain filtering, same as session_ctx - 404 if UUID not found; valid=false if perm check fails (no 403) - Add ApiCheckUserResponse struct to apistructs - Add has_all_scopes() helper to permutil for scope-set-based checks --- paskia/fastapi/api.py | 68 +++++++++++++++++++++++++++++++++++++-- paskia/util/apistructs.py | 7 ++++ paskia/util/permutil.py | 7 +++- 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index 46f5008..35ec627 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -1,6 +1,7 @@ import logging from contextlib import suppress from datetime import UTC, datetime, timedelta +from uuid import UUID from fastapi import ( Depends, @@ -20,8 +21,17 @@ from paskia.fastapi import authz, session, user from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip from paskia.globals import passkey as global_passkey -from paskia.util import hostutil, htmlutil, passphrase, userinfo -from paskia.util.apistructs import ApiSettings, ApiTokenInfo, ApiValidateResponse +from paskia.util import hostutil, htmlutil, passphrase, permutil, userinfo +from paskia.util.apistructs import ( + ApiCheckUserResponse, + ApiOrgContext, + ApiRoleContext, + ApiSessionContext, + ApiSettings, + ApiTokenInfo, + ApiUserContext, + ApiValidateResponse, +) bearer_auth = HTTPBearer(auto_error=False) @@ -109,6 +119,60 @@ async def validate_token( ) +@app.get("/check") +async def check_user( + request: Request, + user_uuid: UUID = Query(..., alias="user"), + perm: list[str] = Query([]), +): + """Check permissions for a user by UUID without requiring a session. + + Query Params: + - user: UUID of the user to check. + - perm: repeated permission scope the user must possess (ALL required). + + Returns 200 with valid=True/False and the user's effective permissions, + scoped to the requesting host (domain-restricted permissions are filtered). + Returns 404 if the user UUID does not exist. + + No session cookie is read or written. Caller authentication is not required. + """ + data = db.data() + try: + u = data.users[user_uuid] + role = u.role + org = role.org + except KeyError: + raise HTTPException(status_code=404, detail="User not found") + + host = hostutil.normalize_host(request.headers.get("host")) + org_perm_uuids = {p.uuid for p in org.permissions} + + effective_perms = [] + for perm_uuid in role.permission_set: + if perm_uuid not in org_perm_uuids: + continue + try: + p = data.permissions[perm_uuid] + except KeyError: + continue + if p.domain is not None and p.domain != host: + continue + effective_perms.append(p) + + required = " ".join(perm).split() + effective_scopes = {p.scope for p in effective_perms} + valid = permutil.has_all_scopes(effective_scopes, required) + + ctx = ApiSessionContext( + user=ApiUserContext(uuid=u.uuid, display_name=u.display_name, theme=u.theme), + org=ApiOrgContext(uuid=org.uuid, display_name=org.display_name), + role=ApiRoleContext(uuid=role.uuid, display_name=role.display_name), + permissions=sorted(effective_scopes), + ) + return MsgspecResponse(ApiCheckUserResponse(valid=valid, ctx=ctx)) + + @app.get("/forward") async def forward_authentication( request: Request, diff --git a/paskia/util/apistructs.py b/paskia/util/apistructs.py index a1cecef..f4a454f 100644 --- a/paskia/util/apistructs.py +++ b/paskia/util/apistructs.py @@ -233,6 +233,13 @@ class ApiValidateResponse(msgspec.Struct): ctx: ApiSessionContext +class ApiCheckUserResponse(msgspec.Struct): + """Response struct for check-user endpoint.""" + + valid: bool + ctx: ApiSessionContext + + class ApiAdminInfo(msgspec.Struct, kw_only=True): """Combined admin info response.""" diff --git a/paskia/util/permutil.py b/paskia/util/permutil.py index faf635a..2589a96 100644 --- a/paskia/util/permutil.py +++ b/paskia/util/permutil.py @@ -6,7 +6,7 @@ from fnmatch import fnmatchcase from paskia.authsession import session_ctx from paskia.util.hostutil import normalize_host -__all__ = ["has_any", "has_all", "session_context"] +__all__ = ["has_any", "has_all", "has_all_scopes", "session_context"] def _match(perms: set[str], patterns: Sequence[str]): @@ -36,6 +36,11 @@ def has_all(ctx, patterns: Sequence[str]) -> bool: return all(_match(_get_effective_scopes(ctx), patterns)) if ctx else False +def has_all_scopes(scopes: set[str], patterns: Sequence[str]) -> bool: + """Check that a pre-computed scope set satisfies all required patterns.""" + return all(_match(scopes, patterns)) if patterns else True + + async def session_context(auth: str | None, host: str | None = None): if not auth: return None