Updated database to use async background worker, making changes lock-free synchronous ops.

This commit is contained in:
2026-01-23 15:57:16 +00:00
parent c13044c085
commit 2c6a5c72d9
2 changed files with 315 additions and 347 deletions
+314 -347
View File
@@ -13,7 +13,6 @@ A background task periodically flushes queued changes to disk.
import asyncio import asyncio
import logging import logging
import os import os
import threading
from collections import deque from collections import deque
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -21,6 +20,7 @@ from pathlib import Path
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
import aiofiles
import base64url import base64url
import jsondiff import jsondiff
import msgspec import msgspec
@@ -246,7 +246,7 @@ async def _background_loop():
# Run cleanup immediately on startup to clear old expired items # Run cleanup immediately on startup to clear old expired items
if _db is not None: if _db is not None:
_db.cleanup() _db.cleanup()
_db.flush() await _db.flush()
last_cleanup = datetime.now(timezone.utc) last_cleanup = datetime.now(timezone.utc)
@@ -255,18 +255,18 @@ async def _background_loop():
await asyncio.sleep(FLUSH_INTERVAL) await asyncio.sleep(FLUSH_INTERVAL)
if _db is not None: if _db is not None:
# Flush pending changes to disk # Flush pending changes to disk
_db.flush() await _db.flush()
# Run cleanup less frequently # Run cleanup less frequently
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
if (now - last_cleanup).total_seconds() >= CLEANUP_INTERVAL: if (now - last_cleanup).total_seconds() >= CLEANUP_INTERVAL:
_db.cleanup() _db.cleanup()
_db.flush() # Flush cleanup changes await _db.flush() # Flush cleanup changes
last_cleanup = now last_cleanup = now
except asyncio.CancelledError: except asyncio.CancelledError:
# Final flush before exit # Final flush before exit
if _db is not None: if _db is not None:
_db.flush() await _db.flush()
break break
except Exception: except Exception:
_logger.exception("Error in database background loop") _logger.exception("Error in database background loop")
@@ -315,7 +315,7 @@ class DB:
Changes are queued and periodically flushed to disk by a background task. Changes are queued and periodically flushed to disk by a background task.
Each change records the actor (user UUID or system identifier). Each change records the actor (user UUID or system identifier).
Thread-safety: Uses a lock for concurrent access to the data structure. Thread-safety: Not needed since the app is single-threaded.
Data structure: Data structure:
{ {
@@ -335,7 +335,6 @@ class DB:
self._data: _DatabaseData | None = None self._data: _DatabaseData | None = None
self._previous_builtins: dict[str, Any] = {} # For diffing (JSON-compatible) self._previous_builtins: dict[str, Any] = {} # For diffing (JSON-compatible)
self._pending_changes: deque[_ChangeRecord] = deque() self._pending_changes: deque[_ChangeRecord] = deque()
self._lock = threading.RLock() # Reentrant for nested calls
self._current_actor: str = "system" # Default actor for changes self._current_actor: str = "system" # Default actor for changes
def _empty_data(self) -> _DatabaseData: def _empty_data(self) -> _DatabaseData:
@@ -357,31 +356,30 @@ class DB:
schema evolution), then validates the final state against msgspec schema evolution), then validates the final state against msgspec
structs which become the working copy with proper datetime types. structs which become the working copy with proper datetime types.
""" """
with self._lock: data_dict = msgspec.to_builtins(self._empty_data())
data_dict = msgspec.to_builtins(self._empty_data()) if self.db_path.exists():
if self.db_path.exists(): try:
try: # Read JSONL file line by line and apply diffs
# Read JSONL file line by line and apply diffs with open(self.db_path, encoding="utf-8") as f:
with open(self.db_path, encoding="utf-8") as f: for line_num, line in enumerate(f, 1):
for line_num, line in enumerate(f, 1): line = line.strip()
line = line.strip() if not line:
if not line: continue
continue try:
try: change = msgspec.json.decode(line.encode("utf-8"))
change = msgspec.json.decode(line.encode("utf-8")) # Apply the diff to current state (marshal=True for $-prefixed keys)
# Apply the diff to current state (marshal=True for $-prefixed keys) data_dict = jsondiff.patch(
data_dict = jsondiff.patch( data_dict, change["diff"], marshal=True
data_dict, change["diff"], marshal=True )
) except Exception as e:
except Exception as e: raise ValueError(f"Error parsing line {line_num}: {e}")
raise ValueError(f"Error parsing line {line_num}: {e}") except (OSError, ValueError, msgspec.DecodeError) as e:
except (OSError, ValueError, msgspec.DecodeError) as e: raise ValueError(f"Failed to load database: {e}")
raise ValueError(f"Failed to load database: {e}")
# Validate and convert to msgspec struct (datetime strings -> datetime objects) # Validate and convert to msgspec struct (datetime strings -> datetime objects)
self._data = _json_decoder.decode(_json_encoder.encode(data_dict)) self._data = _json_decoder.decode(_json_encoder.encode(data_dict))
# Store builtins representation for diffing (to_builtins creates a copy) # Store builtins representation for diffing (to_builtins creates a copy)
self._previous_builtins = msgspec.to_builtins(self._data) self._previous_builtins = msgspec.to_builtins(self._data)
def _queue_change(self) -> None: def _queue_change(self) -> None:
"""Queue a change record for later flush. Must hold lock.""" """Queue a change record for later flush. Must hold lock."""
@@ -404,15 +402,14 @@ class DB:
# Update previous builtins for next diff # Update previous builtins for next diff
self._previous_builtins = current_builtins self._previous_builtins = current_builtins
def flush(self) -> None: async def flush(self) -> None:
"""Write all pending changes to disk.""" """Write all pending changes to disk."""
with self._lock: if not self._pending_changes:
if not self._pending_changes: return
return
# Collect all pending changes # Collect all pending changes
changes_to_write = list(self._pending_changes) changes_to_write = list(self._pending_changes)
self._pending_changes.clear() self._pending_changes.clear()
# Write outside the lock to avoid blocking other operations # Write outside the lock to avoid blocking other operations
try: try:
@@ -425,32 +422,32 @@ class DB:
# Read existing content and append # Read existing content and append
existing_content = "" existing_content = ""
if self.db_path.exists(): if self.db_path.exists():
existing_content = self.db_path.read_text("utf-8") async with aiofiles.open(self.db_path, encoding="utf-8") as f:
existing_content = await f.read()
new_content = existing_content + "\n".join(lines) + "\n" new_content = existing_content + "\n".join(lines) + "\n"
# Write atomically via temp file # Write atomically via temp file
tmp_path = self.db_path.with_suffix(".tmp") tmp_path = self.db_path.with_suffix(".tmp")
tmp_path.write_text(new_content, "utf-8") async with aiofiles.open(tmp_path, "w", encoding="utf-8") as f:
await f.write(new_content)
tmp_path.replace(self.db_path) tmp_path.replace(self.db_path)
except OSError: except OSError:
_logger.exception("Failed to flush database changes") _logger.exception("Failed to flush database changes")
# Re-queue the changes on failure # Re-queue the changes on failure
with self._lock: for change in reversed(changes_to_write):
for change in reversed(changes_to_write): self._pending_changes.appendleft(change)
self._pending_changes.appendleft(change)
@contextmanager @contextmanager
def session(self, actor: str = "system"): def session(self, actor: str = "system"):
"""Context manager for atomic operations with change queued on exit.""" """Context manager for atomic operations with change queued on exit."""
with self._lock: old_actor = self._current_actor
old_actor = self._current_actor self._current_actor = actor
self._current_actor = actor try:
try: yield
yield self._queue_change()
self._queue_change() finally:
finally: self._current_actor = old_actor
self._current_actor = old_actor
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Internal helpers (caller must hold lock) # Internal helpers (caller must hold lock)
@@ -550,11 +547,10 @@ class DB:
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
def get_user_by_uuid(self, user_uuid: UUID) -> User: def get_user_by_uuid(self, user_uuid: UUID) -> User:
with self._lock: key = str(user_uuid)
key = str(user_uuid) if key not in self._data.users:
if key not in self._data.users: raise ValueError("User not found")
raise ValueError("User not found") return self._build_user(key)
return self._build_user(key)
def create_user(self, user: User, actor: str = "system") -> None: def create_user(self, user: User, actor: str = "system") -> None:
with self.session(actor): with self.session(actor):
@@ -622,11 +618,10 @@ class DB:
del self._data.roles[key] del self._data.roles[key]
def get_role(self, role_uuid: UUID) -> Role: def get_role(self, role_uuid: UUID) -> Role:
with self._lock: key = str(role_uuid)
key = str(role_uuid) if key not in self._data.roles:
if key not in self._data.roles: raise ValueError("Role not found")
raise ValueError("Role not found") return self._build_role(key)
return self._build_role(key)
def get_role_hidden_permissions(self, role_uuid: UUID) -> list[str]: def get_role_hidden_permissions(self, role_uuid: UUID) -> list[str]:
"""Get permission scopes assigned to role but not grantable by its org. """Get permission scopes assigned to role but not grantable by its org.
@@ -634,30 +629,25 @@ class DB:
These are "hidden" permissions that should be preserved when updating These are "hidden" permissions that should be preserved when updating
the role, so they can become effective again if the org regains access. the role, so they can become effective again if the org regains access.
""" """
with self._lock: key = str(role_uuid)
key = str(role_uuid) if key not in self._data.roles:
if key not in self._data.roles: return []
return []
role_data = self._data.roles[key] role_data = self._data.roles[key]
org_uuid = role_data.org org_uuid = role_data.org
# Get org's grantable scopes # Get org's grantable scopes
if org_uuid not in self._data.orgs: if org_uuid not in self._data.orgs:
return [] return []
org_allowed_scopes = { org_allowed_scopes = {
p.scope p.scope for pid, p in self._data.permissions.items() if org_uuid in p.orgs
for pid, p in self._data.permissions.items() }
if org_uuid in p.orgs
}
# Return scopes in role but not in org # Return scopes in role but not in org
return [ return [
scope scope for scope in role_data.permissions if scope not in org_allowed_scopes
for scope in role_data.permissions ]
if scope not in org_allowed_scopes
]
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Credential operations # Credential operations
@@ -678,22 +668,20 @@ class DB:
) )
def get_credential_by_id(self, credential_id: bytes) -> Credential: def get_credential_by_id(self, credential_id: bytes) -> Credential:
with self._lock: for key, c in self._data.credentials.items():
for key, c in self._data.credentials.items(): if c.credential_id == credential_id:
if c.credential_id == credential_id: return self._build_credential(key)
return self._build_credential(key) raise ValueError("Credential not found")
raise ValueError("Credential not found")
def get_credentials_by_user_uuid(self, user_uuid: UUID) -> list[bytes]: def get_credentials_by_user_uuid(self, user_uuid: UUID) -> list[bytes]:
with self._lock: user_key = str(user_uuid)
user_key = str(user_uuid) result: list[bytes] = []
result: list[bytes] = [] for c in self._data.credentials.values():
for c in self._data.credentials.values(): if c.user == user_key:
if c.user == user_key: cred_id = c.credential_id
cred_id = c.credential_id if cred_id is not None:
if cred_id is not None: result.append(cred_id)
result.append(cred_id) return result
return result
def update_credential(self, credential: Credential, actor: str = "system") -> None: def update_credential(self, credential: Credential, actor: str = "system") -> None:
with self.session(actor): with self.session(actor):
@@ -745,11 +733,10 @@ class DB:
) )
def get_session(self, key: bytes) -> Session | None: def get_session(self, key: bytes) -> Session | None:
with self._lock: key_b64 = _bytes_to_str(key)
key_b64 = _bytes_to_str(key) if key_b64 not in self._data.sessions:
if key_b64 not in self._data.sessions: return None
return None return self._build_session(key_b64)
return self._build_session(key_b64)
def delete_session(self, key: bytes, actor: str = "system") -> None: def delete_session(self, key: bytes, actor: str = "system") -> None:
with self.session(actor): with self.session(actor):
@@ -785,17 +772,16 @@ class DB:
s.host = host s.host = host
def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]: def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]:
with self._lock: user_key = str(user_uuid)
user_key = str(user_uuid) sessions = []
sessions = [] for key_b64, s in self._data.sessions.items():
for key_b64, s in self._data.sessions.items(): if s.user == user_key:
if s.user == user_key: key_bytes = _str_to_bytes(key_b64)
key_bytes = _str_to_bytes(key_b64) if key_bytes and key_bytes.startswith(b"sess"):
if key_bytes and key_bytes.startswith(b"sess"): sessions.append(self._build_session(key_b64))
sessions.append(self._build_session(key_b64)) # Sort by expiry desc (most recent expiry first)
# Sort by expiry desc (most recent expiry first) sessions.sort(key=lambda x: x.expiry, reverse=True)
sessions.sort(key=lambda x: x.expiry, reverse=True) return sessions
return sessions
def delete_sessions_for_user(self, user_uuid: UUID, actor: str = "system") -> None: def delete_sessions_for_user(self, user_uuid: UUID, actor: str = "system") -> None:
with self.session(actor): with self.session(actor):
@@ -827,17 +813,16 @@ class DB:
) )
def get_reset_token(self, key: bytes) -> ResetToken | None: def get_reset_token(self, key: bytes) -> ResetToken | None:
with self._lock: key_b64 = _bytes_to_str(key)
key_b64 = _bytes_to_str(key) if key_b64 not in self._data.reset_tokens:
if key_b64 not in self._data.reset_tokens: return None
return None t = self._data.reset_tokens[key_b64]
t = self._data.reset_tokens[key_b64] return ResetToken(
return ResetToken( key=_str_to_bytes(key_b64), # type: ignore[arg-type]
key=_str_to_bytes(key_b64), # type: ignore[arg-type] user_uuid=UUID(t.user),
user_uuid=UUID(t.user), expiry=t.expiry, # Already datetime
expiry=t.expiry, # Already datetime token_type=t.token_type,
token_type=t.token_type, )
)
def delete_reset_token(self, key: bytes, actor: str = "system") -> None: def delete_reset_token(self, key: bytes, actor: str = "system") -> None:
with self.session(actor): with self.session(actor):
@@ -874,17 +859,15 @@ class DB:
break break
def get_organization(self, org_id: str) -> Org: def get_organization(self, org_id: str) -> Org:
with self._lock: if org_id not in self._data.orgs:
if org_id not in self._data.orgs: raise ValueError("Organization not found")
raise ValueError("Organization not found") return self._build_org(org_id, include_roles=True)
return self._build_org(org_id, include_roles=True)
def list_organizations(self) -> list[Org]: def list_organizations(self) -> list[Org]:
with self._lock: return [
return [ self._build_org(org_uuid, include_roles=True)
self._build_org(org_uuid, include_roles=True) for org_uuid in self._data.orgs
for org_uuid in self._data.orgs ]
]
def update_organization(self, org: Org, actor: str = "system") -> None: def update_organization(self, org: Org, actor: str = "system") -> None:
with self.session(actor): with self.session(actor):
@@ -949,52 +932,46 @@ class DB:
raise ValueError("Users cannot be transferred to a different organization") raise ValueError("Users cannot be transferred to a different organization")
def get_user_organization(self, user_uuid: UUID) -> tuple[Org, str]: def get_user_organization(self, user_uuid: UUID) -> tuple[Org, str]:
with self._lock: user_key = str(user_uuid)
user_key = str(user_uuid) if user_key not in self._data.users:
if user_key not in self._data.users: raise ValueError("User not found")
raise ValueError("User not found") role_uuid = self._data.users[user_key].role
role_uuid = self._data.users[user_key].role if role_uuid not in self._data.roles:
if role_uuid not in self._data.roles: raise ValueError("Role not found")
raise ValueError("Role not found") r = self._data.roles[role_uuid]
r = self._data.roles[role_uuid] if r.org not in self._data.orgs:
if r.org not in self._data.orgs: raise ValueError("Organization not found")
raise ValueError("Organization not found") return self._build_org(r.org), r.display_name
return self._build_org(r.org), r.display_name
def get_organization_users(self, org_id: str) -> list[tuple[User, str]]: def get_organization_users(self, org_id: str) -> list[tuple[User, str]]:
with self._lock: # Get all roles for this org
# Get all roles for this org org_role_uuids = {
org_role_uuids = { role_uuid for role_uuid, r in self._data.roles.items() if r.org == org_id
role_uuid }
for role_uuid, r in self._data.roles.items() return [
if r.org == org_id (self._build_user(user_uuid), self._data.roles[u.role].display_name)
} for user_uuid, u in self._data.users.items()
return [ if u.role in org_role_uuids
(self._build_user(user_uuid), self._data.roles[u.role].display_name) ]
for user_uuid, u in self._data.users.items()
if u.role in org_role_uuids
]
def get_roles_by_organization(self, org_id: str) -> list[Role]: def get_roles_by_organization(self, org_id: str) -> list[Role]:
with self._lock: return [
return [ self._build_role(role_uuid)
self._build_role(role_uuid) for role_uuid, r in self._data.roles.items()
for role_uuid, r in self._data.roles.items() if r.org == org_id
if r.org == org_id ]
]
def get_user_role_in_organization(self, user_uuid: UUID, org_id: str) -> str | None: def get_user_role_in_organization(self, user_uuid: UUID, org_id: str) -> str | None:
with self._lock: user_key = str(user_uuid)
user_key = str(user_uuid) if user_key not in self._data.users:
if user_key not in self._data.users: return None
return None role_uuid = self._data.users[user_key].role
role_uuid = self._data.users[user_key].role if role_uuid not in self._data.roles:
if role_uuid not in self._data.roles: return None
return None r = self._data.roles[role_uuid]
r = self._data.roles[role_uuid] if r.org != org_id:
if r.org != org_id: return None
return None return r.display_name
return r.display_name
def update_user_role_in_organization( def update_user_role_in_organization(
self, user_uuid: UUID, new_role: str, actor: str = "system" self, user_uuid: UUID, new_role: str, actor: str = "system"
@@ -1038,51 +1015,48 @@ class DB:
- A UUID string (the primary key) - A UUID string (the primary key)
- A scope string (searches for matching scope) - A scope string (searches for matching scope)
""" """
with self._lock: # First try as UUID key
# First try as UUID key if permission_id in self._data.permissions:
if permission_id in self._data.permissions: p = self._data.permissions[permission_id]
p = self._data.permissions[permission_id] return Permission(
uuid=UUID(permission_id),
scope=p.scope,
display_name=p.display_name,
domain=p.domain,
)
# Fall back to scope search
for pid, p in self._data.permissions.items():
if p.scope == permission_id:
return Permission( return Permission(
uuid=UUID(permission_id),
scope=p.scope,
display_name=p.display_name,
domain=p.domain,
)
# Fall back to scope search
for pid, p in self._data.permissions.items():
if p.scope == permission_id:
return Permission(
uuid=UUID(pid),
scope=p.scope,
display_name=p.display_name,
domain=p.domain,
)
raise ValueError("Permission not found")
def get_permission_by_scope(self, scope: str) -> Permission | None:
"""Get a permission by its scope string."""
with self._lock:
for pid, p in self._data.permissions.items():
if p.scope == scope:
return Permission(
uuid=UUID(pid),
scope=p.scope,
display_name=p.display_name,
domain=p.domain,
)
return None
def list_permissions(self) -> list[Permission]:
with self._lock:
return [
Permission(
uuid=UUID(pid), uuid=UUID(pid),
scope=p.scope, scope=p.scope,
display_name=p.display_name, display_name=p.display_name,
domain=p.domain, domain=p.domain,
) )
for pid, p in self._data.permissions.items() raise ValueError("Permission not found")
]
def get_permission_by_scope(self, scope: str) -> Permission | None:
"""Get a permission by its scope string."""
for pid, p in self._data.permissions.items():
if p.scope == scope:
return Permission(
uuid=UUID(pid),
scope=p.scope,
display_name=p.display_name,
domain=p.domain,
)
return None
def list_permissions(self) -> list[Permission]:
return [
Permission(
uuid=UUID(pid),
scope=p.scope,
display_name=p.display_name,
domain=p.domain,
)
for pid, p in self._data.permissions.items()
]
def update_permission(self, permission: Permission, actor: str = "system") -> None: def update_permission(self, permission: Permission, actor: str = "system") -> None:
with self.session(actor): with self.session(actor):
@@ -1188,37 +1162,33 @@ class DB:
del orgs[org_id] del orgs[org_id]
def get_organization_permissions(self, org_id: str) -> list[Permission]: def get_organization_permissions(self, org_id: str) -> list[Permission]:
with self._lock: if org_id not in self._data.orgs:
if org_id not in self._data.orgs: raise ValueError("Organization not found")
raise ValueError("Organization not found") permissions = []
permissions = [] for pid, p in self._data.permissions.items():
for pid, p in self._data.permissions.items(): if org_id in p.orgs:
if org_id in p.orgs: permissions.append(
permissions.append( Permission(
Permission( uuid=UUID(pid),
uuid=UUID(pid), scope=p.scope,
scope=p.scope, display_name=p.display_name,
display_name=p.display_name, domain=p.domain,
domain=p.domain,
)
) )
return permissions )
return permissions
def get_permission_organizations(self, permission_id: str) -> list[Org]: def get_permission_organizations(self, permission_id: str) -> list[Org]:
"""Get organizations that can grant a permission. """Get organizations that can grant a permission.
permission_id can be a UUID string or a scope string. permission_id can be a UUID string or a scope string.
""" """
with self._lock: key = self._resolve_permission_key(permission_id)
key = self._resolve_permission_key(permission_id) if not key or key not in self._data.permissions:
if not key or key not in self._data.permissions: return []
return [] org_ids = self._data.permissions[key].orgs
org_ids = self._data.permissions[key].orgs return [
return [ self._build_org(org_id) for org_id in org_ids if org_id in self._data.orgs
self._build_org(org_id) ]
for org_id in org_ids
if org_id in self._data.orgs
]
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Role-permission operations # Role-permission operations
@@ -1272,58 +1242,56 @@ class DB:
can grant. Set to False to see all assigned permissions can grant. Set to False to see all assigned permissions
regardless of org restrictions. regardless of org restrictions.
""" """
with self._lock: key = str(role_uuid)
key = str(role_uuid) if key not in self._data.roles:
if key not in self._data.roles: return []
return [] role_data = self._data.roles[key]
role_data = self._data.roles[key] scopes = list(role_data.permissions.keys())
scopes = list(role_data.permissions.keys())
# Get org permissions if filtering # Get org permissions if filtering
org_allowed_scopes = None org_allowed_scopes = None
if filter_by_org: if filter_by_org:
org_uuid = role_data.org org_uuid = role_data.org
if org_uuid in self._data.orgs: if org_uuid in self._data.orgs:
org_allowed_scopes = { org_allowed_scopes = {
p.scope p.scope
for pid, p in self._data.permissions.items() for pid, p in self._data.permissions.items()
if org_uuid in p.orgs if org_uuid in p.orgs
} }
permissions = [] permissions = []
for scope in scopes: for scope in scopes:
# Skip if org filtering is enabled and scope not allowed by org # Skip if org filtering is enabled and scope not allowed by org
if org_allowed_scopes is not None and scope not in org_allowed_scopes: if org_allowed_scopes is not None and scope not in org_allowed_scopes:
continue continue
# Find permission with this scope # Find permission with this scope
for pid, p in self._data.permissions.items(): for pid, p in self._data.permissions.items():
if p.scope == scope: if p.scope == scope:
permissions.append( permissions.append(
Permission( Permission(
uuid=UUID(pid), uuid=UUID(pid),
scope=p.scope, scope=p.scope,
display_name=p.display_name, display_name=p.display_name,
domain=p.domain, domain=p.domain,
)
) )
break )
return permissions break
return permissions
def get_permission_roles(self, permission_id: str) -> list[Role]: def get_permission_roles(self, permission_id: str) -> list[Role]:
"""Get roles that have a permission. """Get roles that have a permission.
permission_id can be a UUID string or a scope string. permission_id can be a UUID string or a scope string.
""" """
with self._lock: scope = self._resolve_permission_scope(permission_id)
scope = self._resolve_permission_scope(permission_id) if not scope:
if not scope: return []
return [] return [
return [ self._build_role(role_uuid)
self._build_role(role_uuid) for role_uuid, r in self._data.roles.items()
for role_uuid, r in self._data.roles.items() if scope in r.permissions
if scope in r.permissions ]
]
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Combined operations # Combined operations
@@ -1467,84 +1435,83 @@ class DB:
This is the primary method for validating sessions and getting all This is the primary method for validating sessions and getting all
associated user/org/role/credential data in a single call. associated user/org/role/credential data in a single call.
""" """
with self._lock: sess_key_b64 = _bytes_to_str(session_key)
sess_key_b64 = _bytes_to_str(session_key) if sess_key_b64 not in self._data.sessions:
if sess_key_b64 not in self._data.sessions: return None
s = self._data.sessions[sess_key_b64]
# Handle host binding
if host is not None:
if s.host is None:
s.host = host
self._queue_change() # Queue change for host binding
elif s.host != host:
return None return None
s = self._data.sessions[sess_key_b64] # Validate user exists
user_key = s.user
if user_key not in self._data.users:
return None
# Handle host binding # Validate role exists
if host is not None: role_uuid = self._data.users[user_key].role
if s.host is None: if role_uuid not in self._data.roles:
s.host = host return None
self._queue_change() # Queue change for host binding
elif s.host != host:
return None
# Validate user exists # Validate org exists
user_key = s.user org_uuid = self._data.roles[role_uuid].org
if user_key not in self._data.users: if org_uuid not in self._data.orgs:
return None return None
# Validate role exists # Build objects using helpers
role_uuid = self._data.users[user_key].role session_obj = self._build_session(sess_key_b64)
if role_uuid not in self._data.roles: user_obj = self._build_user(user_key)
return None role_obj = self._build_role(role_uuid)
org_obj = self._build_org(org_uuid)
# Validate org exists # Get credential (optional)
org_uuid = self._data.roles[role_uuid].org cred_uuid = s.credential
if org_uuid not in self._data.orgs: credential_obj = (
return None self._build_credential(cred_uuid)
if cred_uuid in self._data.credentials
else None
)
# Build objects using helpers # Effective permissions: role permissions (scopes) that the org can grant
session_obj = self._build_session(sess_key_b64) # role_obj.permissions contains scopes, org_obj.permissions contains scopes
user_obj = self._build_user(user_key) from paskia.util.hostutil import normalize_host
role_obj = self._build_role(role_uuid)
org_obj = self._build_org(org_uuid)
# Get credential (optional) normalized_host = normalize_host(host)
cred_uuid = s.credential # Strip port for domain matching (e.g., localhost:4401 -> localhost)
credential_obj = ( host_without_port = (
self._build_credential(cred_uuid) normalized_host.rsplit(":", 1)[0] if normalized_host else None
if cred_uuid in self._data.credentials )
else None effective_permissions = []
) for scope in role_obj.permissions:
if scope not in org_obj.permissions:
# Effective permissions: role permissions (scopes) that the org can grant continue
# role_obj.permissions contains scopes, org_obj.permissions contains scopes # Find the permission by scope
from paskia.util.hostutil import normalize_host for pid, p in self._data.permissions.items():
if p.scope == scope:
normalized_host = normalize_host(host) # Check domain restriction (compare without port)
# Strip port for domain matching (e.g., localhost:4401 -> localhost) if p.domain is not None and p.domain != host_without_port:
host_without_port = ( continue
normalized_host.rsplit(":", 1)[0] if normalized_host else None effective_permissions.append(
) Permission(
effective_permissions = [] uuid=UUID(pid),
for scope in role_obj.permissions: scope=p.scope,
if scope not in org_obj.permissions: display_name=p.display_name,
continue domain=p.domain,
# Find the permission by scope
for pid, p in self._data.permissions.items():
if p.scope == scope:
# Check domain restriction (compare without port)
if p.domain is not None and p.domain != host_without_port:
continue
effective_permissions.append(
Permission(
uuid=UUID(pid),
scope=p.scope,
display_name=p.display_name,
domain=p.domain,
)
) )
break )
break
return SessionContext( return SessionContext(
session=session_obj, session=session_obj,
user=user_obj, user=user_obj,
org=org_obj, org=org_obj,
role=role_obj, role=role_obj,
credential=credential_obj, credential=credential_obj,
permissions=effective_permissions or None, permissions=effective_permissions or None,
) )
+1
View File
@@ -21,6 +21,7 @@ dependencies = [
"user-agents>=2.2.0", "user-agents>=2.2.0",
"jsondiff>=2.2.1", "jsondiff>=2.2.1",
"msgspec>=0.20.0", "msgspec>=0.20.0",
"aiofiles>=25.1.0",
] ]
requires-python = ">=3.10" requires-python = ">=3.10"