Hardened PATCH handling (only allow updating select fields). Hardened DB transactions, rollback.

This commit is contained in:
2026-01-28 01:13:05 +00:00
parent 1062b5d6c8
commit aa58f08bc5
4 changed files with 104 additions and 12 deletions
+34
View File
@@ -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."""
+17 -7
View File
@@ -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:
+50 -2
View File
@@ -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
+3 -3
View File
@@ -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"}