Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76f24a755b | ||
|
|
5c452f325a |
@@ -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")
|
||||||
|
try:
|
||||||
config = load_readonly(db_path, rp_id=args.rp_id).config
|
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:
|
||||||
|
|||||||
+18
-17
@@ -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,19 +88,10 @@ 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
|
||||||
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:
|
if not data_dict:
|
||||||
return DB(config=Config(rp_id=rp_id))
|
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))
|
apply_migrations_readonly(data_dict, version, MigrationCtx(rp_id=rp_id))
|
||||||
|
|
||||||
# Decode to msgspec struct
|
# Decode to msgspec struct
|
||||||
db = msgspec.json.decode(msgspec.json.encode(data_dict), type=DB)
|
try:
|
||||||
return db
|
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):
|
class ChangeRecord(msgspec.Struct, omit_defaults=True, kw_only=True):
|
||||||
|
|||||||
+66
-2
@@ -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,
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
Reference in New Issue
Block a user