Compare commits

..
5 Commits
Author SHA1 Message Date
LeoVasanko 72d76df35d Log session id from handlers on selected auth routes. Adds request.state.log_extra for handlers to print access log extra. 2026-04-29 03:02:30 +00:00
LeoVasanko 1a742fc0e7 Cleaner websocket access log. 2026-04-29 02:45:20 +00:00
LeoVasanko 0b29654d6f Log original path on forward endpoint. Added logging extra argument for such additions on access logs. 2026-04-29 02:16:48 +00:00
LeoVasanko 76f24a755b Add GET /auth/api/check endpoint for unauthenticated user permission checks
Checks permissions for a user given by ?user=<UUID> 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
2026-04-26 05:45:59 +00:00
LeoVasanko 5c452f325a Better error messages on database loading errors. 2026-02-19 21:52:33 +00:00
6 changed files with 147 additions and 36 deletions
+6
View File
@@ -1,11 +1,13 @@
import argparse
import logging
import os
import sys
import msgspec
from fastapi_vue import server
from fastapi_vue.hostutil import parse_endpoints
from paskia._version import __version__
from paskia.db.jsonl import load_readonly
from paskia.util import startupbox
from paskia.util.hostutil import (
@@ -74,7 +76,11 @@ def main():
# Load stored config (read-only, no writes, no global state)
db_path = os.environ.get("PASKIA_DB", f"{args.rp_id}.paskiadb")
try:
config = load_readonly(db_path, rp_id=args.rp_id).config
except SystemExit as e:
print(f"🛑 Paskia {__version__} could not load")
sys.exit(str(e))
# Override stored config with CLI args, or clear with empty string
if args.rp_name is not None:
+18 -17
View File
@@ -41,11 +41,9 @@ class ReplayResult(msgspec.Struct, frozen=False):
changes: int = 0
class DatabaseError(Exception):
class DatabaseError(ValueError):
"""Exception raised for database loading errors."""
pass
def _replay_from_data(data: bytes, db_path: str) -> ReplayResult:
"""Replay database state from file data, using the last snapshot if available."""
@@ -61,14 +59,16 @@ def _replay_from_data(data: bytes, db_path: str) -> ReplayResult:
# Replay change records after the snapshot
lines = data[start_offset:].split(b"\n")
for line_num, raw in enumerate(lines, start=1): # 1-based line numbering
for raw in lines:
line = raw.strip()
if not line:
continue
try:
change = msgspec.json.decode(line, type=ChangeRecord)
except msgspec.DecodeError as e:
raise DatabaseError(f"{resolved_path}:{line_num}: {e}")
raise DatabaseError(
f"{resolved_path}: {e}\n{line.decode(errors='replace')}"
)
result.state = jsondiff.patch(result.state, change.diff, marshal=True)
result.v = change.v
result.ts = change.ts
@@ -88,19 +88,10 @@ def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB:
return DB(config=Config(rp_id=rp_id))
try:
with open(path, "rb") as f:
content = f.read()
content = path.read_bytes()
r = _replay_from_data(content, str(path.resolve()))
data_dict = r.state
version = r.v
except OSError as e:
_logger.exception("Failed to load database")
raise SystemExit(f"{e}")
except (ValueError, msgspec.DecodeError, DatabaseError) as e:
raise SystemExit(f"{e}")
except Exception as e:
_logger.exception("Unexpected error loading database")
raise SystemExit(f"{e}")
if not data_dict:
return DB(config=Config(rp_id=rp_id))
@@ -109,8 +100,18 @@ def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB:
apply_migrations_readonly(data_dict, version, MigrationCtx(rp_id=rp_id))
# Decode to msgspec struct
db = msgspec.json.decode(msgspec.json.encode(data_dict), type=DB)
return db
try:
return msgspec.json.decode(msgspec.json.encode(data_dict), type=DB)
except msgspec.ValidationError as e:
raise DatabaseError(f"{path.resolve()}: {e}") from None
except OSError as e:
_logger.exception("Failed to load database")
raise SystemExit(f"{e}")
except (ValueError, msgspec.DecodeError) as e:
raise SystemExit(f"{e}")
except Exception as e:
_logger.exception("Unexpected error loading database")
raise SystemExit(f"{e}")
class ChangeRecord(msgspec.Struct, omit_defaults=True, kw_only=True):
+78 -2
View File
@@ -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,18 @@ 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.crypto import hash_secret
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)
@@ -46,6 +57,12 @@ async def http_exception_handler(_request: Request, exc: HTTPException):
_REFRESH_INTERVAL = timedelta(minutes=5)
def _set_log_extra(request: Request, *parts: str) -> None:
values = [part for part in parts if part]
if values:
request.state.log_extra = " ".join(values)
@app.exception_handler(ValueError)
async def value_error_handler(_request: Request, exc: ValueError):
return JSONResponse(status_code=400, content={"detail": str(exc)})
@@ -100,6 +117,7 @@ async def validate_token(
)
session.set_session_cookie(response, auth)
renewed = True
_set_log_extra(request, ctx.session.key)
return MsgspecResponse(
ApiValidateResponse(
valid=True,
@@ -109,6 +127,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,
@@ -138,6 +210,7 @@ async def forward_authentication(
host=request.headers.get("host"),
max_age=max_age,
)
_set_log_extra(request, request.headers.get("x-forwarded-uri", ""), ctx.session.key)
# Build permission scopes for Remote-Groups header
role_permissions = (
{p.scope for p in ctx.permissions} if ctx.permissions else set()
@@ -212,6 +285,8 @@ async def api_user_info(
clear_session=True,
)
_set_log_extra(request, ctx.session.key)
return MsgspecResponse(
await userinfo.build_user_info(
user_uuid=ctx.user.uuid,
@@ -287,5 +362,6 @@ async def api_set_session(
if not ctx:
raise HTTPException(401, f"Session not found on {host}")
_set_log_extra(request, hash_secret("cookie", secret))
session.set_session_cookie(response, secret)
return {"status": "ok", "user": str(ctx.user.uuid)}
+30 -14
View File
@@ -112,7 +112,13 @@ def method_color(method: str) -> str:
def format_access_log(
client: str, status: int, method: str, host: str, path: str, duration_ms: float
client: str,
status: int,
method: str,
host: str,
path: str,
duration_ms: float,
extra: str = "",
) -> str:
"""Format access log line with colors and aligned fields."""
# Format components with fixed widths for alignment
@@ -126,8 +132,9 @@ def format_access_log(
host_str = f"{_HOST}{host}{_RESET}"
path_str = f"{_PATH}{path}{_RESET}"
# Format: "IP STATUS METHOD host path TIMING"
return f"{ip} {status_str} {method_str} {host_str}{path_str} {timing_str}"
# Format: "IP STATUS METHOD host path [extra] TIMING"
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
return f"{ip} {status_str} {method_str} {host_str}{path_str}{extra_str} {timing_str}"
# WebSocket connection counter (mod 100)
@@ -152,20 +159,21 @@ def log_ws_open(ws) -> int:
origin = ws.headers.get("origin")
ip = format_client_ip(client).ljust(19)
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
# ID right-aligned like status codes (3 chars), emoji formatted like method
id_str = f"{_WS_OPEN}{str(ws_id).rjust(3)}{_RESET}"
# Emoji (2 display width) + 6 spaces = 8 display chars, but within color for alignment
emoji_str = f"{_METHOD_READ}🔌 {_RESET}"
# Determine if origin should be shown (omit when same as host)
# Origin header includes scheme (e.g., "https://example.com"), compare host part
origin_host = origin.split("://", 1)[-1] if origin else None
show_origin = origin_host and origin_host != host
# 🔌 aligned with status (takes ~2 char width), ID aligned with method
prefix = f"🔌 {_WS_OPEN}{id_str}{_RESET}"
host_str = f"{_HOST}{host}{_RESET}"
path_str = f"{_PATH}{path}{_RESET}"
origin_str = f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
logger.info(f"{ip} {prefix} {host_str}{path_str}{origin_str}")
logger.info(f"{ip} {id_str} {emoji_str}{host_str}{path_str}{origin_str}")
return ws_id
@@ -191,21 +199,25 @@ WS_CLOSE_CODES = {
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
"""Log WebSocket connection close with duration and status."""
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
# ID right-aligned like status codes (3 chars), "closed" formatted like method
id_str = f"{_WS_CLOSE}{str(ws_id).rjust(3)}{_RESET}"
# Pad within the dim color to keep full width in color (8 display chars)
closed_str = f"{_TIMING}closed {_RESET}"
timing = f"{duration * 1000:.0f}ms"
# Convert close code to status text
if close_code is None:
status = "closed"
code = "----"
status = "unknown"
else:
code = str(close_code)
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
# 🔌 aligned with status, ID aligned with method
prefix = f"🔌 {_WS_CLOSE}{id_str}{_RESET}"
status_str = f"{_WS_STATUS}{status}{_RESET}"
# Status code and text in normal color, not dim
status_str = f"{code} {status}"
timing_str = f"{_TIMING}{timing}{_RESET}"
logger.info(f"{' ' * 19} {prefix} {status_str} {timing_str}")
logger.info(f"{' ' * 19} {id_str} {closed_str}{status_str} {timing_str}")
def log_permission_denied(
@@ -244,7 +256,11 @@ class AccessLogMiddleware(BaseHTTPMiddleware):
path = f"{path}?{request.url.query}"
status = response.status_code
line = format_access_log(client, status, method, host, path, duration_ms)
extra = getattr(request.state, "log_extra", "")
line = format_access_log(
client, status, method, host, path, duration_ms, extra=extra
)
logger.info(line)
return response
+7
View File
@@ -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."""
+6 -1
View File
@@ -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