Hardened PATCH handling (only allow updating select fields). Hardened DB transactions, rollback.
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user