1317 lines
49 KiB
Python
1317 lines
49 KiB
Python
"""
|
|
Async 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.
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
from contextlib import asynccontextmanager
|
|
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"
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Public data types (msgspec Structs)
|
|
# -------------------------------------------------------------------------
|
|
|
|
|
|
class Permission(msgspec.Struct):
|
|
"""A permission that can be granted to roles."""
|
|
|
|
id: str # String primary key (max 128 chars)
|
|
display_name: str
|
|
|
|
|
|
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
|
|
renewed: datetime
|
|
|
|
def metadata(self) -> dict:
|
|
"""Return session metadata for backwards compatibility."""
|
|
return {
|
|
"ip": self.ip,
|
|
"user_agent": self.user_agent,
|
|
"renewed": self.renewed.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):
|
|
display_name: str
|
|
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
|
|
renewed: 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
|
|
|
|
|
|
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 init(*args, **kwargs):
|
|
"""Initialize the global database instance."""
|
|
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)
|
|
await _db.init_db()
|
|
|
|
|
|
class DB:
|
|
"""JSON-based database implementation.
|
|
|
|
Maintains data in memory and persists to disk on every change.
|
|
Uses nested dictionaries keyed by UUID strings for efficient lookup.
|
|
|
|
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._lock = asyncio.Lock()
|
|
|
|
def _empty_data(self) -> _DatabaseData:
|
|
"""Return an empty database structure."""
|
|
return _DatabaseData(
|
|
permissions={},
|
|
orgs={},
|
|
roles={},
|
|
users={},
|
|
credentials={},
|
|
sessions={},
|
|
reset_tokens={},
|
|
)
|
|
|
|
async 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.
|
|
"""
|
|
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)
|
|
|
|
async def _save(self, actor: str = "system") -> None:
|
|
"""Append change record to JSONL file."""
|
|
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 save if there are changes
|
|
if diff:
|
|
change_record = _ChangeRecord(
|
|
ts=datetime.now(timezone.utc),
|
|
actor=actor,
|
|
diff=diff,
|
|
)
|
|
|
|
# Encode and append to file
|
|
data = _json_encoder.encode(change_record)
|
|
line = data.decode("utf-8") + "\n"
|
|
|
|
# Append atomically (create temp file, then append)
|
|
tmp_path = self.db_path.with_suffix(".tmp")
|
|
try:
|
|
# Read existing content
|
|
existing_content = ""
|
|
if self.db_path.exists():
|
|
existing_content = await asyncio.to_thread(
|
|
self.db_path.read_text, "utf-8"
|
|
)
|
|
|
|
# Append new line
|
|
new_content = existing_content + line
|
|
|
|
# Write to temp file and rename
|
|
await asyncio.to_thread(tmp_path.write_text, new_content, "utf-8")
|
|
await asyncio.to_thread(tmp_path.replace, self.db_path)
|
|
|
|
# Update previous builtins for next diff (to_builtins creates a copy)
|
|
self._previous_builtins = current_builtins
|
|
except OSError:
|
|
# Clean up temp file on error
|
|
if tmp_path.exists():
|
|
await asyncio.to_thread(tmp_path.unlink)
|
|
|
|
@asynccontextmanager
|
|
async def session(self):
|
|
"""Context manager for atomic operations with save on exit."""
|
|
async with self._lock:
|
|
yield
|
|
await self._save()
|
|
|
|
async def init_db(self) -> None:
|
|
"""Initialize database (load from disk)."""
|
|
async with self._lock:
|
|
await self._load()
|
|
|
|
# -------------------------------------------------------------------------
|
|
# User operations
|
|
# -------------------------------------------------------------------------
|
|
|
|
async def get_user_by_uuid(self, user_uuid: UUID) -> User:
|
|
async with self._lock:
|
|
key = str(user_uuid)
|
|
if key not in self._data.users:
|
|
raise ValueError("User not found")
|
|
u = self._data.users[key]
|
|
return User(
|
|
uuid=user_uuid, # Use the key directly
|
|
display_name=u.display_name,
|
|
role_uuid=UUID(u.role),
|
|
created_at=u.created_at,
|
|
last_seen=u.last_seen,
|
|
visits=u.visits,
|
|
)
|
|
|
|
async def create_user(self, user: User) -> None:
|
|
async with self.session():
|
|
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,
|
|
)
|
|
|
|
async def update_user_display_name(
|
|
self, user_uuid: UUID, display_name: str
|
|
) -> None:
|
|
async with self.session():
|
|
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
|
|
# -------------------------------------------------------------------------
|
|
|
|
async def create_role(self, role: Role) -> None:
|
|
async with self.session():
|
|
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 {},
|
|
)
|
|
|
|
async def update_role(self, role: Role) -> None:
|
|
async with self.session():
|
|
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 {}
|
|
)
|
|
|
|
async def delete_role(self, role_uuid: UUID) -> None:
|
|
async with self.session():
|
|
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]
|
|
|
|
async def get_role(self, role_uuid: UUID) -> Role:
|
|
async with self._lock:
|
|
key = str(role_uuid)
|
|
if key not in self._data.roles:
|
|
raise ValueError("Role not found")
|
|
r = self._data.roles[key]
|
|
return Role(
|
|
uuid=role_uuid, # Use the key directly
|
|
org_uuid=UUID(r.org),
|
|
display_name=r.display_name,
|
|
permissions=list(r.permissions),
|
|
)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Credential operations
|
|
# -------------------------------------------------------------------------
|
|
|
|
async def create_credential(self, credential: Credential) -> None:
|
|
async with self.session():
|
|
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,
|
|
)
|
|
|
|
async def get_credential_by_id(self, credential_id: bytes) -> Credential:
|
|
async with self._lock:
|
|
for key, c in self._data.credentials.items():
|
|
if c.credential_id == credential_id:
|
|
return Credential(
|
|
uuid=UUID(key), # Use the key directly
|
|
credential_id=c.credential_id, # Already bytes
|
|
user_uuid=UUID(c.user),
|
|
aaguid=UUID(c.aaguid),
|
|
public_key=c.public_key, # Already bytes
|
|
sign_count=c.sign_count,
|
|
created_at=c.created_at, # Already datetime
|
|
last_used=c.last_used,
|
|
last_verified=c.last_verified,
|
|
)
|
|
raise ValueError("Credential not found")
|
|
|
|
async def get_credentials_by_user_uuid(self, user_uuid: UUID) -> list[bytes]:
|
|
async 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
|
|
|
|
async def update_credential(self, credential: Credential) -> None:
|
|
async with self.session():
|
|
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")
|
|
|
|
async def delete_credential(self, uuid: UUID, user_uuid: UUID) -> None:
|
|
async with self.session():
|
|
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
|
|
# -------------------------------------------------------------------------
|
|
|
|
async def create_session(
|
|
self,
|
|
user_uuid: UUID,
|
|
key: bytes,
|
|
credential_uuid: UUID,
|
|
host: str,
|
|
ip: str,
|
|
user_agent: str,
|
|
renewed: datetime,
|
|
) -> None:
|
|
async with self.session():
|
|
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,
|
|
renewed=renewed,
|
|
)
|
|
|
|
async def get_session(self, key: bytes) -> Session | None:
|
|
async with self._lock:
|
|
key_b64 = _bytes_to_str(key)
|
|
if key_b64 not in self._data.sessions:
|
|
return None
|
|
s = self._data.sessions[key_b64]
|
|
return Session(
|
|
key=_str_to_bytes(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,
|
|
renewed=s.renewed, # Already datetime
|
|
)
|
|
|
|
async def delete_session(self, key: bytes) -> None:
|
|
async with self.session():
|
|
key_b64 = _bytes_to_str(key)
|
|
if key_b64 in self._data.sessions:
|
|
del self._data.sessions[key_b64]
|
|
|
|
async def update_session(
|
|
self,
|
|
key: bytes,
|
|
*,
|
|
ip: str,
|
|
user_agent: str,
|
|
renewed: datetime,
|
|
) -> Session | None:
|
|
async with self.session():
|
|
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.renewed = renewed
|
|
return Session(
|
|
key=_str_to_bytes(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,
|
|
renewed=s.renewed, # Already datetime
|
|
)
|
|
|
|
async def set_session_host(self, key: bytes, host: str) -> None:
|
|
async with self.session():
|
|
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
|
|
|
|
async def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]:
|
|
async 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(
|
|
Session(
|
|
key=key_bytes,
|
|
user_uuid=UUID(s.user),
|
|
credential_uuid=UUID(s.credential),
|
|
host=s.host,
|
|
ip=s.ip,
|
|
user_agent=s.user_agent,
|
|
renewed=s.renewed, # Already datetime
|
|
)
|
|
)
|
|
# Sort by renewed desc
|
|
sessions.sort(key=lambda x: x.renewed, reverse=True)
|
|
return sessions
|
|
|
|
async def delete_sessions_for_user(self, user_uuid: UUID) -> None:
|
|
async with self.session():
|
|
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
|
|
# -------------------------------------------------------------------------
|
|
|
|
async def create_reset_token(
|
|
self,
|
|
user_uuid: UUID,
|
|
key: bytes,
|
|
expiry: datetime,
|
|
token_type: str,
|
|
) -> None:
|
|
async with self.session():
|
|
key_b64 = _bytes_to_str(key)
|
|
self._data.reset_tokens[key_b64] = _ResetTokenData(
|
|
user=str(user_uuid),
|
|
expiry=expiry,
|
|
token_type=token_type,
|
|
)
|
|
|
|
async def get_reset_token(self, key: bytes) -> ResetToken | None:
|
|
async 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,
|
|
)
|
|
|
|
async def delete_reset_token(self, key: bytes) -> None:
|
|
async with self.session():
|
|
key_b64 = _bytes_to_str(key)
|
|
if key_b64 in self._data.reset_tokens:
|
|
del self._data.reset_tokens[key_b64]
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Organization operations
|
|
# -------------------------------------------------------------------------
|
|
|
|
async def create_organization(self, org: Org) -> None:
|
|
async with self.session():
|
|
key = str(org.uuid)
|
|
self._data.orgs[key] = _OrgData(
|
|
display_name=org.display_name,
|
|
)
|
|
|
|
# Update permissions to allow this org to grant them
|
|
for perm_id in org.permissions:
|
|
if perm_id in self._data.permissions:
|
|
self._data.permissions[perm_id].orgs[key] = True
|
|
|
|
# Automatically create an organization admin permission if not present
|
|
auto_perm_id = f"auth:org:{org.uuid}"
|
|
if auto_perm_id not in self._data.permissions:
|
|
self._data.permissions[auto_perm_id] = _PermissionData(
|
|
display_name=f"{org.display_name} Admin",
|
|
orgs={key: True}, # This org can grant its own admin permission
|
|
)
|
|
else:
|
|
# Ensure this org can grant its own admin permission
|
|
self._data.permissions[auto_perm_id].orgs[key] = True
|
|
# Reflect the automatically added permission in the dataclass instance
|
|
if auto_perm_id not in org.permissions:
|
|
org.permissions.append(auto_perm_id)
|
|
|
|
async def get_organization(self, org_id: str) -> Org:
|
|
async with self._lock:
|
|
# org_id is a UUID string
|
|
if org_id not in self._data.orgs:
|
|
raise ValueError("Organization not found")
|
|
o = self._data.orgs[org_id]
|
|
# Get permissions that this org can grant
|
|
permissions = []
|
|
for perm_id, p in self._data.permissions.items():
|
|
if org_id in p.orgs:
|
|
permissions.append(perm_id)
|
|
org = Org(
|
|
uuid=UUID(org_id), # Use the key directly
|
|
display_name=o.display_name,
|
|
permissions=permissions,
|
|
)
|
|
# Load roles for this org
|
|
roles = []
|
|
for role_uuid_str, r in self._data.roles.items():
|
|
if r.org == org_id:
|
|
roles.append(
|
|
Role(
|
|
uuid=UUID(role_uuid_str), # Use the key directly
|
|
org_uuid=UUID(r.org),
|
|
display_name=r.display_name,
|
|
permissions=list(r.permissions),
|
|
)
|
|
)
|
|
org.roles = roles
|
|
return org
|
|
|
|
async def list_organizations(self) -> list[Org]:
|
|
async with self._lock:
|
|
orgs = []
|
|
for org_uuid_str, o in self._data.orgs.items():
|
|
# Get permissions that this org can grant
|
|
permissions = []
|
|
for perm_id, p in self._data.permissions.items():
|
|
if org_uuid_str in p.orgs:
|
|
permissions.append(perm_id)
|
|
org = Org(
|
|
uuid=UUID(org_uuid_str), # Use the key directly
|
|
display_name=o.display_name,
|
|
permissions=permissions,
|
|
)
|
|
# Load roles for this org
|
|
roles = []
|
|
for role_uuid_str, r in self._data.roles.items():
|
|
if r.org == org_uuid_str:
|
|
roles.append(
|
|
Role(
|
|
uuid=UUID(role_uuid_str), # Use the key directly
|
|
org_uuid=UUID(r.org),
|
|
display_name=r.display_name,
|
|
permissions=list(r.permissions),
|
|
)
|
|
)
|
|
org.roles = roles
|
|
orgs.append(org)
|
|
return orgs
|
|
|
|
async def update_organization(self, org: Org) -> None:
|
|
async with self.session():
|
|
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
|
|
# 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
|
|
for perm_id in org.permissions:
|
|
if perm_id in self._data.permissions:
|
|
self._data.permissions[perm_id].orgs[key] = True
|
|
|
|
async def delete_organization(self, org_uuid: UUID) -> None:
|
|
async with self.session():
|
|
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]
|
|
|
|
async def add_user_to_organization(
|
|
self, user_uuid: UUID, org_id: str, role: str
|
|
) -> None:
|
|
async with self.session():
|
|
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
|
|
|
|
async 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")
|
|
|
|
async def get_user_organization(self, user_uuid: UUID) -> tuple[Org, str]:
|
|
async 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]
|
|
org_uuid = r.org
|
|
if org_uuid not in self._data.orgs:
|
|
raise ValueError("Organization not found")
|
|
o = self._data.orgs[org_uuid]
|
|
org = Org(
|
|
uuid=UUID(org_uuid),
|
|
display_name=o.display_name,
|
|
permissions=[], # Could populate from permissions if needed
|
|
)
|
|
return org, r.display_name
|
|
|
|
async def get_organization_users(self, org_id: str) -> list[tuple[User, str]]:
|
|
async with self._lock:
|
|
# Get all roles for this org
|
|
org_role_uuids = {
|
|
role_uuid_str
|
|
for role_uuid_str, r in self._data.roles.items()
|
|
if r.org == org_id
|
|
}
|
|
results = []
|
|
for user_uuid_str, u in self._data.users.items():
|
|
if u.role in org_role_uuids:
|
|
role_name = self._data.roles[u.role].display_name
|
|
user = User(
|
|
uuid=UUID(user_uuid_str),
|
|
display_name=u.display_name,
|
|
role_uuid=UUID(u.role),
|
|
created_at=u.created_at,
|
|
last_seen=u.last_seen,
|
|
visits=u.visits,
|
|
)
|
|
results.append((user, role_name))
|
|
return results
|
|
|
|
async def get_roles_by_organization(self, org_id: str) -> list[Role]:
|
|
async with self._lock:
|
|
roles = []
|
|
for role_uuid_str, r in self._data.roles.items():
|
|
if r.org == org_id:
|
|
roles.append(
|
|
Role(
|
|
uuid=UUID(role_uuid_str), # Use the key directly
|
|
org_uuid=UUID(r.org),
|
|
display_name=r.display_name,
|
|
permissions=list(r.permissions),
|
|
)
|
|
)
|
|
return roles
|
|
|
|
async def get_user_role_in_organization(
|
|
self, user_uuid: UUID, org_id: str
|
|
) -> str | None:
|
|
async 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
|
|
|
|
async def update_user_role_in_organization(
|
|
self, user_uuid: UUID, new_role: str
|
|
) -> None:
|
|
async with self.session():
|
|
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
|
|
# -------------------------------------------------------------------------
|
|
|
|
async def create_permission(self, permission: Permission) -> None:
|
|
async with self.session():
|
|
self._data.permissions[permission.id] = _PermissionData(
|
|
display_name=permission.display_name,
|
|
orgs={}, # Will be populated when orgs are allowed to grant this permission
|
|
)
|
|
|
|
async def get_permission(self, permission_id: str) -> Permission:
|
|
async with self._lock:
|
|
if permission_id not in self._data.permissions:
|
|
raise ValueError("Permission not found")
|
|
p = self._data.permissions[permission_id]
|
|
return Permission(id=permission_id, display_name=p.display_name)
|
|
|
|
async def list_permissions(self) -> list[Permission]:
|
|
async with self._lock:
|
|
return [
|
|
Permission(id=pid, display_name=p.display_name)
|
|
for pid, p in self._data.permissions.items()
|
|
]
|
|
|
|
async def update_permission(self, permission: Permission) -> None:
|
|
async with self.session():
|
|
if permission.id not in self._data.permissions:
|
|
raise ValueError("Permission not found")
|
|
self._data.permissions[permission.id].display_name = permission.display_name
|
|
|
|
async def delete_permission(self, permission_id: str) -> None:
|
|
async with self.session():
|
|
if permission_id in self._data.permissions:
|
|
del self._data.permissions[permission_id]
|
|
# Remove from roles (permissions is a dict)
|
|
for r in self._data.roles.values():
|
|
if permission_id in r.permissions:
|
|
del r.permissions[permission_id]
|
|
|
|
async def rename_permission(
|
|
self, old_id: str, new_id: str, display_name: str
|
|
) -> None:
|
|
async with self.session():
|
|
if old_id == new_id:
|
|
if old_id in self._data.permissions:
|
|
self._data.permissions[old_id].display_name = display_name
|
|
return
|
|
if old_id not in self._data.permissions:
|
|
raise ValueError("Original permission not found")
|
|
if new_id in self._data.permissions:
|
|
raise ValueError("New permission id already exists")
|
|
|
|
# Create new permission with same orgs
|
|
old_perm = self._data.permissions[old_id]
|
|
self._data.permissions[new_id] = _PermissionData(
|
|
display_name=display_name,
|
|
orgs=dict(old_perm.orgs),
|
|
)
|
|
# Update role references (roles store permissions as dict)
|
|
for r in self._data.roles.values():
|
|
if old_id in r.permissions:
|
|
del r.permissions[old_id]
|
|
r.permissions[new_id] = True
|
|
# Delete old permission
|
|
del self._data.permissions[old_id]
|
|
|
|
async def add_permission_to_organization(
|
|
self, org_id: str, permission_id: str
|
|
) -> None:
|
|
async with self.session():
|
|
if org_id not in self._data.orgs:
|
|
raise ValueError("Organization not found")
|
|
if permission_id not in self._data.permissions:
|
|
raise ValueError("Permission not found")
|
|
self._data.permissions[permission_id].orgs[org_id] = True
|
|
|
|
async def remove_permission_from_organization(
|
|
self, org_id: str, permission_id: str
|
|
) -> None:
|
|
async with self.session():
|
|
if permission_id in self._data.permissions:
|
|
orgs = self._data.permissions[permission_id].orgs
|
|
if org_id in orgs:
|
|
del orgs[org_id]
|
|
|
|
async def get_organization_permissions(self, org_id: str) -> list[Permission]:
|
|
async 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(id=pid, display_name=p.display_name))
|
|
return permissions
|
|
|
|
async def get_permission_organizations(self, permission_id: str) -> list[Org]:
|
|
async with self._lock:
|
|
if permission_id not in self._data.permissions:
|
|
return []
|
|
org_ids = self._data.permissions[permission_id].orgs
|
|
orgs = []
|
|
for org_id in org_ids:
|
|
if org_id in self._data.orgs:
|
|
o = self._data.orgs[org_id]
|
|
# Get permissions for this org
|
|
permissions = []
|
|
for pid, p in self._data.permissions.items():
|
|
if org_id in p.orgs:
|
|
permissions.append(pid)
|
|
orgs.append(
|
|
Org(
|
|
uuid=UUID(org_id),
|
|
display_name=o.display_name,
|
|
permissions=permissions,
|
|
)
|
|
)
|
|
return orgs
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Role-permission operations
|
|
# -------------------------------------------------------------------------
|
|
|
|
async def add_permission_to_role(self, role_uuid: UUID, permission_id: str) -> None:
|
|
async with self.session():
|
|
key = str(role_uuid)
|
|
if key not in self._data.roles:
|
|
raise ValueError("Role not found")
|
|
if permission_id not in self._data.permissions:
|
|
raise ValueError("Permission not found")
|
|
self._data.roles[key].permissions[permission_id] = True
|
|
|
|
async def remove_permission_from_role(
|
|
self, role_uuid: UUID, permission_id: str
|
|
) -> None:
|
|
async with self.session():
|
|
key = str(role_uuid)
|
|
if key in self._data.roles:
|
|
if permission_id in self._data.roles[key].permissions:
|
|
del self._data.roles[key].permissions[permission_id]
|
|
|
|
async def get_role_permissions(self, role_uuid: UUID) -> list[Permission]:
|
|
async with self._lock:
|
|
key = str(role_uuid)
|
|
if key not in self._data.roles:
|
|
return []
|
|
perm_ids = list(self._data.roles[key].permissions)
|
|
permissions = []
|
|
for pid in perm_ids:
|
|
if pid in self._data.permissions:
|
|
p = self._data.permissions[pid]
|
|
permissions.append(Permission(id=pid, display_name=p.display_name))
|
|
return permissions
|
|
|
|
async def get_permission_roles(self, permission_id: str) -> list[Role]:
|
|
async with self._lock:
|
|
roles = []
|
|
for role_uuid_str, r in self._data.roles.items():
|
|
if permission_id in r.permissions:
|
|
roles.append(
|
|
Role(
|
|
uuid=UUID(role_uuid_str), # Use the key directly
|
|
org_uuid=UUID(r.org),
|
|
display_name=r.display_name,
|
|
permissions=list(r.permissions),
|
|
)
|
|
)
|
|
return roles
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Combined operations
|
|
# -------------------------------------------------------------------------
|
|
|
|
async def login(self, user_uuid: UUID, credential: Credential) -> None:
|
|
async with self.session():
|
|
# 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
|
|
)
|
|
|
|
async def create_user_and_credential(
|
|
self, user: User, credential: Credential
|
|
) -> None:
|
|
async with self.session():
|
|
# 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,
|
|
)
|
|
|
|
async 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,
|
|
) -> None:
|
|
async with self.session():
|
|
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
|
|
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,
|
|
renewed=credential.last_used,
|
|
)
|
|
|
|
# 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
|
|
)
|
|
|
|
async def cleanup(self) -> None:
|
|
async with self.session():
|
|
current_time = datetime.now(timezone.utc)
|
|
session_threshold = current_time - SESSION_LIFETIME
|
|
|
|
# Clean expired sessions
|
|
to_delete_sessions = []
|
|
for k, s in self._data.sessions.items():
|
|
renewed = s.renewed
|
|
if renewed and renewed < session_threshold:
|
|
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():
|
|
expiry = t.expiry
|
|
if expiry and expiry < current_time:
|
|
to_delete_tokens.append(k)
|
|
for k in to_delete_tokens:
|
|
del self._data.reset_tokens[k]
|
|
|
|
async def get_session_context(
|
|
self, session_key: bytes, host: str | None = None
|
|
) -> SessionContext | None:
|
|
# Need to acquire session lock for potential write (host binding)
|
|
async 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
|
|
# Mark for save
|
|
await self._save()
|
|
elif s.host != host:
|
|
return None
|
|
|
|
# Build session object
|
|
session_obj = 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,
|
|
renewed=s.renewed, # Already datetime
|
|
)
|
|
|
|
# Get user
|
|
user_key = s.user
|
|
if user_key not in self._data.users:
|
|
return None
|
|
u = self._data.users[user_key]
|
|
user_obj = User(
|
|
uuid=UUID(user_key),
|
|
display_name=u.display_name,
|
|
role_uuid=UUID(u.role),
|
|
created_at=u.created_at,
|
|
last_seen=u.last_seen,
|
|
visits=u.visits,
|
|
)
|
|
|
|
# Get role
|
|
role_uuid = u.role
|
|
if role_uuid not in self._data.roles:
|
|
return None
|
|
r = self._data.roles[role_uuid]
|
|
role_obj = Role(
|
|
uuid=UUID(role_uuid),
|
|
org_uuid=UUID(r.org),
|
|
display_name=r.display_name,
|
|
permissions=list(r.permissions),
|
|
)
|
|
|
|
# Get org
|
|
org_uuid = r.org
|
|
if org_uuid not in self._data.orgs:
|
|
return None
|
|
o = self._data.orgs[org_uuid]
|
|
org_obj = Org(
|
|
uuid=UUID(org_uuid), # Use the key directly
|
|
display_name=o.display_name,
|
|
permissions=[], # Could populate from permissions if needed
|
|
)
|
|
|
|
# Get credential (optional)
|
|
cred_uuid = s.credential
|
|
credential_obj = None
|
|
if cred_uuid in self._data.credentials:
|
|
c = self._data.credentials[cred_uuid]
|
|
credential_obj = Credential(
|
|
uuid=UUID(cred_uuid), # Use the key directly
|
|
credential_id=c.credential_id, # Already bytes
|
|
user_uuid=UUID(c.user),
|
|
aaguid=UUID(c.aaguid),
|
|
public_key=c.public_key, # Already bytes
|
|
sign_count=c.sign_count,
|
|
created_at=c.created_at, # Already datetime
|
|
last_used=c.last_used,
|
|
last_verified=c.last_verified,
|
|
)
|
|
|
|
# Collect permissions for the role
|
|
permissions = []
|
|
for pid in role_obj.permissions:
|
|
if pid in self._data.permissions:
|
|
p = self._data.permissions[pid]
|
|
permissions.append(Permission(id=pid, display_name=p.display_name))
|
|
|
|
# Filter effective permissions: only include permissions that the org can grant
|
|
effective_permissions = [
|
|
p for p in permissions if p.id in org_obj.permissions
|
|
]
|
|
|
|
# Filter effective permissions: only include permissions that the org can grant
|
|
effective_permissions = [
|
|
p for p in permissions if p.id in org_obj.permissions
|
|
]
|
|
|
|
return SessionContext(
|
|
session=session_obj,
|
|
user=user_obj,
|
|
org=org_obj,
|
|
role=role_obj,
|
|
credential=credential_obj,
|
|
permissions=effective_permissions if effective_permissions else None,
|
|
)
|