From aa58f08bc5219f3cdae72846364f3de2014c30c1 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Wed, 28 Jan 2026 01:13:05 +0000 Subject: [PATCH] Hardened PATCH handling (only allow updating select fields). Hardened DB transactions, rollback. --- paskia/db/jsonl.py | 34 +++++++++++++++++++++++++++ paskia/db/operations.py | 24 +++++++++++++------ paskia/db/structs.py | 52 +++++++++++++++++++++++++++++++++++++++-- paskia/fastapi/admin.py | 6 ++--- 4 files changed, 104 insertions(+), 12 deletions(-) diff --git a/paskia/db/jsonl.py b/paskia/db/jsonl.py index de4a551..0cdf456 100644 --- a/paskia/db/jsonl.py +++ b/paskia/db/jsonl.py @@ -162,6 +162,8 @@ class JsonlStore: self._pending_changes: deque[_ChangeRecord] = deque() self._current_action: str = "system" self._current_user: str | None = None + self._in_transaction: bool = False + self._transaction_snapshot: dict[str, Any] | None = None async def load(self, db_path: str | None = None) -> None: """Load data from JSONL change log.""" @@ -220,17 +222,49 @@ class JsonlStore: ctx: Session context of user performing the action (None for system operations) user: User UUID string (alternative to ctx when full context unavailable) """ + if self._in_transaction: + raise RuntimeError("Nested transactions are not supported") + + # Check for out-of-transaction modifications + current_state = msgspec.to_builtins(self.db) + if current_state != self._previous_builtins: + _logger.error( + "Database state modified outside of transaction! " + "This indicates a bug where DB changes occurred without a transaction wrapper. " + "Resetting to last known state from JSONL file." + ) + # Hard reset to last known good state + decoder = msgspec.json.Decoder(DB) + self.db = decoder.decode(msgspec.json.encode(self._previous_builtins)) + self.db._store = self + current_state = self._previous_builtins.copy() + 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 + self._in_transaction = True + self._transaction_snapshot = current_state + try: yield self._queue_change() + except Exception: + # Rollback on error: restore from snapshot + _logger.warning("Transaction '%s' failed, rolling back changes", action) + if self._transaction_snapshot is not None: + decoder = msgspec.json.Decoder(DB) + self.db = decoder.decode( + msgspec.json.encode(self._transaction_snapshot) + ) + self.db._store = self + raise finally: self._current_action = old_action self._current_user = old_user + self._in_transaction = False + self._transaction_snapshot = None async def flush(self) -> bool: """Write all pending changes to disk.""" diff --git a/paskia/db/operations.py b/paskia/db/operations.py index 25d061b..28db53b 100644 --- a/paskia/db/operations.py +++ b/paskia/db/operations.py @@ -225,14 +225,24 @@ def create_permission(perm: Permission, *, ctx: SessionContext | None = None) -> _db.permissions[perm.uuid] = perm -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.permissions: - raise ValueError(f"Permission {perm.uuid} not found") +def update_permission( + uuid: UUID, + scope: str, + display_name: str, + domain: str | None = None, + *, + ctx: SessionContext | None = None, +) -> None: + """Update a permission's scope, display_name, and domain. + + Only these fields can be modified; created_at and other metadata remain immutable. + """ + if uuid not in _db.permissions: + raise ValueError(f"Permission {uuid} not found") with _db.transaction("admin:update_permission", ctx): - _db.permissions[perm.uuid].scope = perm.scope - _db.permissions[perm.uuid].display_name = perm.display_name - _db.permissions[perm.uuid].domain = perm.domain + _db.permissions[uuid].scope = scope + _db.permissions[uuid].display_name = display_name + _db.permissions[uuid].domain = domain def delete_permission(uuid: UUID, *, ctx: SessionContext | None = None) -> None: diff --git a/paskia/db/structs.py b/paskia/db/structs.py index f8eb560..18dc73f 100644 --- a/paskia/db/structs.py +++ b/paskia/db/structs.py @@ -9,6 +9,13 @@ _UUID_UNSET = UUID(int=0) class Permission(msgspec.Struct, dict=True, omit_defaults=True): + """Permission data structure. + + Mutable fields: scope, display_name, domain, orgs + Immutable fields: None (all fields can be updated via update_permission) + uuid is generated at creation. + """ + scope: str # Permission scope identifier (e.g. "auth:admin", "myapp:write") display_name: str domain: str | None = None # If set, scopes permission to this domain @@ -40,6 +47,13 @@ class Permission(msgspec.Struct, dict=True, omit_defaults=True): class Role(msgspec.Struct, dict=True, omit_defaults=True): + """Role data structure. + + Mutable fields: display_name, permissions + Immutable fields: org (set at creation, never modified) + uuid is generated at creation. + """ + org: UUID display_name: str permissions: dict[UUID, bool] = {} # permission_uuid -> True @@ -69,9 +83,16 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True): return role -class Org(msgspec.Struct, dict=True, omit_defaults=True): +class Org(msgspec.Struct, dict=True): + """Organization data structure. + + Mutable fields: display_name + Immutable fields: created_at (set at creation, never modified) + uuid is derived from created_at using uuid7. + """ + display_name: str - created_at: datetime | None = None + created_at: datetime def __post_init__(self): self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized @@ -88,6 +109,13 @@ class Org(msgspec.Struct, dict=True, omit_defaults=True): class User(msgspec.Struct, dict=True): + """User data structure. + + Mutable fields: display_name, role, last_seen, visits + Immutable fields: created_at (set at creation, never modified) + uuid is derived from created_at using uuid7. + """ + display_name: str role: UUID created_at: datetime @@ -116,6 +144,13 @@ class User(msgspec.Struct, dict=True): class Credential(msgspec.Struct, dict=True): + """Credential (passkey) data structure. + + Mutable fields: sign_count, last_used, last_verified + Immutable fields: credential_id, user, aaguid, public_key, created_at + uuid is derived from created_at using uuid7. + """ + credential_id: bytes # Long binary ID from the authenticator user: UUID aaguid: UUID @@ -155,6 +190,13 @@ class Credential(msgspec.Struct, dict=True): class Session(msgspec.Struct, dict=True): + """Session data structure. + + Mutable fields: expiry (updated on session refresh) + Immutable fields: user, credential, host, ip, user_agent + key is stored in the dict key, not in the struct. + """ + user: UUID credential: UUID host: str | None @@ -175,6 +217,12 @@ class Session(msgspec.Struct, dict=True): class ResetToken(msgspec.Struct, dict=True): + """Reset/device-addition token data structure. + + Immutable fields: All fields (tokens are created and deleted, never modified) + key is stored in the dict key, not in the struct. + """ + user: UUID expiry: datetime token_type: str diff --git a/paskia/fastapi/admin.py b/paskia/fastapi/admin.py index c8646bf..e149b46 100644 --- a/paskia/fastapi/admin.py +++ b/paskia/fastapi/admin.py @@ -997,13 +997,13 @@ async def admin_update_permission( if perm.scope == "auth:admin" or new_scope == "auth:admin": _check_admin_lockout(str(perm.uuid), domain_value, request.headers.get("host")) - updated_perm = PermDC( + db.update_permission( + uuid=perm.uuid, scope=new_scope, display_name=new_display_name, domain=domain_value, + ctx=ctx, ) - updated_perm.uuid = perm.uuid - db.update_permission(updated_perm, ctx=ctx) return {"status": "ok"}