Compare commits

..
2 Commits
Author SHA1 Message Date
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
5 changed files with 105 additions and 22 deletions
+7 -1
View File
@@ -1,11 +1,13 @@
import argparse import argparse
import logging import logging
import os import os
import sys
import msgspec import msgspec
from fastapi_vue import server from fastapi_vue import server
from fastapi_vue.hostutil import parse_endpoints from fastapi_vue.hostutil import parse_endpoints
from paskia._version import __version__
from paskia.db.jsonl import load_readonly from paskia.db.jsonl import load_readonly
from paskia.util import startupbox from paskia.util import startupbox
from paskia.util.hostutil import ( from paskia.util.hostutil import (
@@ -74,7 +76,11 @@ def main():
# Load stored config (read-only, no writes, no global state) # Load stored config (read-only, no writes, no global state)
db_path = os.environ.get("PASKIA_DB", f"{args.rp_id}.paskiadb") db_path = os.environ.get("PASKIA_DB", f"{args.rp_id}.paskiadb")
config = load_readonly(db_path, rp_id=args.rp_id).config 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 # Override stored config with CLI args, or clear with empty string
if args.rp_name is not None: if args.rp_name is not None:
+19 -18
View File
@@ -41,11 +41,9 @@ class ReplayResult(msgspec.Struct, frozen=False):
changes: int = 0 changes: int = 0
class DatabaseError(Exception): class DatabaseError(ValueError):
"""Exception raised for database loading errors.""" """Exception raised for database loading errors."""
pass
def _replay_from_data(data: bytes, db_path: str) -> ReplayResult: def _replay_from_data(data: bytes, db_path: str) -> ReplayResult:
"""Replay database state from file data, using the last snapshot if available.""" """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 # Replay change records after the snapshot
lines = data[start_offset:].split(b"\n") 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() line = raw.strip()
if not line: if not line:
continue continue
try: try:
change = msgspec.json.decode(line, type=ChangeRecord) change = msgspec.json.decode(line, type=ChangeRecord)
except msgspec.DecodeError as e: 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.state = jsondiff.patch(result.state, change.diff, marshal=True)
result.v = change.v result.v = change.v
result.ts = change.ts result.ts = change.ts
@@ -88,30 +88,31 @@ def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB:
return DB(config=Config(rp_id=rp_id)) return DB(config=Config(rp_id=rp_id))
try: try:
with open(path, "rb") as f: content = path.read_bytes()
content = f.read()
r = _replay_from_data(content, str(path.resolve())) r = _replay_from_data(content, str(path.resolve()))
data_dict = r.state data_dict = r.state
version = r.v version = r.v
if not data_dict:
return DB(config=Config(rp_id=rp_id))
# Apply migrations in-memory (no persistence)
apply_migrations_readonly(data_dict, version, MigrationCtx(rp_id=rp_id))
# Decode to msgspec struct
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: except OSError as e:
_logger.exception("Failed to load database") _logger.exception("Failed to load database")
raise SystemExit(f"{e}") raise SystemExit(f"{e}")
except (ValueError, msgspec.DecodeError, DatabaseError) as e: except (ValueError, msgspec.DecodeError) as e:
raise SystemExit(f"{e}") raise SystemExit(f"{e}")
except Exception as e: except Exception as e:
_logger.exception("Unexpected error loading database") _logger.exception("Unexpected error loading database")
raise SystemExit(f"{e}") raise SystemExit(f"{e}")
if not data_dict:
return DB(config=Config(rp_id=rp_id))
# Apply migrations in-memory (no persistence)
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
class ChangeRecord(msgspec.Struct, omit_defaults=True, kw_only=True): class ChangeRecord(msgspec.Struct, omit_defaults=True, kw_only=True):
ts: datetime = msgspec.field(default_factory=lambda: datetime.now(UTC)) ts: datetime = msgspec.field(default_factory=lambda: datetime.now(UTC))
+66 -2
View File
@@ -1,6 +1,7 @@
import logging import logging
from contextlib import suppress from contextlib import suppress
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from uuid import UUID
from fastapi import ( from fastapi import (
Depends, Depends,
@@ -20,8 +21,17 @@ from paskia.fastapi import authz, session, user
from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
from paskia.globals import passkey as global_passkey from paskia.globals import passkey as global_passkey
from paskia.util import hostutil, htmlutil, passphrase, userinfo from paskia.util import hostutil, htmlutil, passphrase, permutil, userinfo
from paskia.util.apistructs import ApiSettings, ApiTokenInfo, ApiValidateResponse from paskia.util.apistructs import (
ApiCheckUserResponse,
ApiOrgContext,
ApiRoleContext,
ApiSessionContext,
ApiSettings,
ApiTokenInfo,
ApiUserContext,
ApiValidateResponse,
)
bearer_auth = HTTPBearer(auto_error=False) 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") @app.get("/forward")
async def forward_authentication( async def forward_authentication(
request: Request, request: Request,
+7
View File
@@ -233,6 +233,13 @@ class ApiValidateResponse(msgspec.Struct):
ctx: ApiSessionContext ctx: ApiSessionContext
class ApiCheckUserResponse(msgspec.Struct):
"""Response struct for check-user endpoint."""
valid: bool
ctx: ApiSessionContext
class ApiAdminInfo(msgspec.Struct, kw_only=True): class ApiAdminInfo(msgspec.Struct, kw_only=True):
"""Combined admin info response.""" """Combined admin info response."""
+6 -1
View File
@@ -6,7 +6,7 @@ from fnmatch import fnmatchcase
from paskia.authsession import session_ctx from paskia.authsession import session_ctx
from paskia.util.hostutil import normalize_host 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]): 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 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): async def session_context(auth: str | None, host: str | None = None):
if not auth: if not auth:
return None return None