1469 lines
53 KiB
Python
1469 lines
53 KiB
Python
"""
|
|
JSON database implementation for WebAuthn passkey authentication.
|
|
|
|
This module provides a JSON file-based database layer that maintains all data
|
|
in memory and persists changes to disk as JSONL. Uses object keys by UUID
|
|
instead of lists for efficient lookups.
|
|
|
|
All public data types are msgspec Structs for efficient serialization.
|
|
Database methods are synchronous since all data is in memory.
|
|
A background task periodically flushes queued changes to disk.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import threading
|
|
from collections import deque
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
import base64url
|
|
import jsondiff
|
|
import msgspec
|
|
|
|
from paskia.config import SESSION_LIFETIME
|
|
|
|
DB_PATH_DEFAULT = "paskia.jsonl"
|
|
|
|
# Flush changes to disk every N seconds
|
|
FLUSH_INTERVAL = 5
|
|
# Cleanup expired items every N seconds (cheap when nothing to remove)
|
|
CLEANUP_INTERVAL = 1
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Public data types (msgspec Structs)
|
|
# -------------------------------------------------------------------------
|
|
|
|
|
|
class Permission(msgspec.Struct, omit_defaults=True):
|
|
"""A permission that can be granted to roles."""
|
|
|
|
uuid: UUID # UUID primary key
|
|
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
|
|
|
|
|
|
class Role(msgspec.Struct):
|
|
"""A role within an organization that can be assigned to users."""
|
|
|
|
uuid: UUID
|
|
org_uuid: UUID
|
|
display_name: str
|
|
permissions: list[str] = [] # permission IDs this role grants
|
|
|
|
|
|
class Org(msgspec.Struct):
|
|
"""An organization that contains users and roles."""
|
|
|
|
uuid: UUID
|
|
display_name: str
|
|
permissions: list[str] = [] # permission IDs this org can grant
|
|
roles: list[Role] = [] # roles belonging to this org
|
|
|
|
|
|
class User(msgspec.Struct):
|
|
"""A user in the authentication system."""
|
|
|
|
uuid: UUID
|
|
display_name: str
|
|
role_uuid: UUID
|
|
created_at: datetime | None = None
|
|
last_seen: datetime | None = None
|
|
visits: int = 0
|
|
|
|
|
|
class Credential(msgspec.Struct):
|
|
"""A WebAuthn credential (passkey) belonging to a user."""
|
|
|
|
uuid: UUID
|
|
credential_id: bytes # Long binary ID from the authenticator
|
|
user_uuid: UUID
|
|
aaguid: UUID
|
|
public_key: bytes
|
|
sign_count: int
|
|
created_at: datetime
|
|
last_used: datetime | None = None
|
|
last_verified: datetime | None = None
|
|
|
|
|
|
class Session(msgspec.Struct):
|
|
"""An active user session."""
|
|
|
|
key: bytes
|
|
user_uuid: UUID
|
|
credential_uuid: UUID
|
|
host: str | None
|
|
ip: str | None
|
|
user_agent: str | None
|
|
expiry: datetime
|
|
|
|
def metadata(self) -> dict:
|
|
"""Return session metadata for backwards compatibility."""
|
|
return {
|
|
"ip": self.ip,
|
|
"user_agent": self.user_agent,
|
|
"expiry": self.expiry.isoformat(),
|
|
}
|
|
|
|
|
|
class ResetToken(msgspec.Struct):
|
|
"""A token for password reset or device addition."""
|
|
|
|
key: bytes
|
|
user_uuid: UUID
|
|
expiry: datetime
|
|
token_type: str
|
|
|
|
|
|
class SessionContext(msgspec.Struct):
|
|
"""Complete context for an authenticated session."""
|
|
|
|
session: Session
|
|
user: User
|
|
org: Org
|
|
role: Role
|
|
credential: Credential | None = None
|
|
permissions: list[Permission] | None = None
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Internal storage types (different structure for efficient storage)
|
|
# -------------------------------------------------------------------------
|
|
|
|
|
|
class _PermissionData(msgspec.Struct, omit_defaults=True):
|
|
scope: str # Permission scope identifier
|
|
display_name: str
|
|
domain: str | None = None
|
|
orgs: dict[str, bool] = {} # org_uuid -> True (which orgs can grant this)
|
|
|
|
|
|
class _OrgData(msgspec.Struct):
|
|
display_name: str
|
|
created_at: datetime | None = None
|
|
|
|
|
|
class _RoleData(msgspec.Struct):
|
|
org: str
|
|
display_name: str
|
|
permissions: dict[str, bool] # permission_id -> True
|
|
|
|
|
|
class _UserData(msgspec.Struct):
|
|
display_name: str
|
|
role: str
|
|
created_at: datetime
|
|
last_seen: datetime | None
|
|
visits: int
|
|
|
|
|
|
class _CredentialData(msgspec.Struct):
|
|
credential_id: bytes # msgspec uses standard base64
|
|
user: str
|
|
aaguid: str
|
|
public_key: bytes # msgspec uses standard base64
|
|
sign_count: int
|
|
created_at: datetime
|
|
last_used: datetime | None
|
|
last_verified: datetime | None
|
|
|
|
|
|
class _SessionData(msgspec.Struct):
|
|
user: str
|
|
credential: str
|
|
host: str | None
|
|
ip: str | None
|
|
user_agent: str | None
|
|
expiry: datetime
|
|
|
|
|
|
class _ResetTokenData(msgspec.Struct):
|
|
user: str
|
|
expiry: datetime
|
|
token_type: str
|
|
|
|
|
|
class _DatabaseData(msgspec.Struct):
|
|
permissions: dict[str, _PermissionData]
|
|
orgs: dict[str, _OrgData]
|
|
roles: dict[str, _RoleData]
|
|
users: dict[str, _UserData]
|
|
credentials: dict[str, _CredentialData]
|
|
sessions: dict[str, _SessionData]
|
|
reset_tokens: dict[str, _ResetTokenData]
|
|
|
|
|
|
class _ChangeRecord(msgspec.Struct):
|
|
"""A single change record in the JSONL file."""
|
|
|
|
ts: datetime
|
|
actor: str
|
|
diff: dict
|
|
|
|
|
|
# msgspec encoder/decoder with built-in conversions
|
|
# datetime -> ISO 8601 strings, bytes -> standard base64
|
|
_json_encoder = msgspec.json.Encoder()
|
|
_json_decoder = msgspec.json.Decoder(_DatabaseData)
|
|
|
|
|
|
def _bytes_to_str(b: bytes | None) -> str | None:
|
|
"""Convert bytes to base64url string."""
|
|
if b is None:
|
|
return None
|
|
return base64url.enc(b)
|
|
|
|
|
|
def _str_to_bytes(s: str | None) -> bytes | None:
|
|
"""Convert base64url string to bytes."""
|
|
if s is None:
|
|
return None
|
|
return base64url.dec(s)
|
|
|
|
|
|
# Global database instance (set by init())
|
|
_db: "DB | None" = None
|
|
_background_task: asyncio.Task | None = None
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def get_db() -> "DB":
|
|
"""Get the global database instance."""
|
|
if _db is None:
|
|
raise RuntimeError("Database not initialized. Call init() first.")
|
|
return _db
|
|
|
|
|
|
async def _background_loop():
|
|
"""Background task that periodically flushes changes and cleans up."""
|
|
# Run cleanup immediately on startup to clear old expired items
|
|
if _db is not None:
|
|
_db.cleanup()
|
|
_db.flush()
|
|
|
|
last_cleanup = datetime.now(timezone.utc)
|
|
|
|
while True:
|
|
try:
|
|
await asyncio.sleep(FLUSH_INTERVAL)
|
|
if _db is not None:
|
|
# Flush pending changes to disk
|
|
_db.flush()
|
|
|
|
# Run cleanup less frequently
|
|
now = datetime.now(timezone.utc)
|
|
if (now - last_cleanup).total_seconds() >= CLEANUP_INTERVAL:
|
|
_db.cleanup()
|
|
_db.flush() # Flush cleanup changes
|
|
last_cleanup = now
|
|
except asyncio.CancelledError:
|
|
# Final flush before exit
|
|
if _db is not None:
|
|
_db.flush()
|
|
break
|
|
except Exception:
|
|
_logger.exception("Error in database background loop")
|
|
|
|
|
|
async def start_background():
|
|
"""Start the background flush/cleanup task."""
|
|
global _background_task
|
|
if _background_task is None:
|
|
_background_task = asyncio.create_task(_background_loop())
|
|
|
|
|
|
async def stop_background():
|
|
"""Stop the background task and flush any pending changes."""
|
|
global _background_task
|
|
if _background_task:
|
|
_background_task.cancel()
|
|
try:
|
|
await _background_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
_background_task = None
|
|
|
|
|
|
# Aliases for backwards compatibility
|
|
start_cleanup = start_background
|
|
stop_cleanup = stop_background
|
|
|
|
|
|
async def init(*args, **kwargs):
|
|
"""Initialize the global database instance and start background task."""
|
|
global _db
|
|
db_path = os.environ.get("PASKIA_DB", DB_PATH_DEFAULT)
|
|
# Remove any prefix (for compatibility with SQL-style URIs)
|
|
if db_path.startswith("json:"):
|
|
db_path = db_path[5:]
|
|
_db = DB(db_path)
|
|
_db.load()
|
|
await start_background()
|
|
|
|
|
|
class DB:
|
|
"""JSON-based database implementation.
|
|
|
|
All methods are synchronous since data is maintained in memory.
|
|
Changes are queued and periodically flushed to disk by a background task.
|
|
Each change records the actor (user UUID or system identifier).
|
|
|
|
Thread-safety: Uses a lock for concurrent access to the data structure.
|
|
|
|
Data structure:
|
|
{
|
|
"permissions": { "<id>": {"id": ..., "display_name": ...} },
|
|
"orgs": { "<uuid>": {..., "permissions": [...]} },
|
|
"roles": { "<uuid>": {..., "permissions": [...]} },
|
|
"users": { "<uuid>": {...} },
|
|
"credentials": { "<uuid>": {...} },
|
|
"sessions": { "<b64 key>": {...} },
|
|
"reset_tokens": { "<b64 key>": {...} },
|
|
}
|
|
"""
|
|
|
|
def __init__(self, db_path: str = DB_PATH_DEFAULT):
|
|
"""Initialize with database file path."""
|
|
self.db_path = Path(db_path)
|
|
self._data: _DatabaseData | None = None
|
|
self._previous_builtins: dict[str, Any] = {} # For diffing (JSON-compatible)
|
|
self._pending_changes: deque[_ChangeRecord] = deque()
|
|
self._lock = threading.RLock() # Reentrant for nested calls
|
|
self._current_actor: str = "system" # Default actor for changes
|
|
|
|
def _empty_data(self) -> _DatabaseData:
|
|
"""Return an empty database structure."""
|
|
return _DatabaseData(
|
|
permissions={},
|
|
orgs={},
|
|
roles={},
|
|
users={},
|
|
credentials={},
|
|
sessions={},
|
|
reset_tokens={},
|
|
)
|
|
|
|
def load(self) -> None:
|
|
"""Load data from disk by applying change log.
|
|
|
|
Replays all changes from JSONL file using plain dicts (to handle
|
|
schema evolution), then validates the final state against msgspec
|
|
structs which become the working copy with proper datetime types.
|
|
"""
|
|
with self._lock:
|
|
data_dict = msgspec.to_builtins(self._empty_data())
|
|
if self.db_path.exists():
|
|
try:
|
|
# Read JSONL file line by line and apply diffs
|
|
with open(self.db_path, encoding="utf-8") as f:
|
|
for line_num, line in enumerate(f, 1):
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
change = msgspec.json.decode(line.encode("utf-8"))
|
|
# Apply the diff to current state (marshal=True for $-prefixed keys)
|
|
data_dict = jsondiff.patch(
|
|
data_dict, change["diff"], marshal=True
|
|
)
|
|
except Exception as e:
|
|
raise ValueError(f"Error parsing line {line_num}: {e}")
|
|
except (OSError, ValueError, msgspec.DecodeError) as e:
|
|
raise ValueError(f"Failed to load database: {e}")
|
|
|
|
# Validate and convert to msgspec struct (datetime strings -> datetime objects)
|
|
self._data = _json_decoder.decode(_json_encoder.encode(data_dict))
|
|
# Store builtins representation for diffing (to_builtins creates a copy)
|
|
self._previous_builtins = msgspec.to_builtins(self._data)
|
|
|
|
def _queue_change(self) -> None:
|
|
"""Queue a change record for later flush. Must hold lock."""
|
|
if self._data is None:
|
|
return
|
|
# Convert current struct to builtins for diffing (datetime->str, bytes->base64)
|
|
current_builtins = msgspec.to_builtins(self._data)
|
|
|
|
# Calculate diff between previous and current state (marshal=True for JSON-serializable keys)
|
|
diff = jsondiff.diff(self._previous_builtins, current_builtins, marshal=True)
|
|
|
|
# Only queue if there are changes
|
|
if diff:
|
|
change_record = _ChangeRecord(
|
|
ts=datetime.now(timezone.utc),
|
|
actor=self._current_actor,
|
|
diff=diff,
|
|
)
|
|
self._pending_changes.append(change_record)
|
|
# Update previous builtins for next diff
|
|
self._previous_builtins = current_builtins
|
|
|
|
def flush(self) -> None:
|
|
"""Write all pending changes to disk."""
|
|
with self._lock:
|
|
if not self._pending_changes:
|
|
return
|
|
|
|
# Collect all pending changes
|
|
changes_to_write = list(self._pending_changes)
|
|
self._pending_changes.clear()
|
|
|
|
# Write outside the lock to avoid blocking other operations
|
|
try:
|
|
# Build lines to append
|
|
lines = []
|
|
for change in changes_to_write:
|
|
data = _json_encoder.encode(change)
|
|
lines.append(data.decode("utf-8"))
|
|
|
|
# Read existing content and append
|
|
existing_content = ""
|
|
if self.db_path.exists():
|
|
existing_content = self.db_path.read_text("utf-8")
|
|
|
|
new_content = existing_content + "\n".join(lines) + "\n"
|
|
|
|
# Write atomically via temp file
|
|
tmp_path = self.db_path.with_suffix(".tmp")
|
|
tmp_path.write_text(new_content, "utf-8")
|
|
tmp_path.replace(self.db_path)
|
|
except OSError:
|
|
_logger.exception("Failed to flush database changes")
|
|
# Re-queue the changes on failure
|
|
with self._lock:
|
|
for change in reversed(changes_to_write):
|
|
self._pending_changes.appendleft(change)
|
|
|
|
@contextmanager
|
|
def session(self, actor: str = "system"):
|
|
"""Context manager for atomic operations with change queued on exit."""
|
|
with self._lock:
|
|
old_actor = self._current_actor
|
|
self._current_actor = actor
|
|
try:
|
|
yield
|
|
self._queue_change()
|
|
finally:
|
|
self._current_actor = old_actor
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Internal helpers (caller must hold lock)
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _build_user(self, user_uuid: str) -> User:
|
|
"""Build a User object from internal storage. Caller must hold lock."""
|
|
u = self._data.users[user_uuid]
|
|
return User(
|
|
uuid=UUID(user_uuid),
|
|
display_name=u.display_name,
|
|
role_uuid=UUID(u.role),
|
|
created_at=u.created_at,
|
|
last_seen=u.last_seen,
|
|
visits=u.visits,
|
|
)
|
|
|
|
def _build_role(self, role_uuid: str) -> Role:
|
|
"""Build a Role object from internal storage. Caller must hold lock."""
|
|
r = self._data.roles[role_uuid]
|
|
return Role(
|
|
uuid=UUID(role_uuid),
|
|
org_uuid=UUID(r.org),
|
|
display_name=r.display_name,
|
|
permissions=list(r.permissions),
|
|
)
|
|
|
|
def _build_org(self, org_uuid: str, include_roles: bool = False) -> Org:
|
|
"""Build an Org object from internal storage. Caller must hold lock."""
|
|
o = self._data.orgs[org_uuid]
|
|
# Get permission scopes this org can grant
|
|
perm_scopes = [
|
|
p.scope for pid, p in self._data.permissions.items() if org_uuid in p.orgs
|
|
]
|
|
org = Org(
|
|
uuid=UUID(org_uuid),
|
|
display_name=o.display_name,
|
|
permissions=perm_scopes,
|
|
)
|
|
if include_roles:
|
|
org.roles = [
|
|
self._build_role(role_uuid)
|
|
for role_uuid, r in self._data.roles.items()
|
|
if r.org == org_uuid
|
|
]
|
|
return org
|
|
|
|
def _build_credential(self, cred_uuid: str) -> Credential:
|
|
"""Build a Credential object from internal storage. Caller must hold lock."""
|
|
c = self._data.credentials[cred_uuid]
|
|
return Credential(
|
|
uuid=UUID(cred_uuid),
|
|
credential_id=c.credential_id,
|
|
user_uuid=UUID(c.user),
|
|
aaguid=UUID(c.aaguid),
|
|
public_key=c.public_key,
|
|
sign_count=c.sign_count,
|
|
created_at=c.created_at,
|
|
last_used=c.last_used,
|
|
last_verified=c.last_verified,
|
|
)
|
|
|
|
def _build_session(self, sess_key_b64: str) -> Session:
|
|
"""Build a Session object from internal storage. Caller must hold lock."""
|
|
s = self._data.sessions[sess_key_b64]
|
|
return Session(
|
|
key=_str_to_bytes(sess_key_b64), # type: ignore[arg-type]
|
|
user_uuid=UUID(s.user),
|
|
credential_uuid=UUID(s.credential),
|
|
host=s.host,
|
|
ip=s.ip,
|
|
user_agent=s.user_agent,
|
|
expiry=s.expiry,
|
|
)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# User operations
|
|
# -------------------------------------------------------------------------
|
|
|
|
def get_user_by_uuid(self, user_uuid: UUID) -> User:
|
|
with self._lock:
|
|
key = str(user_uuid)
|
|
if key not in self._data.users:
|
|
raise ValueError("User not found")
|
|
return self._build_user(key)
|
|
|
|
def create_user(self, user: User, actor: str = "system") -> None:
|
|
with self.session(actor):
|
|
key = str(user.uuid)
|
|
self._data.users[key] = _UserData(
|
|
display_name=user.display_name,
|
|
role=str(user.role_uuid),
|
|
created_at=user.created_at or datetime.now(timezone.utc),
|
|
last_seen=user.last_seen,
|
|
visits=user.visits,
|
|
)
|
|
|
|
def update_user_display_name(
|
|
self, user_uuid: UUID, display_name: str, actor: str = "system"
|
|
) -> None:
|
|
with self.session(actor):
|
|
key = str(user_uuid)
|
|
if key not in self._data.users:
|
|
raise ValueError("User not found")
|
|
self._data.users[key].display_name = display_name
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Role operations
|
|
# -------------------------------------------------------------------------
|
|
|
|
def create_role(self, role: Role, actor: str = "system") -> None:
|
|
with self.session(actor):
|
|
key = str(role.uuid)
|
|
self._data.roles[key] = _RoleData(
|
|
org=str(role.org_uuid),
|
|
display_name=role.display_name,
|
|
permissions={p: True for p in role.permissions}
|
|
if role.permissions
|
|
else {},
|
|
)
|
|
|
|
def update_role(self, role: Role, actor: str = "system") -> None:
|
|
with self.session(actor):
|
|
key = str(role.uuid)
|
|
if key not in self._data.roles:
|
|
raise ValueError("Role not found")
|
|
self._data.roles[key].display_name = role.display_name
|
|
self._data.roles[key].permissions = (
|
|
{p: True for p in role.permissions} if role.permissions else {}
|
|
)
|
|
|
|
def delete_role(self, role_uuid: UUID, actor: str = "system") -> None:
|
|
with self.session(actor):
|
|
key = str(role_uuid)
|
|
# Check for users with this role
|
|
for u in self._data.users.values():
|
|
if u.role == key:
|
|
raise ValueError("Cannot delete role with assigned users")
|
|
if key in self._data.roles:
|
|
del self._data.roles[key]
|
|
|
|
def get_role(self, role_uuid: UUID) -> Role:
|
|
with self._lock:
|
|
key = str(role_uuid)
|
|
if key not in self._data.roles:
|
|
raise ValueError("Role not found")
|
|
return self._build_role(key)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Credential operations
|
|
# -------------------------------------------------------------------------
|
|
|
|
def create_credential(self, credential: Credential, actor: str = "system") -> None:
|
|
with self.session(actor):
|
|
key = str(credential.uuid)
|
|
self._data.credentials[key] = _CredentialData(
|
|
credential_id=credential.credential_id, # Store bytes directly
|
|
user=str(credential.user_uuid),
|
|
aaguid=str(credential.aaguid),
|
|
public_key=credential.public_key, # Store bytes directly
|
|
sign_count=credential.sign_count,
|
|
created_at=credential.created_at,
|
|
last_used=credential.last_used,
|
|
last_verified=credential.last_verified,
|
|
)
|
|
|
|
def get_credential_by_id(self, credential_id: bytes) -> Credential:
|
|
with self._lock:
|
|
for key, c in self._data.credentials.items():
|
|
if c.credential_id == credential_id:
|
|
return self._build_credential(key)
|
|
raise ValueError("Credential not found")
|
|
|
|
def get_credentials_by_user_uuid(self, user_uuid: UUID) -> list[bytes]:
|
|
with self._lock:
|
|
user_key = str(user_uuid)
|
|
result: list[bytes] = []
|
|
for c in self._data.credentials.values():
|
|
if c.user == user_key:
|
|
cred_id = c.credential_id
|
|
if cred_id is not None:
|
|
result.append(cred_id)
|
|
return result
|
|
|
|
def update_credential(self, credential: Credential, actor: str = "system") -> None:
|
|
with self.session(actor):
|
|
for key, c in self._data.credentials.items():
|
|
if c.credential_id == credential.credential_id:
|
|
c.sign_count = credential.sign_count
|
|
c.created_at = credential.created_at
|
|
c.last_used = credential.last_used
|
|
c.last_verified = credential.last_verified
|
|
return
|
|
raise ValueError("Credential not found")
|
|
|
|
def delete_credential(
|
|
self, uuid: UUID, user_uuid: UUID, actor: str = "system"
|
|
) -> None:
|
|
with self.session(actor):
|
|
key = str(uuid)
|
|
if key not in self._data.credentials:
|
|
return
|
|
c = self._data.credentials[key]
|
|
if c.user != str(user_uuid):
|
|
return
|
|
del self._data.credentials[key]
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Session operations
|
|
# -------------------------------------------------------------------------
|
|
|
|
def create_session(
|
|
self,
|
|
user_uuid: UUID,
|
|
key: bytes,
|
|
credential_uuid: UUID,
|
|
host: str,
|
|
ip: str,
|
|
user_agent: str,
|
|
expiry: datetime,
|
|
actor: str = "system",
|
|
) -> None:
|
|
with self.session(actor):
|
|
key_b64 = _bytes_to_str(key)
|
|
self._data.sessions[key_b64] = _SessionData(
|
|
user=str(user_uuid),
|
|
credential=str(credential_uuid),
|
|
host=host,
|
|
ip=ip,
|
|
user_agent=user_agent,
|
|
expiry=expiry,
|
|
)
|
|
|
|
def get_session(self, key: bytes) -> Session | None:
|
|
with self._lock:
|
|
key_b64 = _bytes_to_str(key)
|
|
if key_b64 not in self._data.sessions:
|
|
return None
|
|
return self._build_session(key_b64)
|
|
|
|
def delete_session(self, key: bytes, actor: str = "system") -> None:
|
|
with self.session(actor):
|
|
key_b64 = _bytes_to_str(key)
|
|
if key_b64 in self._data.sessions:
|
|
del self._data.sessions[key_b64]
|
|
|
|
def update_session(
|
|
self,
|
|
key: bytes,
|
|
*,
|
|
ip: str,
|
|
user_agent: str,
|
|
expiry: datetime,
|
|
actor: str = "system",
|
|
) -> Session | None:
|
|
with self.session(actor):
|
|
key_b64 = _bytes_to_str(key)
|
|
if key_b64 not in self._data.sessions:
|
|
return None
|
|
s = self._data.sessions[key_b64]
|
|
s.ip = ip
|
|
s.user_agent = user_agent
|
|
s.expiry = expiry
|
|
return self._build_session(key_b64)
|
|
|
|
def set_session_host(self, key: bytes, host: str, actor: str = "system") -> None:
|
|
with self.session(actor):
|
|
key_b64 = _bytes_to_str(key)
|
|
if key_b64 in self._data.sessions:
|
|
s = self._data.sessions[key_b64]
|
|
if s.host is None:
|
|
s.host = host
|
|
|
|
def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]:
|
|
with self._lock:
|
|
user_key = str(user_uuid)
|
|
sessions = []
|
|
for key_b64, s in self._data.sessions.items():
|
|
if s.user == user_key:
|
|
key_bytes = _str_to_bytes(key_b64)
|
|
if key_bytes and key_bytes.startswith(b"sess"):
|
|
sessions.append(self._build_session(key_b64))
|
|
# Sort by expiry desc (most recent expiry first)
|
|
sessions.sort(key=lambda x: x.expiry, reverse=True)
|
|
return sessions
|
|
|
|
def delete_sessions_for_user(self, user_uuid: UUID, actor: str = "system") -> None:
|
|
with self.session(actor):
|
|
user_key = str(user_uuid)
|
|
to_delete = [
|
|
k for k, s in self._data.sessions.items() if s.user == user_key
|
|
]
|
|
for k in to_delete:
|
|
del self._data.sessions[k]
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Reset token operations
|
|
# -------------------------------------------------------------------------
|
|
|
|
def create_reset_token(
|
|
self,
|
|
user_uuid: UUID,
|
|
key: bytes,
|
|
expiry: datetime,
|
|
token_type: str,
|
|
actor: str = "system",
|
|
) -> None:
|
|
with self.session(actor):
|
|
key_b64 = _bytes_to_str(key)
|
|
self._data.reset_tokens[key_b64] = _ResetTokenData(
|
|
user=str(user_uuid),
|
|
expiry=expiry,
|
|
token_type=token_type,
|
|
)
|
|
|
|
def get_reset_token(self, key: bytes) -> ResetToken | None:
|
|
with self._lock:
|
|
key_b64 = _bytes_to_str(key)
|
|
if key_b64 not in self._data.reset_tokens:
|
|
return None
|
|
t = self._data.reset_tokens[key_b64]
|
|
return ResetToken(
|
|
key=_str_to_bytes(key_b64), # type: ignore[arg-type]
|
|
user_uuid=UUID(t.user),
|
|
expiry=t.expiry, # Already datetime
|
|
token_type=t.token_type,
|
|
)
|
|
|
|
def delete_reset_token(self, key: bytes, actor: str = "system") -> None:
|
|
with self.session(actor):
|
|
key_b64 = _bytes_to_str(key)
|
|
if key_b64 in self._data.reset_tokens:
|
|
del self._data.reset_tokens[key_b64]
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Organization operations
|
|
# -------------------------------------------------------------------------
|
|
|
|
def create_organization(self, org: Org, actor: str = "system") -> None:
|
|
import uuid7
|
|
|
|
with self.session(actor):
|
|
key = str(org.uuid)
|
|
self._data.orgs[key] = _OrgData(
|
|
display_name=org.display_name,
|
|
)
|
|
|
|
# Update permissions to allow this org to grant them (by scope)
|
|
for perm_scope in org.permissions:
|
|
# Find permission by scope and add org
|
|
for pid, p in self._data.permissions.items():
|
|
if p.scope == perm_scope:
|
|
p.orgs[key] = True
|
|
break
|
|
|
|
# Automatically create or enable the common org admin permission
|
|
org_admin_scope = "auth:org:admin"
|
|
org_admin_key = None
|
|
for pid, p in self._data.permissions.items():
|
|
if p.scope == org_admin_scope:
|
|
org_admin_key = pid
|
|
break
|
|
|
|
if org_admin_key is None:
|
|
# Create the common org admin permission
|
|
org_admin_key = str(uuid7.create())
|
|
self._data.permissions[org_admin_key] = _PermissionData(
|
|
scope=org_admin_scope,
|
|
display_name="Organization Admin",
|
|
orgs={key: True},
|
|
)
|
|
else:
|
|
# Ensure this org can grant the org admin permission
|
|
self._data.permissions[org_admin_key].orgs[key] = True
|
|
|
|
# Reflect the org admin permission in the dataclass instance
|
|
if org_admin_scope not in org.permissions:
|
|
org.permissions.append(org_admin_scope)
|
|
|
|
def get_organization(self, org_id: str) -> Org:
|
|
with self._lock:
|
|
if org_id not in self._data.orgs:
|
|
raise ValueError("Organization not found")
|
|
return self._build_org(org_id, include_roles=True)
|
|
|
|
def list_organizations(self) -> list[Org]:
|
|
with self._lock:
|
|
return [
|
|
self._build_org(org_uuid, include_roles=True)
|
|
for org_uuid in self._data.orgs
|
|
]
|
|
|
|
def update_organization(self, org: Org, actor: str = "system") -> None:
|
|
with self.session(actor):
|
|
key = str(org.uuid)
|
|
if key not in self._data.orgs:
|
|
raise ValueError("Organization not found")
|
|
self._data.orgs[key].display_name = org.display_name
|
|
# Update which permissions this org can grant (by scope)
|
|
# First remove this org from all permissions
|
|
for p in self._data.permissions.values():
|
|
if key in p.orgs:
|
|
del p.orgs[key]
|
|
# Then add this org to the specified permissions (by scope)
|
|
for perm_scope in org.permissions:
|
|
for pid, p in self._data.permissions.items():
|
|
if p.scope == perm_scope:
|
|
p.orgs[key] = True
|
|
break
|
|
|
|
def delete_organization(self, org_uuid: UUID, actor: str = "system") -> None:
|
|
with self.session(actor):
|
|
key = str(org_uuid)
|
|
if key in self._data.orgs:
|
|
del self._data.orgs[key]
|
|
# Cascade delete roles belonging to this org
|
|
to_delete = [k for k, r in self._data.roles.items() if r.org == key]
|
|
for k in to_delete:
|
|
del self._data.roles[k]
|
|
|
|
def add_user_to_organization(
|
|
self, user_uuid: UUID, org_id: str, role: str, actor: str = "system"
|
|
) -> None:
|
|
with self.session(actor):
|
|
user_key = str(user_uuid)
|
|
if user_key not in self._data.users:
|
|
raise ValueError("User not found")
|
|
if org_id not in self._data.orgs:
|
|
raise ValueError("Organization not found")
|
|
# Find role by display_name in org
|
|
role_uuid = None
|
|
for role_key, r in self._data.roles.items():
|
|
if r.org == org_id and r.display_name == role:
|
|
role_uuid = role_key
|
|
break
|
|
if role_uuid is None:
|
|
raise ValueError("Role not found in organization")
|
|
self._data.users[user_key].role = role_uuid
|
|
|
|
def transfer_user_to_organization(
|
|
self, user_uuid: UUID, new_org_id: str, new_role: str | None = None
|
|
) -> None:
|
|
raise ValueError("Users cannot be transferred to a different organization")
|
|
|
|
def get_user_organization(self, user_uuid: UUID) -> tuple[Org, str]:
|
|
with self._lock:
|
|
user_key = str(user_uuid)
|
|
if user_key not in self._data.users:
|
|
raise ValueError("User not found")
|
|
role_uuid = self._data.users[user_key].role
|
|
if role_uuid not in self._data.roles:
|
|
raise ValueError("Role not found")
|
|
r = self._data.roles[role_uuid]
|
|
if r.org not in self._data.orgs:
|
|
raise ValueError("Organization not found")
|
|
return self._build_org(r.org), r.display_name
|
|
|
|
def get_organization_users(self, org_id: str) -> list[tuple[User, str]]:
|
|
with self._lock:
|
|
# Get all roles for this org
|
|
org_role_uuids = {
|
|
role_uuid
|
|
for role_uuid, r in self._data.roles.items()
|
|
if r.org == org_id
|
|
}
|
|
return [
|
|
(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]:
|
|
with self._lock:
|
|
return [
|
|
self._build_role(role_uuid)
|
|
for role_uuid, r in self._data.roles.items()
|
|
if r.org == org_id
|
|
]
|
|
|
|
def get_user_role_in_organization(self, user_uuid: UUID, org_id: str) -> str | None:
|
|
with self._lock:
|
|
user_key = str(user_uuid)
|
|
if user_key not in self._data.users:
|
|
return None
|
|
role_uuid = self._data.users[user_key].role
|
|
if role_uuid not in self._data.roles:
|
|
return None
|
|
r = self._data.roles[role_uuid]
|
|
if r.org != org_id:
|
|
return None
|
|
return r.display_name
|
|
|
|
def update_user_role_in_organization(
|
|
self, user_uuid: UUID, new_role: str, actor: str = "system"
|
|
) -> None:
|
|
with self.session(actor):
|
|
user_key = str(user_uuid)
|
|
if user_key not in self._data.users:
|
|
raise ValueError("User not found")
|
|
current_role_uuid = self._data.users[user_key].role
|
|
if current_role_uuid not in self._data.roles:
|
|
raise ValueError("Current role not found")
|
|
org_uuid = self._data.roles[current_role_uuid].org
|
|
# Find new role
|
|
new_role_uuid = None
|
|
for role_uuid_str, r in self._data.roles.items():
|
|
if r.org == org_uuid and r.display_name == new_role:
|
|
new_role_uuid = role_uuid_str
|
|
break
|
|
if new_role_uuid is None:
|
|
raise ValueError("Role not found in user's organization")
|
|
self._data.users[user_key].role = new_role_uuid
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Permission operations
|
|
# -------------------------------------------------------------------------
|
|
|
|
def create_permission(self, permission: Permission, actor: str = "system") -> None:
|
|
with self.session(actor):
|
|
key = str(permission.uuid)
|
|
self._data.permissions[key] = _PermissionData(
|
|
scope=permission.scope,
|
|
display_name=permission.display_name,
|
|
domain=permission.domain,
|
|
orgs={}, # Will be populated when orgs are allowed to grant this permission
|
|
)
|
|
|
|
def get_permission(self, permission_id: str) -> Permission:
|
|
"""Get a permission by UUID string or scope.
|
|
|
|
For backwards compatibility, this accepts either:
|
|
- A UUID string (the primary key)
|
|
- A scope string (searches for matching scope)
|
|
"""
|
|
with self._lock:
|
|
# First try as UUID key
|
|
if permission_id in self._data.permissions:
|
|
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(
|
|
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),
|
|
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:
|
|
with self.session(actor):
|
|
key = str(permission.uuid)
|
|
if key not in self._data.permissions:
|
|
raise ValueError("Permission not found")
|
|
self._data.permissions[key].scope = permission.scope
|
|
self._data.permissions[key].display_name = permission.display_name
|
|
self._data.permissions[key].domain = permission.domain
|
|
|
|
def delete_permission(self, permission_id: str, actor: str = "system") -> None:
|
|
"""Delete a permission by UUID string or scope."""
|
|
with self.session(actor):
|
|
# Find the UUID key
|
|
key = self._resolve_permission_key(permission_id)
|
|
if key and key in self._data.permissions:
|
|
scope = self._data.permissions[key].scope
|
|
del self._data.permissions[key]
|
|
# Remove from roles (roles store scopes, not UUIDs)
|
|
for r in self._data.roles.values():
|
|
if scope in r.permissions:
|
|
del r.permissions[scope]
|
|
|
|
def _resolve_permission_key(self, permission_id: str) -> str | None:
|
|
"""Resolve a permission_id (UUID or scope) to its UUID key."""
|
|
if permission_id in self._data.permissions:
|
|
return permission_id
|
|
for pid, p in self._data.permissions.items():
|
|
if p.scope == permission_id:
|
|
return pid
|
|
return None
|
|
|
|
def _resolve_permission_scope(self, permission_id: str) -> str | None:
|
|
"""Resolve a permission_id (UUID or scope) to its scope."""
|
|
if permission_id in self._data.permissions:
|
|
return self._data.permissions[permission_id].scope
|
|
for pid, p in self._data.permissions.items():
|
|
if p.scope == permission_id:
|
|
return p.scope
|
|
return None
|
|
|
|
def rename_permission(
|
|
self,
|
|
old_scope: str,
|
|
new_scope: str,
|
|
display_name: str,
|
|
domain: str | None = None,
|
|
actor: str = "system",
|
|
) -> None:
|
|
"""Rename a permission's scope. The UUID remains the same."""
|
|
with self.session(actor):
|
|
# Find the permission by scope
|
|
key = self._resolve_permission_key(old_scope)
|
|
if not key:
|
|
raise ValueError("Original permission not found")
|
|
|
|
# Check if new scope already exists
|
|
for pid, p in self._data.permissions.items():
|
|
if p.scope == new_scope and pid != key:
|
|
raise ValueError("New permission scope already exists")
|
|
|
|
old_scope_value = self._data.permissions[key].scope
|
|
|
|
# Update the permission
|
|
self._data.permissions[key].scope = new_scope
|
|
self._data.permissions[key].display_name = display_name
|
|
self._data.permissions[key].domain = domain
|
|
|
|
# Update role references if scope changed
|
|
if old_scope_value != new_scope:
|
|
for r in self._data.roles.values():
|
|
if old_scope_value in r.permissions:
|
|
del r.permissions[old_scope_value]
|
|
r.permissions[new_scope] = True
|
|
|
|
def add_permission_to_organization(
|
|
self, org_id: str, permission_id: str, actor: str = "system"
|
|
) -> None:
|
|
"""Add a permission to an organization (allows org to grant it).
|
|
|
|
permission_id can be a UUID string or a scope string.
|
|
"""
|
|
with self.session(actor):
|
|
if org_id not in self._data.orgs:
|
|
raise ValueError("Organization not found")
|
|
key = self._resolve_permission_key(permission_id)
|
|
if not key:
|
|
raise ValueError("Permission not found")
|
|
self._data.permissions[key].orgs[org_id] = True
|
|
|
|
def remove_permission_from_organization(
|
|
self, org_id: str, permission_id: str, actor: str = "system"
|
|
) -> None:
|
|
"""Remove a permission from an organization.
|
|
|
|
permission_id can be a UUID string or a scope string.
|
|
"""
|
|
with self.session(actor):
|
|
key = self._resolve_permission_key(permission_id)
|
|
if key and key in self._data.permissions:
|
|
orgs = self._data.permissions[key].orgs
|
|
if org_id in orgs:
|
|
del orgs[org_id]
|
|
|
|
def get_organization_permissions(self, org_id: str) -> list[Permission]:
|
|
with self._lock:
|
|
if org_id not in self._data.orgs:
|
|
raise ValueError("Organization not found")
|
|
permissions = []
|
|
for pid, p in self._data.permissions.items():
|
|
if org_id in p.orgs:
|
|
permissions.append(
|
|
Permission(
|
|
uuid=UUID(pid),
|
|
scope=p.scope,
|
|
display_name=p.display_name,
|
|
domain=p.domain,
|
|
)
|
|
)
|
|
return permissions
|
|
|
|
def get_permission_organizations(self, permission_id: str) -> list[Org]:
|
|
"""Get organizations that can grant a permission.
|
|
|
|
permission_id can be a UUID string or a scope string.
|
|
"""
|
|
with self._lock:
|
|
key = self._resolve_permission_key(permission_id)
|
|
if not key or key not in self._data.permissions:
|
|
return []
|
|
org_ids = self._data.permissions[key].orgs
|
|
return [
|
|
self._build_org(org_id)
|
|
for org_id in org_ids
|
|
if org_id in self._data.orgs
|
|
]
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Role-permission operations
|
|
# -------------------------------------------------------------------------
|
|
|
|
def add_permission_to_role(
|
|
self, role_uuid: UUID, permission_id: str, actor: str = "system"
|
|
) -> None:
|
|
"""Add a permission to a role.
|
|
|
|
permission_id can be a UUID string or a scope string.
|
|
Stores the scope in the role's permissions dict.
|
|
"""
|
|
with self.session(actor):
|
|
key = str(role_uuid)
|
|
if key not in self._data.roles:
|
|
raise ValueError("Role not found")
|
|
scope = self._resolve_permission_scope(permission_id)
|
|
if not scope:
|
|
raise ValueError("Permission not found")
|
|
self._data.roles[key].permissions[scope] = True
|
|
|
|
def remove_permission_from_role(
|
|
self, role_uuid: UUID, permission_id: str, actor: str = "system"
|
|
) -> None:
|
|
"""Remove a permission from a role.
|
|
|
|
permission_id can be a UUID string or a scope string.
|
|
"""
|
|
with self.session(actor):
|
|
key = str(role_uuid)
|
|
if key in self._data.roles:
|
|
# Try to find the scope
|
|
scope = self._resolve_permission_scope(permission_id)
|
|
if scope and scope in self._data.roles[key].permissions:
|
|
del self._data.roles[key].permissions[scope]
|
|
# Also try the raw permission_id in case it's already a scope
|
|
elif permission_id in self._data.roles[key].permissions:
|
|
del self._data.roles[key].permissions[permission_id]
|
|
|
|
def get_role_permissions(self, role_uuid: UUID) -> list[Permission]:
|
|
"""Get permissions granted by a role.
|
|
|
|
Note: Roles store scopes, so we need to look up permissions by scope.
|
|
"""
|
|
with self._lock:
|
|
key = str(role_uuid)
|
|
if key not in self._data.roles:
|
|
return []
|
|
scopes = list(self._data.roles[key].permissions.keys())
|
|
permissions = []
|
|
for scope in scopes:
|
|
# Find permission with this scope
|
|
for pid, p in self._data.permissions.items():
|
|
if p.scope == scope:
|
|
permissions.append(
|
|
Permission(
|
|
uuid=UUID(pid),
|
|
scope=p.scope,
|
|
display_name=p.display_name,
|
|
domain=p.domain,
|
|
)
|
|
)
|
|
break
|
|
return permissions
|
|
|
|
def get_permission_roles(self, permission_id: str) -> list[Role]:
|
|
"""Get roles that have a permission.
|
|
|
|
permission_id can be a UUID string or a scope string.
|
|
"""
|
|
with self._lock:
|
|
scope = self._resolve_permission_scope(permission_id)
|
|
if not scope:
|
|
return []
|
|
return [
|
|
self._build_role(role_uuid)
|
|
for role_uuid, r in self._data.roles.items()
|
|
if scope in r.permissions
|
|
]
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Combined operations
|
|
# -------------------------------------------------------------------------
|
|
|
|
def login(
|
|
self, user_uuid: UUID, credential: Credential, actor: str = "system"
|
|
) -> None:
|
|
with self.session(actor):
|
|
# Update credential
|
|
for key, c in self._data.credentials.items():
|
|
if c.credential_id == credential.credential_id:
|
|
c.sign_count = credential.sign_count
|
|
c.created_at = credential.created_at
|
|
c.last_used = credential.last_used
|
|
c.last_verified = credential.last_verified
|
|
break
|
|
|
|
# Update user
|
|
user_key = str(user_uuid)
|
|
if user_key in self._data.users:
|
|
self._data.users[user_key].last_seen = credential.last_used
|
|
self._data.users[user_key].visits = (
|
|
self._data.users[user_key].visits + 1
|
|
)
|
|
|
|
def create_user_and_credential(
|
|
self, user: User, credential: Credential, actor: str = "system"
|
|
) -> None:
|
|
with self.session(actor):
|
|
# Create user
|
|
user_key = str(user.uuid)
|
|
self._data.users[user_key] = _UserData(
|
|
display_name=user.display_name,
|
|
role=str(user.role_uuid),
|
|
created_at=user.created_at or datetime.now(timezone.utc),
|
|
last_seen=user.last_seen,
|
|
visits=user.visits,
|
|
)
|
|
# Create credential
|
|
cred_key = str(credential.uuid)
|
|
self._data.credentials[cred_key] = _CredentialData(
|
|
credential_id=credential.credential_id, # Store bytes directly
|
|
user=str(credential.user_uuid),
|
|
aaguid=str(credential.aaguid),
|
|
public_key=credential.public_key, # Store bytes directly
|
|
sign_count=credential.sign_count,
|
|
created_at=credential.created_at,
|
|
last_used=credential.last_used,
|
|
last_verified=credential.last_verified,
|
|
)
|
|
|
|
def create_credential_session(
|
|
self,
|
|
user_uuid: UUID,
|
|
credential: Credential,
|
|
reset_key: bytes | None,
|
|
session_key: bytes,
|
|
*,
|
|
display_name: str | None = None,
|
|
host: str | None = None,
|
|
ip: str | None = None,
|
|
user_agent: str | None = None,
|
|
actor: str = "system",
|
|
) -> None:
|
|
with self.session(actor):
|
|
user_key = str(user_uuid)
|
|
# Ensure credential has last_used / last_verified
|
|
if credential.last_used is None:
|
|
credential.last_used = credential.created_at
|
|
if credential.last_verified is None:
|
|
credential.last_verified = credential.last_used
|
|
|
|
# Insert credential
|
|
cred_key = str(credential.uuid)
|
|
self._data.credentials[cred_key] = _CredentialData(
|
|
credential_id=credential.credential_id, # Store bytes directly
|
|
user=str(credential.user_uuid),
|
|
aaguid=str(credential.aaguid),
|
|
public_key=credential.public_key, # Store bytes directly
|
|
sign_count=credential.sign_count,
|
|
created_at=credential.created_at,
|
|
last_used=credential.last_used,
|
|
last_verified=credential.last_verified,
|
|
)
|
|
|
|
# Delete old reset token if provided
|
|
if reset_key:
|
|
reset_key_b64 = _bytes_to_str(reset_key)
|
|
if reset_key_b64 in self._data.reset_tokens:
|
|
del self._data.reset_tokens[reset_key_b64]
|
|
|
|
# Optional rename
|
|
if display_name and user_key in self._data.users:
|
|
self._data.users[user_key].display_name = display_name
|
|
|
|
# New session - compute expiry from credential.last_used
|
|
sess_key_b64 = _bytes_to_str(session_key)
|
|
self._data.sessions[sess_key_b64] = _SessionData(
|
|
user=user_key,
|
|
credential=cred_key,
|
|
host=host,
|
|
ip=ip,
|
|
user_agent=user_agent,
|
|
expiry=credential.last_used + SESSION_LIFETIME,
|
|
)
|
|
|
|
# Login side-effects
|
|
if user_key in self._data.users:
|
|
self._data.users[user_key].last_seen = credential.last_used
|
|
self._data.users[user_key].visits = (
|
|
self._data.users[user_key].visits + 1
|
|
)
|
|
|
|
def cleanup(self) -> None:
|
|
"""Remove expired sessions and reset tokens."""
|
|
with self.session("expiry"):
|
|
current_time = datetime.now(timezone.utc)
|
|
|
|
# Clean expired sessions
|
|
to_delete_sessions = []
|
|
for k, s in self._data.sessions.items():
|
|
if s.expiry < current_time:
|
|
to_delete_sessions.append(k)
|
|
for k in to_delete_sessions:
|
|
del self._data.sessions[k]
|
|
|
|
# Clean expired reset tokens
|
|
to_delete_tokens = []
|
|
for k, t in self._data.reset_tokens.items():
|
|
if t.expiry < current_time:
|
|
to_delete_tokens.append(k)
|
|
for k in to_delete_tokens:
|
|
del self._data.reset_tokens[k]
|
|
|
|
def get_session_context(
|
|
self, session_key: bytes, host: str | None = None
|
|
) -> SessionContext | None:
|
|
"""Get full authentication context from a session key.
|
|
|
|
This is the primary method for validating sessions and getting all
|
|
associated user/org/role/credential data in a single call.
|
|
"""
|
|
with self._lock:
|
|
sess_key_b64 = _bytes_to_str(session_key)
|
|
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
|
|
|
|
# Validate user exists
|
|
user_key = s.user
|
|
if user_key not in self._data.users:
|
|
return None
|
|
|
|
# Validate role exists
|
|
role_uuid = self._data.users[user_key].role
|
|
if role_uuid not in self._data.roles:
|
|
return None
|
|
|
|
# Validate org exists
|
|
org_uuid = self._data.roles[role_uuid].org
|
|
if org_uuid not in self._data.orgs:
|
|
return None
|
|
|
|
# Build objects using helpers
|
|
session_obj = self._build_session(sess_key_b64)
|
|
user_obj = self._build_user(user_key)
|
|
role_obj = self._build_role(role_uuid)
|
|
org_obj = self._build_org(org_uuid)
|
|
|
|
# Get credential (optional)
|
|
cred_uuid = s.credential
|
|
credential_obj = (
|
|
self._build_credential(cred_uuid)
|
|
if cred_uuid in self._data.credentials
|
|
else None
|
|
)
|
|
|
|
# Effective permissions: role permissions (scopes) that the org can grant
|
|
# role_obj.permissions contains scopes, org_obj.permissions contains scopes
|
|
from paskia.util.hostutil import normalize_host
|
|
|
|
normalized_host = normalize_host(host)
|
|
effective_permissions = []
|
|
for scope in role_obj.permissions:
|
|
if scope not in org_obj.permissions:
|
|
continue
|
|
# Find the permission by scope
|
|
for pid, p in self._data.permissions.items():
|
|
if p.scope == scope:
|
|
# Check domain restriction
|
|
if p.domain is not None and p.domain != normalized_host:
|
|
continue
|
|
effective_permissions.append(
|
|
Permission(
|
|
uuid=UUID(pid),
|
|
scope=p.scope,
|
|
display_name=p.display_name,
|
|
domain=p.domain,
|
|
)
|
|
)
|
|
break
|
|
|
|
return SessionContext(
|
|
session=session_obj,
|
|
user=user_obj,
|
|
org=org_obj,
|
|
role=role_obj,
|
|
credential=credential_obj,
|
|
permissions=effective_permissions or None,
|
|
)
|