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 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()
+2
View File
@@ -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",
+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(
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
+116 -4
View File
@@ -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,
}
+26 -27
View File
@@ -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__":
-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")
async def api_user_info(
request: Request,
+7 -3
View File
@@ -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
+5
View File
@@ -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")