Cleanup and bugfixes on Bootstrap and JSONL handling.

This commit is contained in:
2026-01-26 23:54:03 +00:00
parent 7e568dbd10
commit 4ddaa9fdf4
8 changed files with 205 additions and 152 deletions
+32 -90
View File
@@ -8,26 +8,11 @@ generating a reset link for initial admin setup.
import asyncio import asyncio
import logging import logging
from datetime import datetime, timezone
import uuid7 from paskia import db
from paskia.util import hostutil
from paskia import authsession, db logger = logging.getLogger(__name__)
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()
# Shared log message template for admin reset links # Shared log message template for admin reset links
ADMIN_RESET_MESSAGE = """\ ADMIN_RESET_MESSAGE = """\
@@ -38,17 +23,9 @@ ADMIN_RESET_MESSAGE = """\
""" """
async def _create_and_log_admin_reset_link(user_uuid, message, session_type) -> str: def _log_reset_link(message: str, passphrase: str) -> str:
"""Create an admin reset link and log it with the provided message.""" """Log a reset link message and return the URL."""
token = passphrase.generate() reset_link = hostutil.reset_link_url(passphrase)
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)
logger.info(ADMIN_RESET_MESSAGE, message, reset_link) logger.info(ADMIN_RESET_MESSAGE, message, reset_link)
return reset_link return reset_link
@@ -57,63 +34,23 @@ async def bootstrap_system() -> dict:
""" """
Bootstrap the entire system with default data. 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: Returns:
dict: Contains information about created entities and reset link dict: Contains information about created entities and reset link
""" """
# Create permission first - will fail if already exists # Call the single-transaction bootstrap function
perm0 = Permission( result = db.bootstrap()
uuid=uuid7.create(), scope="auth:admin", display_name="Master Admin"
)
db.create_permission(perm0)
# Create org admin permission - allows managing users within an org # Log the reset link (this is separate from the transaction log)
perm_org_admin = Permission( reset_link = _log_reset_link("✅ Bootstrap completed!", result["reset_passphrase"])
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"
)
return { return {
"user": user, "user": result["user"],
"org": org, "org": result["org"],
"role": role, "role": result["role"],
"permissions": [ "permissions": [result["perm_admin"], result["perm_org_admin"]],
perm0,
*[
db.get_permission_by_scope(p)
for p in org.permissions
if db.get_permission_by_scope(p)
],
],
"reset_link": reset_link, "reset_link": reset_link,
} }
@@ -145,11 +82,18 @@ async def check_admin_credentials() -> bool:
if not credentials: if not credentials:
# Admin exists but has no credentials, create reset link # Admin exists but has no credentials, create reset link
await _create_and_log_admin_reset_link( from paskia import authsession
admin_user.uuid, from paskia.util import passphrase
"⚠️ Admin user has no credentials!",
"admin registration", 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 True
return False return False
@@ -165,16 +109,12 @@ async def bootstrap_if_needed() -> bool:
Returns: Returns:
bool: True if bootstrapping was performed, False if system was already set up 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
# Check if the admin permission exists - if it does, system is already bootstrapped if db.get_permission_by_scope("auth:admin"):
db.get_permission("auth:admin")
# Permission exists, system is already bootstrapped # Permission exists, system is already bootstrapped
# Check if admin needs credentials (only for already-bootstrapped systems) # Check if admin needs credentials (only for already-bootstrapped systems)
await check_admin_credentials() await check_admin_credentials()
return False return False
except Exception:
# Permission doesn't exist, need to bootstrap
pass
# No admin permission found, need to bootstrap # No admin permission found, need to bootstrap
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after # 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 # Configure logging for CLI usage
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True) logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
from paskia import globals
await globals.init() await globals.init()
+2
View File
@@ -30,6 +30,7 @@ from paskia.db.operations import (
_db, _db,
add_permission_to_organization, add_permission_to_organization,
add_permission_to_role, add_permission_to_role,
bootstrap,
build_credential, build_credential,
build_org, build_org,
build_permission, build_permission,
@@ -145,6 +146,7 @@ __all__ = [
# Write ops # Write ops
"add_permission_to_organization", "add_permission_to_organization",
"add_permission_to_role", "add_permission_to_role",
"bootstrap",
"cleanup_expired", "cleanup_expired",
"create_credential", "create_credential",
"create_credential_session", "create_credential_session",
+17 -3
View File
@@ -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( async def flush_changes(
db_path: Path, db_path: Path,
pending_changes: deque[_ChangeRecord], pending_changes: deque[_ChangeRecord],
@@ -112,15 +116,25 @@ async def flush_changes(
if not pending_changes: if not pending_changes:
return True 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) changes_to_write = list(pending_changes)
pending_changes.clear() pending_changes.clear()
try: try:
# Build lines to append (keep as bytes, join with \n)
lines = [_change_encoder.encode(change) for change in changes_to_write] 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: async with aiofiles.open(db_path, "ab") as f:
await f.write(b"\n".join(lines) + b"\n") await f.write(b"\n".join(lines) + b"\n")
return True return True
+116 -4
View File
@@ -158,14 +158,11 @@ _db = DB()
async def init(*args, **kwargs): async def init(*args, **kwargs):
"""Load database and start background flush task.""" """Load database from JSONL file."""
from paskia.db.background import start_background
db_path = os.environ.get("PASKIA_DB", DB_PATH_DEFAULT) db_path = os.environ.get("PASKIA_DB", DB_PATH_DEFAULT)
if db_path.startswith("json:"): if db_path.startswith("json:"):
db_path = db_path[5:] db_path = db_path[5:]
await _db.load(db_path) await _db.load(db_path)
await start_background()
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -1243,3 +1240,118 @@ def create_credential_session(
if reset_key in _db._data.reset_tokens: if reset_key in _db._data.reset_tokens:
del _db._data.reset_tokens[reset_key] del _db._data.reset_tokens[reset_key]
return session_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,
}
+26 -27
View File
@@ -5,13 +5,14 @@ import logging
import os import os
from urllib.parse import urlparse from urllib.parse import urlparse
import uvicorn
from fastapi_vue.hostutil import parse_endpoint from fastapi_vue.hostutil import parse_endpoint
from uvicorn import Config, Server from uvicorn import Config, Server
from paskia import globals as _globals from paskia import globals as _globals
from paskia.bootstrap import bootstrap_if_needed from paskia.bootstrap import bootstrap_if_needed
from paskia.config import PaskiaConfig 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 app as fastapi_app
from paskia.fastapi import reset as reset_cmd from paskia.fastapi import reset as reset_cmd
from paskia.util import startupbox from paskia.util import startupbox
@@ -183,28 +184,8 @@ def main():
} }
os.environ["PASKIA_CONFIG"] = json.dumps(config_json) 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) 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")) devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL"))
run_kwargs: dict = { run_kwargs: dict = {
@@ -221,18 +202,36 @@ def main():
# Suppress uvicorn startup messages in dev mode # Suppress uvicorn startup messages in dev mode
run_kwargs["log_level"] = "warning" run_kwargs["log_level"] = "warning"
if len(endpoints) > 1: async def async_main():
# Run separate servers for multiple endpoints (e.g. IPv4 + IPv6) await _globals.init(
async def serve_all(): 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: async with asyncio.TaskGroup() as tg:
for ep in endpoints: for ep in endpoints:
tg.create_task( tg.create_task(
Server(Config(app=fastapi_app, **run_kwargs, **ep)).serve() 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()) try:
else: asyncio.run(async_main())
uvicorn.run("paskia.fastapi:app", **run_kwargs, **endpoints[0]) except KeyboardInterrupt:
pass
if __name__ == "__main__": if __name__ == "__main__":
-25
View File
@@ -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") @app.post("/user-info")
async def api_user_info( async def api_user_info(
request: Request, request: Request,
+7 -3
View File
@@ -71,10 +71,11 @@ async def websocket_register_add(
stripped = name.strip() stripped = name.strip()
if stripped: if stripped:
user_name = 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 # 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 # Create a new session and store everything in database
metadata = infodict(ws, "authenticated") metadata = infodict(ws, "authenticated")
@@ -113,7 +114,10 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
try: try:
session = await get_session(auth, host=host) session = await get_session(auth, host=host)
session_user_uuid = session.user_uuid 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: except ValueError:
pass # Invalid/expired session - allow normal authentication pass # Invalid/expired session - allow normal authentication
+5
View File
@@ -63,6 +63,11 @@ async def init(
await bootstrap_if_needed() await bootstrap_if_needed()
# Start background flush/cleanup task after bootstrap
from .db import start_background
await start_background()
# Global instances # Global instances
passkey = Manager[Passkey]("Passkey") passkey = Manager[Passkey]("Passkey")