Large refactoring for better JSONL context. Switched back the urlsafe for session tokens that need to be passed in URLs. Other minor fixes.

This commit is contained in:
2026-01-24 00:40:32 +00:00
parent 57a9c60557
commit cebef8adfc
10 changed files with 262 additions and 163 deletions
+7 -5
View File
@@ -20,12 +20,13 @@ _logger = logging.getLogger(__name__)
DB_PATH_DEFAULT = "paskia.jsonl"
class _ChangeRecord(msgspec.Struct):
class _ChangeRecord(msgspec.Struct, omit_defaults=True):
"""A single change record in the JSONL file."""
ts: datetime
actor: str
diff: dict
a: str # action - describes the operation (e.g., "migrate", "login", "create_user")
u: str | None = None # user UUID who performed the action (None for system)
diff: dict = {}
# msgspec encoder for change records
@@ -83,11 +84,12 @@ def compute_diff(previous: dict, current: dict) -> dict | None:
return diff if diff else None
def create_change_record(actor: str, diff: dict) -> _ChangeRecord:
def create_change_record(action: str, diff: dict, user: str | None = None) -> _ChangeRecord:
"""Create a change record for persistence."""
return _ChangeRecord(
ts=datetime.now(timezone.utc),
actor=actor,
a=action,
u=user,
diff=diff,
)
+206 -98
View File
@@ -73,7 +73,8 @@ class DB:
)
self._previous_builtins: dict[str, Any] = {}
self._pending_changes: deque[_ChangeRecord] = deque()
self._current_actor: str = "system"
self._current_action: str = "system"
self._current_user: str | None = None
async def load(self, db_path: str | None = None) -> None:
"""Load data from JSONL change log.
@@ -98,31 +99,58 @@ class DB:
diff = compute_diff(self._previous_builtins, current)
if diff:
self._pending_changes.append(
create_change_record(self._current_actor, diff)
create_change_record(self._current_action, diff, self._current_user)
)
self._previous_builtins = current
# Log the change with user display name if available
actor_display = self._current_actor
if self._current_actor not in ("system", "expiry", "migrate"):
user_display = None
if self._current_user:
try:
user_uuid = UUID(self._current_actor)
user_uuid = UUID(self._current_user)
if user_uuid in self._data.users:
actor_display = self._data.users[user_uuid].display_name
user_display = self._data.users[user_uuid].display_name
except (ValueError, KeyError):
pass
user_display = self._current_user
import json
_logger.info("DB change by %s: %s", actor_display, json.dumps(diff, default=str))
if user_display:
_logger.info(
"DB %s by %s: %s",
self._current_action,
user_display,
json.dumps(diff, default=str),
)
else:
_logger.info(
"DB %s: %s", self._current_action, json.dumps(diff, default=str)
)
@contextmanager
def transaction(self, actor: str = "system"):
"""Wrap writes in transaction. Queues change on successful exit."""
old_actor = self._current_actor
self._current_actor = actor
def transaction(
self,
action: str,
ctx: SessionContext | None = None,
*,
user: str | None = None,
):
"""Wrap writes in transaction. Queues change on successful exit.
Args:
action: Describes the operation (e.g., "Created user", "Login")
ctx: Session context of user performing the action (None for system operations)
user: User UUID string (alternative to ctx when full context unavailable)
"""
old_action = self._current_action
old_user = self._current_user
self._current_action = action
# Prefer ctx.user.uuid if ctx provided, otherwise use user param
self._current_user = str(ctx.user.uuid) if ctx else user
try:
yield
self._queue_change()
finally:
self._current_actor = old_actor
self._current_action = old_action
self._current_user = old_user
# Global instance, always available (empty until init() loads data)
@@ -176,7 +204,9 @@ def build_role(uuid: UUID) -> Role:
def build_org(uuid: UUID, include_roles: bool = False) -> Org:
o = _db._data.orgs[uuid]
perm_uuids = [str(pid) for pid, p in _db._data.permissions.items() if uuid in p.orgs]
perm_uuids = [
str(pid) for pid, p in _db._data.permissions.items() if uuid in p.orgs
]
org = Org(uuid=uuid, display_name=o.display_name, permissions=perm_uuids)
if include_roles:
org.roles = [
@@ -495,11 +525,13 @@ def get_session_context(
# -------------------------------------------------------------------------
def create_permission(perm: Permission, actor: str = "system") -> None:
def create_permission(
perm: Permission, *, ctx: SessionContext | None = None
) -> None:
"""Create a new permission."""
if perm.uuid in _db._data.permissions:
raise ValueError(f"Permission {perm.uuid} already exists")
with _db.transaction(actor):
with _db.transaction("Created permission", ctx):
_db._data.permissions[perm.uuid] = _PermissionData(
scope=perm.scope,
display_name=perm.display_name,
@@ -508,11 +540,13 @@ def create_permission(perm: Permission, actor: str = "system") -> None:
)
def update_permission(perm: Permission, actor: str = "system") -> None:
def update_permission(
perm: Permission, *, ctx: SessionContext | None = None
) -> None:
"""Update a permission's scope, display_name, and domain."""
if perm.uuid not in _db._data.permissions:
raise ValueError(f"Permission {perm.uuid} not found")
with _db.transaction(actor):
with _db.transaction("Updated permission", ctx):
_db._data.permissions[perm.uuid].scope = perm.scope
_db._data.permissions[perm.uuid].display_name = perm.display_name
_db._data.permissions[perm.uuid].domain = perm.domain
@@ -523,7 +557,8 @@ def rename_permission(
new_scope: str,
display_name: str,
domain: str | None = None,
actor: str = "system",
*,
ctx: SessionContext | None = None,
) -> None:
"""Rename a permission's scope. The UUID remains the same.
@@ -539,40 +574,46 @@ def rename_permission(
if not key:
raise ValueError(f"Permission with scope '{old_scope}' not found")
with _db.transaction(actor):
with _db.transaction("Renamed permission", ctx):
# Update the permission
_db._data.permissions[key].scope = new_scope
_db._data.permissions[key].display_name = display_name
_db._data.permissions[key].domain = domain
def delete_permission(uuid: str | UUID, actor: str = "system") -> None:
def delete_permission(
uuid: str | UUID, *, ctx: SessionContext | None = None
) -> None:
"""Delete a permission and remove it from all roles."""
if isinstance(uuid, str):
uuid = UUID(uuid)
if uuid not in _db._data.permissions:
raise ValueError(f"Permission {uuid} not found")
with _db.transaction(actor):
with _db.transaction("Deleted permission", ctx):
# Remove this permission from all roles
for role in _db._data.roles.values():
role.permissions.pop(uuid, None)
del _db._data.permissions[uuid]
def create_organization(org: Org, actor: str = "system") -> None:
def create_organization(
org: Org, *, ctx: SessionContext | None = None
) -> None:
"""Create a new organization with an Administration role.
Automatically creates an 'Administration' role with auth:org:admin permission.
"""
if org.uuid in _db._data.orgs:
raise ValueError(f"Organization {org.uuid} already exists")
with _db.transaction(actor):
with _db.transaction("Created organization", ctx):
_db._data.orgs[org.uuid] = _OrgData(
display_name=org.display_name, created_at=datetime.now(timezone.utc)
)
# Grant listed permissions to this org (org.permissions contains UUIDs now)
for perm_uuid_str in org.permissions:
perm_uuid = UUID(perm_uuid_str) if isinstance(perm_uuid_str, str) else perm_uuid_str
perm_uuid = (
UUID(perm_uuid_str) if isinstance(perm_uuid_str, str) else perm_uuid_str
)
if perm_uuid in _db._data.permissions:
_db._data.permissions[perm_uuid].orgs[org.uuid] = True
# Create Administration role with org admin permission
@@ -594,24 +635,29 @@ def create_organization(org: Org, actor: str = "system") -> None:
def update_organization_name(
uuid: str | UUID, display_name: str, actor: str = "system"
uuid: str | UUID,
display_name: str,
*,
ctx: SessionContext | None = None,
) -> None:
"""Update organization display name."""
if isinstance(uuid, str):
uuid = UUID(uuid)
if uuid not in _db._data.orgs:
raise ValueError(f"Organization {uuid} not found")
with _db.transaction(actor):
with _db.transaction("Renamed organization", ctx):
_db._data.orgs[uuid].display_name = display_name
def delete_organization(uuid: str | UUID, actor: str = "system") -> None:
def delete_organization(
uuid: str | UUID, *, ctx: SessionContext | None = None
) -> None:
"""Delete organization and all its roles/users."""
if isinstance(uuid, str):
uuid = UUID(uuid)
if uuid not in _db._data.orgs:
raise ValueError(f"Organization {uuid} not found")
with _db.transaction(actor):
with _db.transaction("Deleted organization", ctx):
# Remove org from all permissions
for p in _db._data.permissions.values():
p.orgs.pop(uuid, None)
@@ -627,7 +673,10 @@ def delete_organization(uuid: str | UUID, actor: str = "system") -> None:
def add_permission_to_organization(
org_uuid: str | UUID, permission_id: str | UUID, actor: str = "system"
org_uuid: str | UUID,
permission_id: str | UUID,
*,
ctx: SessionContext | None = None,
) -> None:
"""Grant a permission to an organization by UUID."""
if isinstance(org_uuid, str):
@@ -653,12 +702,15 @@ def add_permission_to_organization(
if permission_uuid not in _db._data.permissions:
raise ValueError(f"Permission {permission_uuid} not found")
with _db.transaction(actor):
with _db.transaction("Granted org permission", ctx):
_db._data.permissions[permission_uuid].orgs[org_uuid] = True
def remove_permission_from_organization(
org_uuid: str | UUID, permission_id: str | UUID, actor: str = "system"
org_uuid: str | UUID,
permission_id: str | UUID,
*,
ctx: SessionContext | None = None,
) -> None:
"""Remove a permission from an organization by UUID."""
if isinstance(org_uuid, str):
@@ -684,17 +736,19 @@ def remove_permission_from_organization(
if permission_uuid not in _db._data.permissions:
return # Permission not found, silently return
with _db.transaction(actor):
with _db.transaction("Revoked org permission", ctx):
_db._data.permissions[permission_uuid].orgs.pop(org_uuid, None)
def create_role(role: Role, actor: str = "system") -> None:
def create_role(
role: Role, *, ctx: SessionContext | None = None
) -> None:
"""Create a new role."""
if role.uuid in _db._data.roles:
raise ValueError(f"Role {role.uuid} already exists")
if role.org_uuid not in _db._data.orgs:
raise ValueError(f"Organization {role.org_uuid} not found")
with _db.transaction(actor):
with _db.transaction("Created role", ctx):
_db._data.roles[role.uuid] = _RoleData(
org=role.org_uuid,
display_name=role.display_name,
@@ -703,19 +757,25 @@ def create_role(role: Role, actor: str = "system") -> None:
def update_role_name(
uuid: str | UUID, display_name: str, actor: str = "system"
uuid: str | UUID,
display_name: str,
*,
ctx: SessionContext | None = None,
) -> None:
"""Update role display name."""
if isinstance(uuid, str):
uuid = UUID(uuid)
if uuid not in _db._data.roles:
raise ValueError(f"Role {uuid} not found")
with _db.transaction(actor):
with _db.transaction("Renamed role", ctx):
_db._data.roles[uuid].display_name = display_name
def add_permission_to_role(
role_uuid: str | UUID, permission_uuid: str | UUID, actor: str = "system"
role_uuid: str | UUID,
permission_uuid: str | UUID,
*,
ctx: SessionContext | None = None,
) -> None:
"""Add permission to role by UUID."""
if isinstance(role_uuid, str):
@@ -726,12 +786,15 @@ def add_permission_to_role(
raise ValueError(f"Role {role_uuid} not found")
if permission_uuid not in _db._data.permissions:
raise ValueError(f"Permission {permission_uuid} not found")
with _db.transaction(actor):
with _db.transaction("Granted role permission", ctx):
_db._data.roles[role_uuid].permissions[permission_uuid] = True
def remove_permission_from_role(
role_uuid: str | UUID, permission_uuid: str | UUID, actor: str = "system"
role_uuid: str | UUID,
permission_uuid: str | UUID,
*,
ctx: SessionContext | None = None,
) -> None:
"""Remove permission from role by UUID."""
if isinstance(role_uuid, str):
@@ -740,11 +803,13 @@ def remove_permission_from_role(
permission_uuid = UUID(permission_uuid)
if role_uuid not in _db._data.roles:
raise ValueError(f"Role {role_uuid} not found")
with _db.transaction(actor):
with _db.transaction("Revoked role permission", ctx):
_db._data.roles[role_uuid].permissions.pop(permission_uuid, None)
def delete_role(uuid: str | UUID, actor: str = "system") -> None:
def delete_role(
uuid: str | UUID, *, ctx: SessionContext | None = None
) -> None:
"""Delete a role."""
if isinstance(uuid, str):
uuid = UUID(uuid)
@@ -753,40 +818,54 @@ def delete_role(uuid: str | UUID, actor: str = "system") -> None:
# Check no users have this role
if any(u.role == uuid for u in _db._data.users.values()):
raise ValueError(f"Cannot delete role {uuid}: users still assigned")
with _db.transaction(actor):
with _db.transaction("Deleted role", ctx):
del _db._data.roles[uuid]
def create_user(user: User, actor: str = "system") -> None:
def create_user(
new_user: User, *, ctx: SessionContext | None = None
) -> None:
"""Create a new user."""
if user.uuid in _db._data.users:
raise ValueError(f"User {user.uuid} already exists")
if user.role_uuid not in _db._data.roles:
raise ValueError(f"Role {user.role_uuid} not found")
with _db.transaction(actor):
_db._data.users[user.uuid] = _UserData(
display_name=user.display_name,
role=user.role_uuid,
created_at=user.created_at or datetime.now(timezone.utc),
last_seen=user.last_seen,
visits=user.visits,
if new_user.uuid in _db._data.users:
raise ValueError(f"User {new_user.uuid} already exists")
if new_user.role_uuid not in _db._data.roles:
raise ValueError(f"Role {new_user.role_uuid} not found")
with _db.transaction("Created user", ctx):
_db._data.users[new_user.uuid] = _UserData(
display_name=new_user.display_name,
role=new_user.role_uuid,
created_at=new_user.created_at or datetime.now(timezone.utc),
last_seen=new_user.last_seen,
visits=new_user.visits,
)
def update_user_display_name(
uuid: str | UUID, display_name: str, actor: str = "system"
uuid: str | UUID,
display_name: str,
*,
ctx: SessionContext | None = None,
) -> None:
"""Update user display name."""
"""Update user display name.
For self-service (user updating own name), ctx can be None and user is derived from uuid.
For admin operations, ctx should be provided.
"""
if isinstance(uuid, str):
uuid = UUID(uuid)
if uuid not in _db._data.users:
raise ValueError(f"User {uuid} not found")
with _db.transaction(actor):
# For self-service, derive user from the uuid being modified
user_str = str(uuid) if not ctx else None
with _db.transaction("Renamed user", ctx, user=user_str):
_db._data.users[uuid].display_name = display_name
def update_user_role(
uuid: str | UUID, role_uuid: str | UUID, actor: str = "system"
uuid: str | UUID,
role_uuid: str | UUID,
*,
ctx: SessionContext | None = None,
) -> None:
"""Update user's role."""
if isinstance(uuid, str):
@@ -797,12 +876,15 @@ def update_user_role(
raise ValueError(f"User {uuid} not found")
if role_uuid not in _db._data.roles:
raise ValueError(f"Role {role_uuid} not found")
with _db.transaction(actor):
with _db.transaction("Changed user role", ctx):
_db._data.users[uuid].role = role_uuid
def update_user_role_in_organization(
user_uuid: str | UUID, role_name: str, actor: str = "system"
user_uuid: str | UUID,
role_name: str,
*,
ctx: SessionContext | None = None,
) -> None:
"""Update user's role by role name within their current organization."""
if isinstance(user_uuid, str):
@@ -821,17 +903,19 @@ def update_user_role_in_organization(
break
if new_role_uuid is None:
raise ValueError(f"Role '{role_name}' not found in organization")
with _db.transaction(actor):
with _db.transaction("Changed user role", ctx):
_db._data.users[user_uuid].role = new_role_uuid
def delete_user(uuid: str | UUID, actor: str = "system") -> None:
def delete_user(
uuid: str | UUID, *, ctx: SessionContext | None = None
) -> None:
"""Delete user and their credentials/sessions."""
if isinstance(uuid, str):
uuid = UUID(uuid)
if uuid not in _db._data.users:
raise ValueError(f"User {uuid} not found")
with _db.transaction(actor):
with _db.transaction("Deleted user", ctx):
# Delete credentials
cred_uuids = [cid for cid, c in _db._data.credentials.items() if c.user == uuid]
for cid in cred_uuids:
@@ -847,13 +931,15 @@ def delete_user(uuid: str | UUID, actor: str = "system") -> None:
del _db._data.users[uuid]
def create_credential(cred: Credential, actor: str = "system") -> None:
def create_credential(
cred: Credential, *, ctx: SessionContext | None = None
) -> None:
"""Create a new credential."""
if cred.uuid in _db._data.credentials:
raise ValueError(f"Credential {cred.uuid} already exists")
if cred.user_uuid not in _db._data.users:
raise ValueError(f"User {cred.user_uuid} not found")
with _db.transaction(actor):
with _db.transaction("Added credential", ctx):
_db._data.credentials[cred.uuid] = _CredentialData(
credential_id=cred.credential_id,
user=cred.user_uuid,
@@ -870,21 +956,25 @@ def update_credential_sign_count(
uuid: str | UUID,
sign_count: int,
last_used: datetime | None = None,
actor: str = "system",
*,
ctx: SessionContext | None = None,
) -> None:
"""Update credential sign count and last_used."""
if isinstance(uuid, str):
uuid = UUID(uuid)
if uuid not in _db._data.credentials:
raise ValueError(f"Credential {uuid} not found")
with _db.transaction(actor):
with _db.transaction("Updated credential", ctx):
_db._data.credentials[uuid].sign_count = sign_count
if last_used:
_db._data.credentials[uuid].last_used = last_used
def delete_credential(
uuid: str | UUID, user_uuid: str | UUID | None = None, actor: str = "system"
uuid: str | UUID,
user_uuid: str | UUID | None = None,
*,
ctx: SessionContext | None = None,
) -> None:
"""Delete a credential.
@@ -900,7 +990,7 @@ def delete_credential(
cred_user = _db._data.credentials[uuid].user
if cred_user != user_uuid:
raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}")
with _db.transaction(actor):
with _db.transaction("Deleted credential", ctx):
del _db._data.credentials[uuid]
@@ -912,7 +1002,8 @@ def create_session(
ip: str | None,
user_agent: str | None,
expiry: datetime,
actor: str = "system",
*,
ctx: SessionContext | None = None,
) -> None:
"""Create a new session."""
if key in _db._data.sessions:
@@ -921,7 +1012,7 @@ def create_session(
raise ValueError(f"User {user_uuid} not found")
if credential_uuid not in _db._data.credentials:
raise ValueError(f"Credential {credential_uuid} not found")
with _db.transaction(actor):
with _db.transaction("Created session", ctx):
_db._data.sessions[key] = _SessionData(
user=user_uuid,
credential=credential_uuid,
@@ -938,12 +1029,13 @@ def update_session(
ip: str | None = None,
user_agent: str | None = None,
expiry: datetime | None = None,
actor: str = "system",
*,
ctx: SessionContext | None = None,
) -> None:
"""Update session metadata."""
if key not in _db._data.sessions:
raise ValueError("Session not found")
with _db.transaction(actor):
with _db.transaction("Updated session", ctx):
s = _db._data.sessions[key]
if host is not None:
s.host = host
@@ -955,24 +1047,36 @@ def update_session(
s.expiry = expiry
def set_session_host(key: str, host: str, actor: str = "system") -> None:
def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None) -> None:
"""Set the host for a session (first-time binding)."""
update_session(key, host=host, actor=actor)
update_session(key, host=host, ctx=ctx)
def delete_session(key: str, actor: str = "system") -> None:
"""Delete a session."""
def delete_session(key: str, *, ctx: SessionContext | None = None) -> None:
"""Delete a session.
For logout (user deleting own session), ctx can be None and user is derived from session.
For admin operations, ctx should be provided.
"""
if key not in _db._data.sessions:
raise ValueError("Session not found")
with _db.transaction(actor):
# For self-service logout, derive user from the session being deleted
user_str = str(_db._data.sessions[key].user) if not ctx else None
with _db.transaction("Deleted session", ctx, user=user_str):
del _db._data.sessions[key]
def delete_sessions_for_user(user_uuid: str | UUID, actor: str = "system") -> None:
"""Delete all sessions for a user."""
def delete_sessions_for_user(user_uuid: str | UUID, *, ctx: SessionContext | None = None) -> None:
"""Delete all sessions for a user.
For logout-all (user deleting own sessions), ctx can be None and user is derived from user_uuid.
For admin operations, ctx should be provided.
"""
if isinstance(user_uuid, str):
user_uuid = UUID(user_uuid)
with _db.transaction(actor):
# For self-service, derive user from the user_uuid param
user_str = str(user_uuid) if not ctx else None
with _db.transaction("Deleted user sessions", ctx, user=user_str):
keys = [k for k, s in _db._data.sessions.items() if s.user == user_uuid]
for k in keys:
del _db._data.sessions[k]
@@ -983,25 +1087,32 @@ def create_reset_token(
user_uuid: UUID,
expiry: datetime,
token_type: str,
actor: str = "system",
*,
ctx: SessionContext | None = None,
) -> None:
"""Create a reset token from a passphrase."""
"""Create a reset token from a passphrase.
For self-service (user creating own recovery link), ctx can be None and user is derived from user_uuid.
For admin operations, ctx should be provided.
"""
key = _reset_key(passphrase)
if key in _db._data.reset_tokens:
raise ValueError("Reset token already exists")
if user_uuid not in _db._data.users:
raise ValueError(f"User {user_uuid} not found")
with _db.transaction(actor):
# For self-service, derive user from the user_uuid param
user_str = str(user_uuid) if not ctx else None
with _db.transaction("Created reset token", ctx, user=user_str):
_db._data.reset_tokens[key] = _ResetTokenData(
user=user_uuid, expiry=expiry, token_type=token_type
)
def delete_reset_token(key: bytes, actor: str = "system") -> None:
def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None:
"""Delete a reset token."""
if key not in _db._data.reset_tokens:
raise ValueError("Reset token not found")
with _db.transaction(actor):
with _db.transaction("Deleted reset token", ctx):
del _db._data.reset_tokens[key]
@@ -1010,11 +1121,11 @@ def delete_reset_token(key: bytes, actor: str = "system") -> None:
# -------------------------------------------------------------------------
def cleanup_expired(actor: str = "system") -> int:
def cleanup_expired() -> int:
"""Remove expired sessions and reset tokens. Returns count removed."""
now = datetime.now(timezone.utc)
count = 0
with _db.transaction(actor):
with _db.transaction("Cleaned up expired"):
expired_sessions = [k for k, s in _db._data.sessions.items() if s.expiry < now]
for k in expired_sessions:
del _db._data.sessions[k]
@@ -1034,10 +1145,8 @@ def cleanup_expired(actor: str = "system") -> int:
def _create_token() -> str:
"""Generate a 16-character session token using standard base64."""
import base64
return base64.b64encode(secrets.token_bytes(12)).decode()
"""Generate a 16-character URL-safe session token."""
return secrets.token_urlsafe(12)
def login(
@@ -1056,7 +1165,6 @@ def login(
Creates:
- new session
Actor is set to the user UUID being logged in.
Returns the generated session token.
"""
if isinstance(user_uuid, str):
@@ -1068,8 +1176,8 @@ def login(
raise ValueError(f"Credential {credential.uuid} not found")
session_key = _create_token()
actor = str(user_uuid)
with _db.transaction(actor):
user_str = str(user_uuid)
with _db.transaction("User logged in", user=user_str):
# Update user
_db._data.users[user_uuid].last_seen = now
_db._data.users[user_uuid].visits += 1
@@ -1096,7 +1204,6 @@ def create_credential_session(
user_agent: str | None,
display_name: str | None = None,
reset_key: bytes | None = None,
actor: str = "system",
) -> str:
"""Create a credential and session together, optionally consuming a reset token.
@@ -1117,7 +1224,8 @@ def create_credential_session(
if user_uuid not in _db._data.users:
raise ValueError(f"User {user_uuid} not found")
with _db.transaction(actor):
user_str = str(user_uuid)
with _db.transaction("Registered credential", user=user_str):
# Update display name if provided
if display_name:
_db._data.users[user_uuid].display_name = display_name
+21 -22
View File
@@ -135,12 +135,11 @@ async def admin_create_org(
)
from ..db import Org as OrgDC # local import to avoid cycles
actor = str(ctx.user.uuid)
org_uuid = uuid4()
display_name = payload.get("display_name") or "New Organization"
permissions = payload.get("permissions") or []
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
db.create_organization(org, actor=actor)
db.create_organization(org, ctx=ctx)
return {"uuid": str(org_uuid)}
@@ -167,7 +166,7 @@ async def admin_update_org_name(
if not display_name:
raise ValueError("display_name is required")
db.update_organization_name(org_uuid, display_name, actor=str(ctx.user.uuid))
db.update_organization_name(org_uuid, display_name, ctx=ctx)
return {"status": "ok"}
@@ -199,9 +198,9 @@ async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
or perm_scope_lower.endswith(f":{org_perm_pattern}")
or perm_scope_lower == org_perm_pattern
):
db.delete_permission(str(perm.uuid), actor=str(ctx.user.uuid))
db.delete_permission(str(perm.uuid), ctx=ctx)
db.delete_organization(org_uuid, actor=str(ctx.user.uuid))
db.delete_organization(org_uuid, ctx=ctx)
return {"status": "ok"}
@@ -216,7 +215,7 @@ async def admin_add_org_permission(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
db.add_permission_to_organization(
str(org_uuid), permission_id, actor=str(ctx.user.uuid)
str(org_uuid), permission_id, ctx=ctx
)
return {"status": "ok"}
@@ -242,7 +241,7 @@ async def admin_remove_org_permission(
)
db.remove_permission_from_organization(
str(org_uuid), permission_id, actor=str(ctx.user.uuid)
str(org_uuid), permission_id, ctx=ctx
)
return {"status": "ok"}
@@ -284,7 +283,7 @@ async def admin_create_role(
display_name=display_name,
permissions=perms,
)
db.create_role(role, actor=str(ctx.user.uuid))
db.create_role(role, ctx=ctx)
return {"uuid": str(role_uuid)}
@@ -315,7 +314,7 @@ async def admin_update_role_name(
if not display_name:
raise ValueError("display_name is required")
db.update_role_name(role_uuid, display_name, actor=str(ctx.user.uuid))
db.update_role_name(role_uuid, display_name, ctx=ctx)
return {"status": "ok"}
@@ -351,7 +350,7 @@ async def admin_add_role_permission(
if str(permission_uuid) not in org.permissions:
raise ValueError("Permission not grantable by organization")
db.add_permission_to_role(role_uuid, permission_uuid, actor=str(ctx.user.uuid))
db.add_permission_to_role(role_uuid, permission_uuid, ctx=ctx)
return {"status": "ok"}
@@ -396,7 +395,7 @@ async def admin_remove_role_permission(
if not has_admin:
raise ValueError("Cannot remove your own admin permissions")
db.remove_permission_from_role(role_uuid, permission_uuid, actor=str(ctx.user.uuid))
db.remove_permission_from_role(role_uuid, permission_uuid, ctx=ctx)
return {"status": "ok"}
@@ -426,7 +425,7 @@ async def admin_delete_role(
if ctx.role.uuid == role_uuid:
raise ValueError("Cannot delete your own role")
db.delete_role(role_uuid, actor=str(ctx.user.uuid))
db.delete_role(role_uuid, ctx=ctx)
return {"status": "ok"}
@@ -468,7 +467,7 @@ async def admin_create_user(
visits=0,
created_at=None,
)
db.create_user(user, actor=str(ctx.user.uuid))
db.create_user(user, ctx=ctx)
return {"uuid": str(user_uuid)}
@@ -516,7 +515,7 @@ async def admin_update_user_role(
"Cannot change your own role to one without admin permissions"
)
db.update_user_role_in_organization(user_uuid, new_role, actor=str(ctx.user.uuid))
db.update_user_role_in_organization(user_uuid, new_role, ctx=ctx)
return {"status": "ok"}
@@ -556,7 +555,7 @@ async def admin_create_user_registration_link(
passphrase=token,
expiry=expiry,
token_type=token_type,
actor=str(ctx.user.uuid),
ctx=ctx,
)
url = hostutil.reset_link_url(token)
return {
@@ -739,7 +738,7 @@ async def admin_update_user_display_name(
raise HTTPException(status_code=400, detail="display_name required")
if len(new_name) > 64:
raise HTTPException(status_code=400, detail="display_name too long")
db.update_user_display_name(user_uuid, new_name, actor=str(ctx.user.uuid))
db.update_user_display_name(user_uuid, new_name, ctx=ctx)
return {"status": "ok"}
@@ -768,7 +767,7 @@ async def admin_delete_user_credential(
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
db.delete_credential(credential_uuid, user_uuid, actor=str(ctx.user.uuid))
db.delete_credential(credential_uuid, user_uuid, ctx=ctx)
return {"status": "ok"}
@@ -801,7 +800,7 @@ async def admin_delete_user_session(
if not target_session or target_session.user_uuid != user_uuid:
raise HTTPException(status_code=404, detail="Session not found")
db.delete_session(session_id, actor=str(ctx.user.uuid))
db.delete_session(session_id, ctx=ctx)
# Check if admin terminated their own session
current_terminated = session_id == auth
@@ -954,7 +953,7 @@ async def admin_create_permission(
PermDC(
uuid=uuid7.create(), scope=scope, display_name=display_name, domain=domain
),
actor=str(ctx.user.uuid),
ctx=ctx,
)
return {"status": "ok"}
@@ -1004,7 +1003,7 @@ async def admin_update_permission(
display_name=new_display_name,
domain=domain_value,
),
actor=str(ctx.user.uuid),
ctx=ctx,
)
return {"status": "ok"}
@@ -1051,7 +1050,7 @@ async def admin_rename_permission(
# All current backends support rename_permission
db.rename_permission(
old_scope, new_scope, display_name, domain_value, actor=str(ctx.user.uuid)
old_scope, new_scope, display_name, domain_value, ctx=ctx
)
return {"status": "ok"}
@@ -1083,5 +1082,5 @@ async def admin_delete_permission(
if perm.scope == "auth:admin":
_check_admin_lockout_on_delete(str(perm.uuid), request.headers.get("host"))
db.delete_permission(str(perm.uuid), actor=str(ctx.user.uuid))
db.delete_permission(str(perm.uuid), ctx=ctx)
return {"status": "ok"}
+2 -2
View File
@@ -288,11 +288,11 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
if not auth:
return {"message": "Already logged out"}
try:
s = await get_session(auth, host=request.headers.get("host"))
_s = await get_session(auth, host=request.headers.get("host"))
except ValueError:
return {"message": "Already logged out"}
with suppress(Exception):
db.delete_session(auth, actor=str(s.user_uuid))
db.delete_session(auth)
session.clear_session_cookie(response)
return {"message": "Logged out successfully"}
-1
View File
@@ -351,7 +351,6 @@ async def websocket_remote_auth_permit(ws: WebSocket):
passphrase=token_str,
expiry=expiry,
token_type="device addition",
actor=str(stored_cred.user_uuid),
)
reset_token = token_str
# Also create a session so the device is logged in
+3 -4
View File
@@ -54,7 +54,7 @@ async def user_update_display_name(
raise HTTPException(status_code=400, detail="display_name required")
if len(new_name) > 64:
raise HTTPException(status_code=400, detail="display_name too long")
db.update_user_display_name(s.user_uuid, new_name, actor=str(s.user_uuid))
db.update_user_display_name(s.user_uuid, new_name)
return {"status": "ok"}
@@ -68,7 +68,7 @@ async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE)
raise authz.AuthException(
status_code=401, detail="Session expired", mode="login"
)
db.delete_sessions_for_user(s.user_uuid, actor=str(s.user_uuid))
db.delete_sessions_for_user(s.user_uuid)
session.clear_session_cookie(response)
return {"message": "Logged out from all hosts"}
@@ -95,7 +95,7 @@ async def api_delete_session(
if not target_session or target_session.user_uuid != current_session.user_uuid:
raise HTTPException(status_code=404, detail="Session not found")
db.delete_session(session_id, actor=str(current_session.user_uuid))
db.delete_session(session_id)
current_terminated = session_id == auth
if current_terminated:
session.clear_session_cookie(response) # explicit because 200
@@ -141,7 +141,6 @@ async def api_create_link(
passphrase=token,
expiry=expiry,
token_type="device addition",
actor=str(s.user_uuid),
)
url = hostutil.reset_link_url(token)
return {
-1
View File
@@ -86,7 +86,6 @@ async def websocket_register_add(
host=host,
ip=metadata.get("ip"),
user_agent=metadata.get("user_agent"),
actor=str(user_uuid),
)
auth = token
+6 -8
View File
@@ -38,10 +38,9 @@ from paskia.db import (
create_session,
create_user,
)
from paskia.db.operations import DB
from paskia.db.operations import DB, _create_token
from paskia.fastapi.session import AUTH_COOKIE_NAME
from paskia.sansio import Passkey
from paskia.util.tokens import create_token, session_key
@pytest.fixture(scope="session")
@@ -220,11 +219,11 @@ async def session_token(
test_db: DB, test_user: User, test_credential: Credential
) -> str:
"""Create a session for the admin user and return the token."""
token = create_token()
token = _create_token()
create_session(
user_uuid=test_user.uuid,
credential_uuid=test_credential.uuid,
key=session_key(token),
key=token,
host="localhost:4401",
ip="127.0.0.1",
user_agent="pytest",
@@ -238,11 +237,11 @@ async def regular_session_token(
test_db: DB, regular_user: User, regular_credential: Credential
) -> str:
"""Create a session for a regular user and return the token."""
token = create_token()
token = _create_token()
create_session(
user_uuid=regular_user.uuid,
credential_uuid=regular_credential.uuid,
key=session_key(token),
key=token,
host="localhost:4401",
ip="127.0.0.1",
user_agent="pytest",
@@ -256,12 +255,11 @@ async def reset_token(test_db: DB, test_user: User, test_credential: Credential)
"""Create a reset token for the test user."""
from paskia.authsession import reset_expires
from paskia.util.passphrase import generate
from paskia.util.tokens import reset_key
token = generate()
create_reset_token(
user_uuid=test_user.uuid,
key=reset_key(token),
passphrase=token,
expiry=reset_expires(),
token_type="reset",
)
+11 -16
View File
@@ -34,8 +34,7 @@ from paskia.db import (
create_session,
create_user,
)
from paskia.db.operations import DB
from paskia.util.tokens import create_token, encode_session_key, session_key
from paskia.db.operations import DB, _create_token
from tests.conftest import auth_headers
# -------------------- Additional Fixtures --------------------
@@ -107,11 +106,11 @@ async def second_org_session_token(
test_db: DB, second_org_user: User, second_org_credential: Credential
) -> str:
"""Create a session for the second org admin user."""
token = create_token()
token = _create_token()
create_session(
user_uuid=second_org_user.uuid,
credential_uuid=second_org_credential.uuid,
key=session_key(token),
key=token,
host="localhost:4401",
ip="127.0.0.1",
user_agent="pytest",
@@ -173,11 +172,11 @@ async def org_admin_session_token(
test_db: DB, org_admin_user: User, org_admin_credential: Credential
) -> str:
"""Create a session for the org admin user."""
token = create_token()
token = _create_token()
create_session(
user_uuid=org_admin_user.uuid,
credential_uuid=org_admin_credential.uuid,
key=session_key(token),
key=token,
host="localhost:4401",
ip="127.0.0.1",
user_agent="pytest",
@@ -1195,21 +1194,19 @@ class TestAdminSessions:
):
"""Admin should be able to delete a user's session."""
# Create an additional session to delete
extra_token = create_token()
extra_key = session_key(extra_token)
extra_token = _create_token()
create_session(
user_uuid=test_user.uuid,
credential_uuid=test_credential.uuid,
key=extra_key,
key=extra_token,
host="other.host:4401",
ip="192.168.1.1",
user_agent="other-agent",
expiry=expires(),
)
encoded_key = encode_session_key(extra_key)
response = await client.delete(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/sessions/{encoded_key}",
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/sessions/{extra_token}",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
@@ -1226,9 +1223,8 @@ class TestAdminSessions:
test_user,
):
"""Admin can delete their own current session."""
encoded_key = encode_session_key(session_key(session_token))
response = await client.delete(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/sessions/{encoded_key}",
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/sessions/{session_token}",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
@@ -1283,10 +1279,9 @@ class TestAdminSessions:
):
"""Deleting non-existent session should fail."""
# Use a valid format but non-existent key
fake_key = session_key(create_token())
encoded_key = encode_session_key(fake_key)
fake_token = _create_token()
response = await client.delete(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/sessions/{encoded_key}",
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/sessions/{fake_token}",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 404
+6 -6
View File
@@ -499,10 +499,10 @@ class TestValidateSessionRefresh:
self, client: httpx.AsyncClient, test_db
):
"""Validate should handle session expiry during refresh attempt."""
from paskia.util.tokens import create_token
from paskia.db.operations import _create_token
# Create a token but don't create a session for it
token = create_token()
token = _create_token()
response = await client.post(
"/auth/api/validate",
headers={**auth_headers(token), "Host": "localhost:4401"},
@@ -522,15 +522,15 @@ class TestValidateSessionRefresh:
from datetime import timedelta
from paskia.authsession import EXPIRES
from paskia.util.tokens import create_token, session_key
from paskia.db.operations import _create_token
# Create a session with an old expiry time to trigger refresh
token = create_token()
token = _create_token()
old_expiry = datetime.now(timezone.utc) + EXPIRES - timedelta(minutes=10)
create_session(
user_uuid=test_user.uuid,
credential_uuid=test_credential.uuid,
key=session_key(token),
key=token,
host="localhost:4401",
ip="127.0.0.1",
user_agent="pytest",
@@ -538,7 +538,7 @@ class TestValidateSessionRefresh:
)
# Delete the session right before validate tries to refresh
delete_session(session_key(token))
delete_session(token)
response = await client.post(
"/auth/api/validate",