Cleanup and bugfixes on Bootstrap and JSONL handling.
This commit is contained in:
+32
-90
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user