API to use msgspec structs as well.
This commit is contained in:
@@ -10,6 +10,7 @@ This module provides functionality to:
|
||||
import json
|
||||
from collections.abc import Iterable
|
||||
from importlib.resources import files
|
||||
from uuid import UUID
|
||||
|
||||
__ALL__ = ["AAGUID", "filter"]
|
||||
|
||||
@@ -18,15 +19,15 @@ AAGUID_FILE = files("paskia") / "aaguid" / "combined_aaguid.json"
|
||||
AAGUID: dict[str, dict] = json.loads(AAGUID_FILE.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def filter(aaguids: Iterable[str]) -> dict[str, dict]:
|
||||
def filter(aaguids: Iterable[UUID]) -> dict[str, dict]:
|
||||
"""
|
||||
Get AAGUID information only for the provided set of AAGUIDs.
|
||||
|
||||
Args:
|
||||
aaguids: Set of AAGUID strings that the user has credentials for
|
||||
aaguids: Iterable of AAGUIDs (UUIDs) that the user has credentials for
|
||||
|
||||
Returns:
|
||||
Dictionary mapping AAGUID to authenticator information for only
|
||||
Dictionary mapping AAGUID string to authenticator information for only
|
||||
the AAGUIDs that the user has and that we have data for
|
||||
"""
|
||||
return {aaguid: AAGUID[aaguid] for aaguid in aaguids if aaguid in AAGUID}
|
||||
return {(s := str(a)): AAGUID[s] for a in aaguids if (s := str(a)) in AAGUID}
|
||||
|
||||
+14
-7
@@ -28,7 +28,8 @@ class Permission(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
orgs: dict[UUID, bool] = {} # org_uuid -> True (which orgs can grant this)
|
||||
|
||||
def __post_init__(self):
|
||||
self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized
|
||||
if getattr(self, "uuid", _UUID_UNSET) == _UUID_UNSET:
|
||||
self.uuid: UUID = _UUID_UNSET
|
||||
|
||||
@property
|
||||
def org_set(self) -> set[UUID]:
|
||||
@@ -67,7 +68,8 @@ class Org(msgspec.Struct, dict=True):
|
||||
display_name: str
|
||||
|
||||
def __post_init__(self):
|
||||
self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized
|
||||
if getattr(self, "uuid", _UUID_UNSET) == _UUID_UNSET:
|
||||
self.uuid: UUID = _UUID_UNSET
|
||||
|
||||
@property
|
||||
def roles(self) -> list[Role]:
|
||||
@@ -100,7 +102,8 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
permissions: dict[UUID, bool] = {} # permission_uuid -> True
|
||||
|
||||
def __post_init__(self):
|
||||
self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized
|
||||
if getattr(self, "uuid", _UUID_UNSET) == _UUID_UNSET:
|
||||
self.uuid: UUID = _UUID_UNSET
|
||||
|
||||
@property
|
||||
def permission_set(self) -> set[UUID]:
|
||||
@@ -159,7 +162,8 @@ class User(msgspec.Struct, dict=True):
|
||||
visits: int = 0
|
||||
|
||||
def __post_init__(self):
|
||||
self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized
|
||||
if getattr(self, "uuid", _UUID_UNSET) == _UUID_UNSET:
|
||||
self.uuid: UUID = _UUID_UNSET
|
||||
|
||||
@property
|
||||
def role(self) -> Role:
|
||||
@@ -222,7 +226,8 @@ class Credential(msgspec.Struct, dict=True):
|
||||
last_verified: datetime | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized
|
||||
if getattr(self, "uuid", _UUID_UNSET) == _UUID_UNSET:
|
||||
self.uuid: UUID = _UUID_UNSET
|
||||
|
||||
@property
|
||||
def user(self) -> User:
|
||||
@@ -279,7 +284,8 @@ class Session(msgspec.Struct, dict=True):
|
||||
expiry: datetime
|
||||
|
||||
def __post_init__(self):
|
||||
self.key: str = "" # Convenience field, not serialized
|
||||
if not getattr(self, "key", ""):
|
||||
self.key: str = ""
|
||||
|
||||
@property
|
||||
def user(self) -> User:
|
||||
@@ -338,7 +344,8 @@ class ResetToken(msgspec.Struct, dict=True):
|
||||
token_type: str
|
||||
|
||||
def __post_init__(self):
|
||||
self.key: bytes = b"" # Convenience field, not serialized
|
||||
if not getattr(self, "key", b""):
|
||||
self.key: bytes = b""
|
||||
|
||||
@property
|
||||
def user(self) -> User:
|
||||
|
||||
+50
-131
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
from datetime import UTC
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Body, FastAPI, HTTPException, Query, Request, Response
|
||||
@@ -13,6 +12,7 @@ from paskia.db import Permission as PermDC
|
||||
from paskia.db import Role as RoleDC
|
||||
from paskia.db import User as UserDC
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.globals import passkey
|
||||
from paskia.util import (
|
||||
@@ -20,9 +20,9 @@ from paskia.util import (
|
||||
passphrase,
|
||||
permutil,
|
||||
querysafe,
|
||||
useragent,
|
||||
vitedev,
|
||||
)
|
||||
from paskia.util.apistructs import ApiPermission, ApiSession, format_datetime
|
||||
from paskia.util.hostutil import normalize_host
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
@@ -83,34 +83,34 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
|
||||
# Org admins can only see their own organization
|
||||
orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
|
||||
|
||||
def role_to_dict(r):
|
||||
return {
|
||||
"uuid": str(r.uuid),
|
||||
"org": str(r.org_uuid),
|
||||
"display_name": r.display_name,
|
||||
"permissions": list(r.permissions.keys()),
|
||||
}
|
||||
|
||||
async def org_to_dict(o):
|
||||
def org_to_dict(o):
|
||||
users = db.get_organization_users(o.uuid)
|
||||
return {
|
||||
"uuid": str(o.uuid),
|
||||
"uuid": o.uuid,
|
||||
"display_name": o.display_name,
|
||||
"permissions": {p.uuid for p in o.permissions},
|
||||
"roles": [role_to_dict(r) for r in o.roles],
|
||||
"roles": [
|
||||
{
|
||||
"uuid": r.uuid,
|
||||
"org": r.org_uuid,
|
||||
"display_name": r.display_name,
|
||||
"permissions": list(r.permissions.keys()),
|
||||
}
|
||||
for r in o.roles
|
||||
],
|
||||
"users": [
|
||||
{
|
||||
"uuid": str(u.uuid),
|
||||
"uuid": u.uuid,
|
||||
"display_name": u.display_name,
|
||||
"role": role_name,
|
||||
"visits": u.visits,
|
||||
"last_seen": u.last_seen.isoformat() if u.last_seen else None,
|
||||
"last_seen": u.last_seen,
|
||||
}
|
||||
for (u, role_name) in users
|
||||
],
|
||||
}
|
||||
|
||||
return [await org_to_dict(o) for o in orgs]
|
||||
return MsgspecResponse([org_to_dict(o) for o in orgs])
|
||||
|
||||
|
||||
@app.post("/orgs")
|
||||
@@ -552,11 +552,7 @@ async def admin_create_user_registration_link(
|
||||
url = hostutil.reset_link_url(token)
|
||||
return {
|
||||
"url": url,
|
||||
"expires": (
|
||||
expiry.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
if expiry.tzinfo
|
||||
else expiry.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z")
|
||||
),
|
||||
"expires": format_datetime(expiry),
|
||||
}
|
||||
|
||||
|
||||
@@ -584,108 +580,39 @@ async def admin_get_user_detail(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
user = db.data().users.get(user_uuid)
|
||||
user_creds = user.credentials
|
||||
creds: list[dict] = []
|
||||
aaguids: set[str] = set()
|
||||
for c in user_creds:
|
||||
aaguid_str = str(c.aaguid)
|
||||
aaguids.add(aaguid_str)
|
||||
creds.append(
|
||||
{
|
||||
"credential": str(c.uuid),
|
||||
"aaguid": aaguid_str,
|
||||
"created_at": (
|
||||
c.created_at.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
if c.created_at.tzinfo
|
||||
else c.created_at.replace(tzinfo=UTC)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
),
|
||||
"last_used": (
|
||||
c.last_used.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
if c.last_used and c.last_used.tzinfo
|
||||
else (
|
||||
c.last_used.replace(tzinfo=UTC)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if c.last_used
|
||||
else None
|
||||
)
|
||||
),
|
||||
"last_verified": (
|
||||
c.last_verified.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
if c.last_verified and c.last_verified.tzinfo
|
||||
else (
|
||||
c.last_verified.replace(tzinfo=UTC)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
if c.last_verified
|
||||
else None
|
||||
)
|
||||
normalized_host = hostutil.normalize_host(request.headers.get("host"))
|
||||
|
||||
return MsgspecResponse(
|
||||
{
|
||||
"display_name": user.display_name,
|
||||
"org": {"display_name": user_org.display_name},
|
||||
"role": role_name,
|
||||
"visits": user.visits,
|
||||
"created_at": user.created_at,
|
||||
"last_seen": user.last_seen,
|
||||
"credentials": [
|
||||
{
|
||||
"credential": c.uuid,
|
||||
"aaguid": c.aaguid,
|
||||
"created_at": c.created_at,
|
||||
"last_used": c.last_used,
|
||||
"last_verified": c.last_verified,
|
||||
"sign_count": c.sign_count,
|
||||
}
|
||||
for c in user.credentials
|
||||
],
|
||||
"aaguid_info": aaguid_mod.filter(c.aaguid for c in user.credentials),
|
||||
"sessions": [
|
||||
ApiSession.from_db(
|
||||
s,
|
||||
current_key=auth,
|
||||
normalized_host=normalized_host,
|
||||
expires_delta=EXPIRES,
|
||||
)
|
||||
if c.last_verified
|
||||
else None,
|
||||
"sign_count": c.sign_count,
|
||||
}
|
||||
)
|
||||
|
||||
aaguid_info = aaguid_mod.filter(aaguids)
|
||||
|
||||
# Get sessions for the user
|
||||
normalized_request_host = hostutil.normalize_host(request.headers.get("host"))
|
||||
session_records = user.sessions
|
||||
current_session_key = auth
|
||||
sessions_payload: list[dict] = []
|
||||
for entry in session_records:
|
||||
renewed = entry.expiry - EXPIRES
|
||||
sessions_payload.append(
|
||||
{
|
||||
"id": entry.key,
|
||||
"credential": str(entry.credential),
|
||||
"host": entry.host,
|
||||
"ip": entry.ip,
|
||||
"user_agent": useragent.compact_user_agent(entry.user_agent),
|
||||
"last_renewed": (
|
||||
renewed.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
if renewed.tzinfo
|
||||
else renewed.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z")
|
||||
),
|
||||
"is_current": entry.key == current_session_key,
|
||||
"is_current_host": bool(
|
||||
normalized_request_host
|
||||
and entry.host
|
||||
and entry.host == normalized_request_host
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"display_name": user.display_name,
|
||||
"org": {"display_name": user_org.display_name},
|
||||
"role": role_name,
|
||||
"visits": user.visits,
|
||||
"created_at": (
|
||||
user.created_at.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
if user.created_at and user.created_at.tzinfo
|
||||
else (
|
||||
user.created_at.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z")
|
||||
if user.created_at
|
||||
else None
|
||||
)
|
||||
),
|
||||
"last_seen": (
|
||||
user.last_seen.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
if user.last_seen and user.last_seen.tzinfo
|
||||
else (
|
||||
user.last_seen.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z")
|
||||
if user.last_seen
|
||||
else None
|
||||
)
|
||||
),
|
||||
"credentials": creds,
|
||||
"aaguid_info": aaguid_info,
|
||||
"sessions": sessions_payload,
|
||||
}
|
||||
for s in user.sessions
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.patch("/orgs/{org_uuid}/users/{user_uuid}/display-name")
|
||||
@@ -789,14 +716,6 @@ async def admin_delete_user_session(
|
||||
# -------------------- Permissions (global) --------------------
|
||||
|
||||
|
||||
def _perm_to_dict(p):
|
||||
"""Convert Permission to dict, omitting domain if None."""
|
||||
d = {"uuid": str(p.uuid), "scope": p.scope, "display_name": p.display_name}
|
||||
if p.domain is not None:
|
||||
d["domain"] = p.domain
|
||||
return d
|
||||
|
||||
|
||||
def _validate_permission_domain(domain: str | None) -> None:
|
||||
"""Validate that domain is rp_id or a subdomain of it."""
|
||||
if domain is None:
|
||||
@@ -888,7 +807,7 @@ async def admin_list_permissions(request: Request, auth=AUTH_COOKIE):
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
perms = db.data().permissions.values() if master_admin(ctx) else ctx.org.permissions
|
||||
return [_perm_to_dict(p) for p in perms]
|
||||
return MsgspecResponse([ApiPermission.from_db(p) for p in perms])
|
||||
|
||||
|
||||
@app.post("/permissions")
|
||||
|
||||
+15
-10
@@ -20,6 +20,7 @@ from paskia.authsession import (
|
||||
refresh_session_token,
|
||||
)
|
||||
from paskia.fastapi import authz, session, user
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
|
||||
from paskia.globals import passkey as global_passkey
|
||||
from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
|
||||
@@ -105,11 +106,13 @@ async def validate_token(
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
)
|
||||
return {
|
||||
"valid": True,
|
||||
"renewed": renewed,
|
||||
"ctx": userinfo.format_session_context(ctx),
|
||||
}
|
||||
return MsgspecResponse(
|
||||
{
|
||||
"valid": True,
|
||||
"renewed": renewed,
|
||||
"ctx": userinfo.build_session_context(ctx),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/token-info")
|
||||
@@ -235,11 +238,13 @@ async def api_user_info(
|
||||
if not ctx:
|
||||
raise HTTPException(401, "Session expired")
|
||||
|
||||
return await userinfo.format_user_info(
|
||||
user_uuid=ctx.user.uuid,
|
||||
auth=auth,
|
||||
session_record=ctx.session,
|
||||
request_host=request.headers.get("host"),
|
||||
return MsgspecResponse(
|
||||
await userinfo.build_user_info(
|
||||
user_uuid=ctx.user.uuid,
|
||||
auth=auth,
|
||||
session_record=ctx.session,
|
||||
request_host=request.headers.get("host"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
+34
-80
@@ -1,106 +1,60 @@
|
||||
"""User information formatting and retrieval logic."""
|
||||
|
||||
from datetime import UTC
|
||||
|
||||
from paskia import aaguid, db
|
||||
from paskia.authsession import EXPIRES
|
||||
from paskia.db import SessionContext
|
||||
from paskia.util import hostutil, permutil, useragent
|
||||
from paskia.util import hostutil, permutil
|
||||
from paskia.util.apistructs import ApiSession
|
||||
|
||||
|
||||
def _format_datetime(dt):
|
||||
"""Format a datetime object to ISO 8601 string with UTC timezone."""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo:
|
||||
return dt.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
else:
|
||||
return dt.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def format_session_context(ctx: SessionContext) -> dict:
|
||||
"""Format SessionContext for JSON response."""
|
||||
def build_session_context(ctx: SessionContext) -> dict:
|
||||
"""Build session context dict from SessionContext."""
|
||||
return {
|
||||
"user": {
|
||||
"uuid": str(ctx.user.uuid),
|
||||
"display_name": ctx.user.display_name,
|
||||
},
|
||||
"org": {
|
||||
"uuid": str(ctx.org.uuid),
|
||||
"display_name": ctx.org.display_name,
|
||||
},
|
||||
"role": {
|
||||
"uuid": str(ctx.role.uuid),
|
||||
"display_name": ctx.role.display_name,
|
||||
},
|
||||
"user": {"uuid": ctx.user.uuid, "display_name": ctx.user.display_name},
|
||||
"org": {"uuid": ctx.org.uuid, "display_name": ctx.org.display_name},
|
||||
"role": {"uuid": ctx.role.uuid, "display_name": ctx.role.display_name},
|
||||
"permissions": [p.scope for p in ctx.permissions],
|
||||
}
|
||||
|
||||
|
||||
async def format_user_info(
|
||||
async def build_user_info(
|
||||
*,
|
||||
user_uuid,
|
||||
auth: str,
|
||||
session_record,
|
||||
request_host: str | None,
|
||||
) -> dict:
|
||||
"""Format complete user information for authenticated users."""
|
||||
"""Build user info dict for authenticated users."""
|
||||
ctx = await permutil.session_context(auth, request_host)
|
||||
|
||||
# Fetch and format credentials
|
||||
user = db.data().users[user_uuid]
|
||||
user_credentials = user.credentials
|
||||
credentials: list[dict] = []
|
||||
user_aaguids: set[str] = set()
|
||||
normalized_host = hostutil.normalize_host(request_host)
|
||||
|
||||
for c in user_credentials:
|
||||
aaguid_str = str(c.aaguid)
|
||||
user_aaguids.add(aaguid_str)
|
||||
credentials.append(
|
||||
credentials = sorted(user.credentials, key=lambda c: c.created_at)
|
||||
return {
|
||||
"ctx": build_session_context(ctx),
|
||||
"created_at": ctx.user.created_at,
|
||||
"last_seen": ctx.user.last_seen,
|
||||
"visits": ctx.user.visits,
|
||||
"credentials": [
|
||||
{
|
||||
"credential": str(c.uuid),
|
||||
"aaguid": aaguid_str,
|
||||
"created_at": _format_datetime(c.created_at),
|
||||
"last_used": _format_datetime(c.last_used),
|
||||
"last_verified": _format_datetime(c.last_verified),
|
||||
"credential": c.uuid,
|
||||
"aaguid": c.aaguid,
|
||||
"created_at": c.created_at,
|
||||
"last_used": c.last_used,
|
||||
"last_verified": c.last_verified,
|
||||
"sign_count": c.sign_count,
|
||||
"is_current_session": session_record.credential == c.uuid,
|
||||
}
|
||||
)
|
||||
|
||||
credentials.sort(key=lambda cred: cred["created_at"])
|
||||
aaguid_info = aaguid.filter(user_aaguids)
|
||||
|
||||
# Format sessions
|
||||
normalized_request_host = hostutil.normalize_host(request_host)
|
||||
session_records = user.sessions
|
||||
current_session_key = auth
|
||||
sessions_payload: list[dict] = []
|
||||
|
||||
for entry in session_records:
|
||||
sessions_payload.append(
|
||||
{
|
||||
"id": entry.key,
|
||||
"credential": str(entry.credential),
|
||||
"host": entry.host,
|
||||
"ip": entry.ip,
|
||||
"user_agent": useragent.compact_user_agent(entry.user_agent),
|
||||
"last_renewed": _format_datetime(entry.expiry - EXPIRES),
|
||||
"is_current": entry.key == current_session_key,
|
||||
"is_current_host": bool(
|
||||
normalized_request_host
|
||||
and entry.host
|
||||
and entry.host == normalized_request_host
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"ctx": format_session_context(ctx),
|
||||
"created_at": _format_datetime(ctx.user.created_at),
|
||||
"last_seen": _format_datetime(ctx.user.last_seen),
|
||||
"visits": ctx.user.visits,
|
||||
"credentials": credentials,
|
||||
"aaguid_info": aaguid_info,
|
||||
"sessions": sessions_payload,
|
||||
for c in credentials
|
||||
],
|
||||
"aaguid_info": aaguid.filter(c.aaguid for c in credentials),
|
||||
"sessions": [
|
||||
ApiSession.from_db(
|
||||
s,
|
||||
current_key=auth,
|
||||
normalized_host=normalized_host,
|
||||
expires_delta=EXPIRES,
|
||||
)
|
||||
for s in user.sessions
|
||||
],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user