Compare commits

..
8 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
LeoVasanko e9b6bc7a3d Implement migration for old format listen field in database (re: commit f746085) 2026-02-19 21:26:12 +00:00
LeoVasanko f5545b48f0 Remove dead code. 2026-02-19 21:10:16 +00:00
LeoVasanko c1b2bcf76c Correct alphabetical sort of names in Org Admin panel. Supports Last, First and First Last + variatioons. 2026-02-19 20:54:52 +00:00
LeoVasanko 1806bcab5c A bit more color for light theme; cleaner user badges in admin app. 2026-02-19 20:40:21 +00:00
LeoVasanko be177cbafc Add default value for a(ction) field in change records to keep support for very old versions. 2026-02-19 20:16:16 +00:00
LeoVasanko f5ccc204be Fix adminapp reference after refactoring. 2026-02-19 20:03:58 +00:00
10 changed files with 139 additions and 47 deletions
+19 -11
View File
@@ -36,14 +36,26 @@ const orgPermissions = computed(() => {
}) })
// Get users for a role as sorted array of { uuid, ...user } // Get users for a role as sorted array of { uuid, ...user }
function getNormalizedName(name) {
let cleaned = name.replace(/\([^)]*\)/g, '').trim();
if (cleaned.includes(',')) {
return cleaned.toLowerCase();
} else {
const parts = cleaned.split(/\s+/);
const last = parts.pop();
const first = parts.join(' ');
return `${last}, ${first}`.toLowerCase();
}
}
function roleUsers(roleUuid) { function roleUsers(roleUuid) {
return Object.entries(props.selectedOrg.users) return Object.entries(props.selectedOrg.users)
.filter(([_, u]) => u.role === roleUuid) .filter(([_, u]) => u.role === roleUuid)
.map(([uuid, u]) => ({ uuid, ...u })) .map(([uuid, u]) => ({ uuid, ...u }))
.sort((a, b) => { .sort((a, b) => {
const nameA = a.display_name.toLowerCase() const normA = getNormalizedName(a.display_name);
const nameB = b.display_name.toLowerCase() const normB = getNormalizedName(b.display_name);
return nameA.localeCompare(nameB) return normA.localeCompare(normB);
}) })
} }
@@ -59,10 +71,6 @@ function onUserChange(evt, targetRoleUuid) {
} }
} }
function permissionDisplayName(scope) {
return props.permissions.find(p => p.scope === scope)?.display_name || scope
}
function toggleRolePermission(role, pid, checked) { function toggleRolePermission(role, pid, checked) {
emit('toggleRolePermission', role, pid, checked) emit('toggleRolePermission', role, pid, checked)
} }
@@ -389,7 +397,7 @@ defineExpose({ focusFirstElement })
:title="u.uuid" :title="u.uuid"
> >
<span class="name">{{ u.display_name }}</span> <span class="name">{{ u.display_name }}</span>
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</span> <span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—' }}</span>
</li> </li>
</template> </template>
</draggable> </draggable>
@@ -409,7 +417,7 @@ defineExpose({ focusFirstElement })
.perm-matrix-grid .role-head { display: flex; align-items: flex-end; justify-content: center; } .perm-matrix-grid .role-head { display: flex; align-items: flex-end; justify-content: center; }
.perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; } .perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
.perm-matrix-grid .add-role-head { cursor: pointer; } .perm-matrix-grid .add-role-head { cursor: pointer; }
.roles-grid { display: flex; flex-wrap: wrap; gap: var(--space-lg); margin-top: var(--space-lg); justify-content: flex-start; align-items: stretch; } .roles-grid { display: flex; flex-wrap: wrap; gap: 0; margin-top: var(--space-lg); justify-content: flex-start; align-items: stretch; }
.role-column { flex: 0 0 240px; border-radius: var(--radius-md); padding: var(--space-md); display: flex; flex-direction: column; } .role-column { flex: 0 0 240px; border-radius: var(--radius-md); padding: var(--space-md); display: flex; flex-direction: column; }
.role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); } .role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); }
.role-name { display: flex; align-items: center; gap: var(--space-xs); font-size: 1.1rem; color: var(--color-heading); } .role-name { display: flex; align-items: center; gap: var(--space-xs); font-size: 1.1rem; color: var(--color-heading); }
@@ -418,9 +426,9 @@ defineExpose({ focusFirstElement })
.plus-btn:hover { background: rgba(37, 99, 235, 0.18); } .plus-btn:hover { background: rgba(37, 99, 235, 0.18); }
.user-list-wrapper { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 5.5rem; } .user-list-wrapper { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 5.5rem; }
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); flex: 1; } .user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); flex: 1; }
.user-chip { background: var(--color-accent-strong); color: white; border: none; border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; } .user-chip { background: var(--color-accent-strong); color: var(--color-accent-contrast); border: none; border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; }
.user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; } .user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; }
.user-chip .meta { font-size: 0.7rem; color: rgba(255, 255, 255, 0.8); } .user-chip .meta { font-size: 0.7rem; }
.user-chip.sortable-ghost { opacity: 0.5; } .user-chip.sortable-ghost { opacity: 0.5; }
.user-chip.sortable-chosen { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); } .user-chip.sortable-chosen { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
.empty-role { position: absolute; inset: 0; border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; pointer-events: none; } .empty-role { position: absolute; inset: 0; border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; pointer-events: none; }
-7
View File
@@ -15,14 +15,11 @@ const props = defineProps({
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'openServerConfig', 'navigateOut']) const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'openServerConfig', 'navigateOut'])
// Template refs for navigation // Template refs for navigation
const orgSection = ref(null)
const orgActionsRef = ref(null) const orgActionsRef = ref(null)
const orgTableRef = ref(null) const orgTableRef = ref(null)
const permMatrixRef = ref(null) const permMatrixRef = ref(null)
const permActionsRef = ref(null) const permActionsRef = ref(null)
const permTableRef = ref(null) const permTableRef = ref(null)
const oidcActionsRef = ref(null)
const oidcTableRef = ref(null)
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> { const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
const nameCompare = a.org.display_name.localeCompare(b.org.display_name) const nameCompare = a.org.display_name.localeCompare(b.org.display_name)
@@ -62,10 +59,6 @@ const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.s
const isMasterAdmin = computed(() => props.info?.ctx.permissions.includes('auth:admin')) const isMasterAdmin = computed(() => props.info?.ctx.permissions.includes('auth:admin'))
const isOrgAdmin = computed(() => props.info?.ctx.permissions.includes('auth:org:admin')) const isOrgAdmin = computed(() => props.info?.ctx.permissions.includes('auth:org:admin'))
function permissionDisplayName(scope) {
return props.permissions.find(p => p.scope === scope)?.display_name || scope
}
function getRoleNames(org) { function getRoleNames(org) {
// org.roles is dict[UUID, Role] // org.roles is dict[UUID, Role]
return Object.values(org.roles) return Object.values(org.roles)
+3 -3
View File
@@ -9,8 +9,8 @@
--font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif; --font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
--font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace; --font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--color-canvas: white; --color-canvas: white;
--color-surface: white; --color-surface: #def;
--color-surface-subtle: white; --color-surface-subtle: #bcf;
--color-surface-hover: oklab(0.97 -0.01 -0.02); --color-surface-hover: oklab(0.97 -0.01 -0.02);
--color-dialog: oklab(0.96 -0.01 -0.03); --color-dialog: oklab(0.96 -0.01 -0.03);
--color-border: oklab(0.82 -0.02 -0.06); --color-border: oklab(0.82 -0.02 -0.06);
@@ -21,7 +21,7 @@
--color-link: oklab(0.5 -0.06 -0.17); --color-link: oklab(0.5 -0.06 -0.17);
--color-link-hover: oklab(0.45 -0.06 -0.19); --color-link-hover: oklab(0.45 -0.06 -0.19);
--color-accent: oklab(0.55 -0.06 -0.19); --color-accent: oklab(0.55 -0.06 -0.19);
--color-accent-strong: oklab(0.45 -0.06 -0.19); --color-accent-strong: #46f;
--color-accent-contrast: white; --color-accent-contrast: white;
--color-secondary: oklab(0.55 -0.02 -0.05); --color-secondary: oklab(0.55 -0.02 -0.05);
--color-secondary-strong: oklab(0.45 -0.02 -0.05); --color-secondary-strong: oklab(0.45 -0.02 -0.05);
+6
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")
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:
+21 -20
View File
@@ -34,23 +34,21 @@ _logger = logging.getLogger(__name__)
class ReplayResult(msgspec.Struct, frozen=False): class ReplayResult(msgspec.Struct, frozen=False):
"""Return value of _replay_from_data""" """Return value of _replay_from_data"""
state: dict state: dict = {}
v: int = 0 v: int = 0
ts: datetime | None = None ts: datetime | None = None
snapts: datetime | None = None snapts: datetime | None = None
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."""
resolved_path = str(Path(db_path).resolve()) resolved_path = str(Path(db_path).resolve())
result = ReplayResult(state={}) result = ReplayResult()
# Find and apply the last snapshot # Find and apply the last snapshot
snap, start_offset = SnapshotState.load(data) snap, start_offset = SnapshotState.load(data)
@@ -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,13 +100,23 @@ 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):
ts: datetime = msgspec.field(default_factory=lambda: datetime.now(UTC)) ts: datetime = msgspec.field(default_factory=lambda: datetime.now(UTC))
a: str # action - describes the operation (e.g., "migrate", "login", "create_user") a: str = "" # action (e.g., "migrate", "login", "create_user")
v: int = 0 # schema version after this change v: int = 0 # schema version after this change
u: str | None = None # user UUID who performed the action (None for system) u: str | None = None # user UUID who performed the action (None for system)
diff: dict diff: dict
+7
View File
@@ -45,6 +45,13 @@ def migrate_v4(d: dict, ctx: MigrationCtx) -> None:
d["oidc"] = {"clients": {}, "key": base64.standard_b64encode(secret_key()).decode()} d["oidc"] = {"clients": {}, "key": base64.standard_b64encode(secret_key()).decode()}
def migrate_v5(d: dict, ctx: MigrationCtx) -> None:
"""Convert config.listen from str to list[str] if needed."""
listen = d["config"].get("listen")
if listen and isinstance(listen, str):
d["config"]["listen"] = [listen]
migrations = sorted( migrations = sorted(
[f for n, f in globals().items() if n.startswith("migrate_v")], [f for n, f in globals().items() if n.startswith("migrate_v")],
key=lambda f: int(f.__name__.removeprefix("migrate_v")), key=lambda f: int(f.__name__.removeprefix("migrate_v")),
+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,
+2 -1
View File
@@ -14,6 +14,7 @@ from paskia.db import start_background, stop_background
from paskia.db.background import flush from paskia.db.background import flush
from paskia.db.logging import configure_db_logging from paskia.db.logging import configure_db_logging
from paskia.fastapi import admin, api, auth_host, oid, ws from paskia.fastapi import admin, api, auth_host, oid, ws
from paskia.fastapi.admin.adminapp import adminapp
# Import frontend instance # Import frontend instance
from paskia.fastapi.front import frontend from paskia.fastapi.front import frontend
@@ -162,7 +163,7 @@ async def admin_root_redirect():
@app.get("/admin/", include_in_schema=False) @app.get("/admin/", include_in_schema=False)
@app.get("/auth/admin/", include_in_schema=False) @app.get("/auth/admin/", include_in_schema=False)
async def admin_root(request: Request, auth=AUTH_COOKIE): async def admin_root(request: Request, auth=AUTH_COOKIE):
return await admin.adminapp(request, auth) # Delegated to admin app return await adminapp(request, auth) # Delegated to admin app
@app.get("/auth/examples/", include_in_schema=False) @app.get("/auth/examples/", include_in_schema=False)
+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