Files
paskia/paskia/__main__.py
T

276 lines
9.2 KiB
Python

import argparse
import asyncio
import logging
import os
import sys
from pathlib import Path
import msgspec
from fastapi_vue import server
from kanta import Kanta
from paskia.db import legacy
from paskia.db.bootstrap import bootstrap, log_reset_link
from paskia.db.paths import db_file_path
from paskia.db.structs import DB, Config, DomainConfig, OriginEntry
from paskia.domains import build as build_registry
from paskia.domains import configure as configure_domains
from paskia.domains import origin_key, validate_config
from paskia.util import startupbox
from paskia.util.constants import DEFAULT_PORT, DEVMODE
from paskia.util.hostutil import (
normalize_origin,
validate_auth_host,
)
from paskia.util.runtime import ServeConfig
EPILOG = """\
Examples:
paskia init --rp-id example.com --rp-name "Example Corporation" --auth-host auth.example.com
paskia migrate --rp-id example.com
paskia
"""
def _split_multi(values: list[str] | None) -> list[str]:
"""Split repeatable/comma-separated CLI values into a flat list."""
result = []
for value in values or []:
result.extend(part.strip() for part in value.split(",") if part.strip())
return result
def _add_listen_option(p: argparse.ArgumentParser, help_extra: str = "") -> None:
p.add_argument(
"-l",
"--listen",
action="append",
metavar="LISTEN",
help=(
"Endpoint to listen on (default: localhost:4401). "
"Forms: host:port port :port [ipv6]:port unix:path /path.sock"
)
+ help_extra,
)
def _load_stored_config(db_path: Path) -> Config:
"""Load the stored Config from disk using Kanta in read-only mode.
This must not depend on PASKIA_CONFIG or the global lifecycle Kanta.
Read-only opens never write or migrate the file.
"""
kanta = Kanta(str(db_path), DB())
async def _read() -> Config:
await kanta.open(readonly=True)
try:
return kanta.data.config
finally:
await kanta.close()
try:
return asyncio.run(_read())
except Exception as e:
logging.exception("Failed to load database")
raise SystemExit(f"{e}") from e
def cmd_init(args: argparse.Namespace) -> None:
"""Bootstrap a new paskia.kantadb database with the initial domain(s)."""
db_path = db_file_path()
if db_path.exists():
raise SystemExit(
f"Database {db_path} already exists — domain configuration is "
"managed via the admin interface, not 'paskia init'."
)
if found := legacy.find_legacy_databases():
names = ", ".join(str(p) for p in found)
raise SystemExit(
f"Legacy database(s) found ({names}) — run 'paskia migrate' to "
"convert, not 'paskia init'."
)
rp_ids = _split_multi(args.rp_id) or ["localhost"]
domains = {}
for i, rp_id in enumerate(rp_ids):
domain = DomainConfig()
if i == 0:
# Bootstrap-time naming and hosts apply to the first domain;
# everything is editable via the admin interface afterwards.
domain.rp_name = args.rp_name or None
origins = {
origin_key(normalize_origin(o)): True
for o in _split_multi(args.origins)
}
auth_host = args.auth_host or None
if auth_host:
try:
validate_auth_host(auth_host, rp_id)
except ValueError as e:
raise SystemExit(str(e)) from e
if "://" not in auth_host:
auth_host = f"https://{auth_host}"
origins[origin_key(auth_host)] = OriginEntry(auth_host=True)
domain.origins = origins
domains[rp_id] = domain
config = Config(domains=domains, listen=_split_multi(args.listen) or None)
try:
validate_config(config)
except ValueError as e:
raise SystemExit(str(e)) from e
# Create the database; the kanta bootstrap callback seeds it (admin
# user, org, permissions, reset token, per-domain OIDC keys).
new_db = DB()
kanta = Kanta(str(db_path), new_db)
result = {}
@kanta.bootstrap
def _bootstrap(data: DB) -> None:
result["passphrase"] = bootstrap(data, config=config)
async def _create() -> None:
async with kanta:
pass
try:
asyncio.run(_create())
except Exception as e:
logging.exception("Failed to create database")
db_path.unlink(missing_ok=True)
raise SystemExit(f"{e}") from e
configure_domains(listen=config.listen)
registry = build_registry(config)
startupbox.print_startup_config(registry, listen=config.listen)
log_reset_link(
registry.get(rp_ids[0]).reset_link_url(result["passphrase"]),
"✅ Bootstrap completed!",
)
def cmd_migrate(args: argparse.Namespace) -> None:
"""Convert a legacy <rp-id>.paskiadb database to paskia.kantadb."""
rp_id = legacy.migrate_legacy_database(args.rp_id)
print(f"✅ Converted legacy database to {db_file_path()} (domain: {rp_id})")
def cmd_serve(args: argparse.Namespace) -> None:
"""Open the combined database and serve all configured domains."""
db_path = db_file_path()
if not db_path.exists():
if found := legacy.find_legacy_databases():
names = ", ".join(str(p) for p in found)
raise SystemExit(
f"Database {db_path} not found, but legacy database(s) exist "
f"({names}) — run 'paskia migrate' to convert."
)
raise SystemExit(f"Database {db_path} not found — run 'paskia init' first.")
config = _load_stored_config(db_path)
listen = _split_multi(args.listen) or config.listen
configure_domains(listen=listen)
try:
registry = build_registry(config)
except ValueError as e:
raise SystemExit(f"Invalid stored configuration: {e}") from e
for warning in registry.warnings:
# Serving is best-effort; fixing the stored config is the admin's
# job via the admin interface on any working domain.
print(
f"⚠️ Config problem (fix via the admin interface): {warning}",
file=sys.stderr,
)
# Pass process-global serve parameters to the server process(es)
os.environ["PASKIA_CONFIG"] = msgspec.json.encode(
ServeConfig(listen=listen)
).decode()
startupbox.print_startup_config(registry, listen=listen)
# Run the server (spawns processes in dev mode)
# tracerite, access logging and log config are handled by fastapi_vue.server;
# we print our own startup config box, so disable the built-in one.
server.run(
"paskia.fastapi.mainapp:app",
listen=listen,
default_port=DEFAULT_PORT,
server_header=False,
startup_box=None,
reload=Path(__file__).parent if DEVMODE else False,
)
def main():
# Configure logging to remove the "ERROR:root:" prefix
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
parser = argparse.ArgumentParser(
prog="paskia",
description="Paskia authentication server",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=EPILOG,
)
_add_listen_option(parser)
init_parser = argparse.ArgumentParser(
prog="paskia init",
description="Bootstrap a new paskia.kantadb database in the current directory",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=EPILOG,
)
init_parser.add_argument(
"--rp-id",
action="append",
help="Relying Party ID of the initial domain(s) (default: localhost). "
"Repeatable and comma-separated; the first is the default domain. "
"Further domains are added via the admin interface.",
)
init_parser.add_argument(
"--rp-name",
help="Relying Party name of the default domain (default: same as rp-id). "
"Used by the initial admin registration; editable later via admin UI.",
)
init_parser.add_argument(
"--origin",
action="append",
dest="origins",
metavar="URL",
help="Allowed origin URL(s) for the default domain. May be specified "
"multiple times; comma-separated values accepted.",
)
init_parser.add_argument(
"--auth-host",
help="Dedicated authentication site for the default domain "
"(optionally with scheme/port)",
)
_add_listen_option(init_parser, help_extra=" (stored in the database)")
migrate_parser = argparse.ArgumentParser(
prog="paskia migrate",
description="Convert a legacy <rp-id>.paskiadb database to paskia.kantadb",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
migrate_parser.add_argument(
"--rp-id",
help="rp-id of the legacy database to convert, selecting "
"<rp-id>.paskiadb when several legacy candidates exist.",
)
argv = sys.argv[1:]
if argv and argv[0] == "init":
cmd_init(init_parser.parse_args(argv[1:]))
elif argv and argv[0] == "migrate":
cmd_migrate(migrate_parser.parse_args(argv[1:]))
else:
cmd_serve(parser.parse_args(argv))
if __name__ == "__main__":
main()