diff --git a/paskia/bootstrap.py b/paskia/bootstrap.py index 98880cd..6918d16 100644 --- a/paskia/bootstrap.py +++ b/paskia/bootstrap.py @@ -8,26 +8,11 @@ generating a reset link for initial admin setup. import asyncio import logging -from datetime import datetime, timezone -import uuid7 +from paskia import db +from paskia.util import hostutil -from paskia import authsession, db -from paskia.db import Org, Permission, Role, User -from paskia.util import hostutil, passphrase - - -def _init_logger() -> logging.Logger: - logger = logging.getLogger(__name__) - if not logger.handlers and not logging.getLogger().handlers: - h = logging.StreamHandler() - h.setFormatter(logging.Formatter("%(message)s")) - logger.addHandler(h) - logger.setLevel(logging.INFO) - return logger - - -logger = _init_logger() +logger = logging.getLogger(__name__) # Shared log message template for admin reset links ADMIN_RESET_MESSAGE = """\ @@ -38,17 +23,9 @@ ADMIN_RESET_MESSAGE = """\ """ -async def _create_and_log_admin_reset_link(user_uuid, message, session_type) -> str: - """Create an admin reset link and log it with the provided message.""" - token = passphrase.generate() - expiry = authsession.reset_expires() - db.create_reset_token( - user_uuid=user_uuid, - passphrase=token, - expiry=expiry, - token_type=session_type, - ) - reset_link = hostutil.reset_link_url(token) +def _log_reset_link(message: str, passphrase: str) -> str: + """Log a reset link message and return the URL.""" + reset_link = hostutil.reset_link_url(passphrase) logger.info(ADMIN_RESET_MESSAGE, message, reset_link) return reset_link @@ -57,63 +34,23 @@ async def bootstrap_system() -> dict: """ Bootstrap the entire system with default data. + Uses db.bootstrap() which performs all operations in a single transaction. + The transaction log will show a single "bootstrap" action with all changes. + Returns: dict: Contains information about created entities and reset link """ - # Create permission first - will fail if already exists - perm0 = Permission( - uuid=uuid7.create(), scope="auth:admin", display_name="Master Admin" - ) - db.create_permission(perm0) + # Call the single-transaction bootstrap function + result = db.bootstrap() - # Create org admin permission - allows managing users within an org - perm_org_admin = Permission( - uuid=uuid7.create(), scope="auth:org:admin", display_name="Org Admin" - ) - db.create_permission(perm_org_admin) - - org = Org(uuid7.create(), "Organization") - db.create_organization(org) - - # Allow this org to grant global admin and org admin permissions - db.add_permission_to_organization(str(org.uuid), perm0.scope) - db.add_permission_to_organization(str(org.uuid), perm_org_admin.scope) - - # Create an Administration role granting both org and global admin - role = Role( - uuid7.create(), - org.uuid, - "Administration", - permissions=[perm0.scope, perm_org_admin.scope], - ) - db.create_role(role) - - user = User( - uuid=uuid7.create(), - display_name="Admin", - role_uuid=role.uuid, - created_at=datetime.now(timezone.utc), - visits=0, - ) - db.create_user(user) - - # Generate reset link and log it - reset_link = await _create_and_log_admin_reset_link( - user.uuid, "✅ Bootstrap completed!", "admin bootstrap" - ) + # Log the reset link (this is separate from the transaction log) + reset_link = _log_reset_link("✅ Bootstrap completed!", result["reset_passphrase"]) return { - "user": user, - "org": org, - "role": role, - "permissions": [ - perm0, - *[ - db.get_permission_by_scope(p) - for p in org.permissions - if db.get_permission_by_scope(p) - ], - ], + "user": result["user"], + "org": result["org"], + "role": result["role"], + "permissions": [result["perm_admin"], result["perm_org_admin"]], "reset_link": reset_link, } @@ -145,11 +82,18 @@ async def check_admin_credentials() -> bool: if not credentials: # Admin exists but has no credentials, create reset link - await _create_and_log_admin_reset_link( - admin_user.uuid, - "⚠️ Admin user has no credentials!", - "admin registration", + from paskia import authsession + from paskia.util import passphrase + + token = passphrase.generate() + expiry = authsession.reset_expires() + db.create_reset_token( + user_uuid=admin_user.uuid, + passphrase=token, + expiry=expiry, + token_type="admin registration", ) + _log_reset_link("⚠️ Admin user has no credentials!", token) return True return False @@ -165,16 +109,12 @@ async def bootstrap_if_needed() -> bool: Returns: bool: True if bootstrapping was performed, False if system was already set up """ - try: - # Check if the admin permission exists - if it does, system is already bootstrapped - db.get_permission("auth:admin") + # Check if the admin permission exists - if it does, system is already bootstrapped + if db.get_permission_by_scope("auth:admin"): # Permission exists, system is already bootstrapped # Check if admin needs credentials (only for already-bootstrapped systems) await check_admin_credentials() return False - except Exception: - # Permission doesn't exist, need to bootstrap - pass # No admin permission found, need to bootstrap # Bootstrap creates the admin user AND the reset link, so no need to check credentials after @@ -188,6 +128,8 @@ async def main(): # Configure logging for CLI usage logging.basicConfig(level=logging.INFO, format="%(message)s", force=True) + from paskia import globals + await globals.init() diff --git a/paskia/db/__init__.py b/paskia/db/__init__.py index 70343dd..ebc189c 100644 --- a/paskia/db/__init__.py +++ b/paskia/db/__init__.py @@ -30,6 +30,7 @@ from paskia.db.operations import ( _db, add_permission_to_organization, add_permission_to_role, + bootstrap, build_credential, build_org, build_permission, @@ -145,6 +146,7 @@ __all__ = [ # Write ops "add_permission_to_organization", "add_permission_to_role", + "bootstrap", "cleanup_expired", "create_credential", "create_credential_session", diff --git a/paskia/db/jsonl.py b/paskia/db/jsonl.py index b3701d7..5be9b0d 100644 --- a/paskia/db/jsonl.py +++ b/paskia/db/jsonl.py @@ -96,6 +96,10 @@ def create_change_record( ) +# Actions that are allowed to create a new database file +_BOOTSTRAP_ACTIONS = frozenset({"bootstrap", "migrate"}) + + async def flush_changes( db_path: Path, pending_changes: deque[_ChangeRecord], @@ -112,15 +116,25 @@ async def flush_changes( if not pending_changes: return True - # Collect all pending changes + if not db_path.exists(): + first_action = pending_changes[0].a + if first_action not in _BOOTSTRAP_ACTIONS: + _logger.error( + "Refusing to create database file with action '%s' - " + "only bootstrap or migrate can create a new database", + first_action, + ) + pending_changes.clear() + return False + changes_to_write = list(pending_changes) pending_changes.clear() try: - # Build lines to append (keep as bytes, join with \n) lines = [_change_encoder.encode(change) for change in changes_to_write] + if not lines: + return True - # Append all lines in a single write (binary mode for Windows compatibility) async with aiofiles.open(db_path, "ab") as f: await f.write(b"\n".join(lines) + b"\n") return True diff --git a/paskia/db/operations.py b/paskia/db/operations.py index eba5af8..239f991 100644 --- a/paskia/db/operations.py +++ b/paskia/db/operations.py @@ -158,14 +158,11 @@ _db = DB() async def init(*args, **kwargs): - """Load database and start background flush task.""" - from paskia.db.background import start_background - + """Load database from JSONL file.""" db_path = os.environ.get("PASKIA_DB", DB_PATH_DEFAULT) if db_path.startswith("json:"): db_path = db_path[5:] await _db.load(db_path) - await start_background() # ------------------------------------------------------------------------- @@ -1243,3 +1240,118 @@ def create_credential_session( if reset_key in _db._data.reset_tokens: del _db._data.reset_tokens[reset_key] return session_key + + +# ------------------------------------------------------------------------- +# Bootstrap (single transaction for initial system setup) +# ------------------------------------------------------------------------- + + +def bootstrap( + org_name: str = "Organization", + admin_name: str = "Admin", + reset_passphrase: str | None = None, + reset_expiry: datetime | None = None, +) -> dict: + """Bootstrap the entire system in a single transaction. + + Creates: + - auth:admin permission (Master Admin) + - auth:org:admin permission (Org Admin) + - Organization with Administration role + - Admin user with Administration role + - Reset token for admin registration + + This is the only way to create a new database file (besides migrate). + All data is created atomically - if any step fails, nothing is written. + + Args: + org_name: Display name for the organization (default: "Organization") + admin_name: Display name for the admin user (default: "Admin") + reset_passphrase: Passphrase for the reset token (generated if not provided) + reset_expiry: Expiry datetime for the reset token (default: 14 days) + + Returns: + dict with keys: perm_admin, perm_org_admin, org, role, user, reset_passphrase + """ + import uuid7 + + from paskia.authsession import reset_expires + from paskia.util.passphrase import generate as generate_passphrase + + # Check if system is already bootstrapped + for p in _db._data.permissions.values(): + if p.scope == "auth:admin": + raise ValueError( + "System already bootstrapped (auth:admin permission exists)" + ) + + # Generate UUIDs upfront + perm_admin_uuid = uuid7.create() + perm_org_admin_uuid = uuid7.create() + org_uuid = uuid7.create() + role_uuid = uuid7.create() + user_uuid = uuid7.create() + + # Generate reset token components + if reset_passphrase is None: + reset_passphrase = generate_passphrase() + if reset_expiry is None: + reset_expiry = reset_expires() + reset_key = _reset_key(reset_passphrase) + + now = datetime.now(timezone.utc) + + with _db.transaction("bootstrap"): + # Create auth:admin permission + _db._data.permissions[perm_admin_uuid] = _PermissionData( + scope="auth:admin", + display_name="Master Admin", + orgs={org_uuid: True}, # Grant to org + ) + + # Create auth:org:admin permission + _db._data.permissions[perm_org_admin_uuid] = _PermissionData( + scope="auth:org:admin", + display_name="Org Admin", + orgs={org_uuid: True}, # Grant to org + ) + + # Create organization + _db._data.orgs[org_uuid] = _OrgData( + display_name=org_name, + created_at=now, + ) + + # Create Administration role with both permissions + _db._data.roles[role_uuid] = _RoleData( + org=org_uuid, + display_name="Administration", + permissions={perm_admin_uuid: True, perm_org_admin_uuid: True}, + ) + + # Create admin user + _db._data.users[user_uuid] = _UserData( + display_name=admin_name, + role=role_uuid, + created_at=now, + last_seen=None, + visits=0, + ) + + # Create reset token + _db._data.reset_tokens[reset_key] = _ResetTokenData( + user=user_uuid, + expiry=reset_expiry, + token_type="admin bootstrap", + ) + + # Return info about what was created (for logging by caller) + return { + "perm_admin": build_permission(perm_admin_uuid), + "perm_org_admin": build_permission(perm_org_admin_uuid), + "org": build_org(org_uuid), + "role": build_role(role_uuid), + "user": build_user(user_uuid), + "reset_passphrase": reset_passphrase, + } diff --git a/paskia/fastapi/__main__.py b/paskia/fastapi/__main__.py index d816944..55040a1 100644 --- a/paskia/fastapi/__main__.py +++ b/paskia/fastapi/__main__.py @@ -5,13 +5,14 @@ import logging import os from urllib.parse import urlparse -import uvicorn from fastapi_vue.hostutil import parse_endpoint from uvicorn import Config, Server from paskia import globals as _globals from paskia.bootstrap import bootstrap_if_needed from paskia.config import PaskiaConfig +from paskia.db import start_background +from paskia.db.background import flush from paskia.fastapi import app as fastapi_app from paskia.fastapi import reset as reset_cmd from paskia.util import startupbox @@ -183,28 +184,8 @@ def main(): } os.environ["PASKIA_CONFIG"] = json.dumps(config_json) - # Initialize globals (without bootstrap yet) - asyncio.run( - _globals.init( - rp_id=config.rp_id, - rp_name=config.rp_name, - origins=config.origins, - bootstrap=False, - ) - ) - - # Print startup configuration startupbox.print_startup_config(config) - # Bootstrap after startup box is printed - asyncio.run(bootstrap_if_needed()) - - # Handle reset command (no server start) - if is_reset: - exit_code = reset_cmd.run(args.reset_query) - raise SystemExit(exit_code) - - # Dev mode: enable reload when FASTAPI_VUE_FRONTEND_URL is set devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL")) run_kwargs: dict = { @@ -221,18 +202,36 @@ def main(): # Suppress uvicorn startup messages in dev mode run_kwargs["log_level"] = "warning" - if len(endpoints) > 1: - # Run separate servers for multiple endpoints (e.g. IPv4 + IPv6) - async def serve_all(): + async def async_main(): + await _globals.init( + rp_id=config.rp_id, + rp_name=config.rp_name, + origins=config.origins, + bootstrap=False, + ) + await bootstrap_if_needed() + await flush() + + if is_reset: + exit_code = reset_cmd.run(args.reset_query) + raise SystemExit(exit_code) + + await start_background() + + if len(endpoints) > 1: async with asyncio.TaskGroup() as tg: for ep in endpoints: tg.create_task( Server(Config(app=fastapi_app, **run_kwargs, **ep)).serve() ) + else: + server = Server(Config(app=fastapi_app, **run_kwargs, **endpoints[0])) + await server.serve() - asyncio.run(serve_all()) - else: - uvicorn.run("paskia.fastapi:app", **run_kwargs, **endpoints[0]) + try: + asyncio.run(async_main()) + except KeyboardInterrupt: + pass if __name__ == "__main__": diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index f2c087d..e1d44b0 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -220,31 +220,6 @@ async def get_settings(): } -@app.get("/token-info") -async def api_token_info(token: str): - """Get information about a reset token. - - Returns: - - type: "reset" - - user_name: display name of the user - - token_type: type of reset token - """ - if not passphrase.is_well_formed(token): - raise HTTPException(status_code=404, detail="Invalid token") - - # Check if this is a reset token - try: - reset_token = await get_reset(token) - user = db.get_user_by_uuid(reset_token.user_uuid) - return { - "type": "reset", - "user_name": user.display_name, - "token_type": reset_token.token_type, - } - except (ValueError, Exception): - raise HTTPException(status_code=404, detail="Token not found or expired") - - @app.post("/user-info") async def api_user_info( request: Request, diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index a1b2f0e..44e6cb2 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -71,10 +71,11 @@ async def websocket_register_add( stripped = name.strip() if stripped: user_name = stripped - challenge_ids = db.get_credentials_by_user_uuid(user_uuid) + credentials = db.get_credentials_by_user_uuid(user_uuid) + credential_ids = [c.credential_id for c in credentials] if credentials else None # WebAuthn registration - credential = await register_chat(ws, user_uuid, user_name, origin, challenge_ids) + credential = await register_chat(ws, user_uuid, user_name, origin, credential_ids) # Create a new session and store everything in database metadata = infodict(ws, "authenticated") @@ -113,7 +114,10 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): try: session = await get_session(auth, host=host) session_user_uuid = session.user_uuid - credential_ids = db.get_credentials_by_user_uuid(session_user_uuid) + credentials = db.get_credentials_by_user_uuid(session_user_uuid) + credential_ids = ( + [c.credential_id for c in credentials] if credentials else None + ) except ValueError: pass # Invalid/expired session - allow normal authentication diff --git a/paskia/globals.py b/paskia/globals.py index 34c3991..2cfe983 100644 --- a/paskia/globals.py +++ b/paskia/globals.py @@ -63,6 +63,11 @@ async def init( await bootstrap_if_needed() + # Start background flush/cleanup task after bootstrap + from .db import start_background + + await start_background() + # Global instances passkey = Manager[Passkey]("Passkey")