Files
paskia/paskia/db/legacy.py
T
LeoVasanko 3a7ba09ddd Fix legacy conversion dropping 'empty origins = allow all' when an auth host was set
The **.{rp-id} wildcard was only added when the resulting origins dict
was empty, so a legacy database with a dedicated auth host but no
configured origins ended up allowing only the auth host.
2026-09-09 17:35:04 +00:00

387 lines
13 KiB
Python

"""Legacy database format reader, converter and database merging.
Retains the msgspec structs used by the old ``<rp-id>.paskiadb/main.db``
format so existing databases can be opened and converted to the combined
``paskia.kantadb`` format, and implements the merge of incoming data
(legacy or current format) into an existing ``paskia.kantadb``. Only the
structs whose shape differs from the current schema are redefined here;
unchanged structs are imported from ``paskia.db.structs``.
Assumes the on-disk records are in the latest legacy format (schema
migrations were discarded together with the old format). The legacy
structs will be deleted once legacy conversion is no longer supported.
"""
from __future__ import annotations
import asyncio
import shutil
from datetime import datetime
from pathlib import Path
from uuid import UUID
import msgspec
from kanta import Kanta
from paskia.db.paths import db_file_path, users_root_path
from paskia.db.structs import (
DB,
OIDC,
Config,
Credential,
DomainConfig,
Org,
OriginEntry,
Permission,
ResetToken,
Role,
Session,
User,
)
class LegacyConfig(msgspec.Struct, omit_defaults=True):
"""Pre-domains stored configuration (single rp-id per database)."""
rp_id: str
rp_name: str | None = None
auth_host: str | None = None
origins: list[str] | None = None
listen: list[str] | None = None
class LegacyCredential(msgspec.Struct, dict=True):
"""Credential without the rp_id stamp."""
credential_id: bytes
user_uuid: UUID = msgspec.field(name="user")
aaguid: UUID
public_key: bytes
sign_count: int
created_at: datetime
last_used: datetime | None = None
last_verified: datetime | None = None
class LegacySession(msgspec.Struct, dict=True, omit_defaults=True):
"""Session without the rp_id/issuer stamps."""
user_uuid: UUID = msgspec.field(name="user")
credential_uuid: UUID = msgspec.field(name="credential")
host: str
ip: str
user_agent: str
validated: datetime
client_uuid: UUID | None = msgspec.field(name="client", default=None)
class LegacyDB(msgspec.Struct, dict=True, omit_defaults=False):
"""Root structure of a legacy single-rp-id database."""
config: LegacyConfig = msgspec.field(
default_factory=lambda: LegacyConfig(rp_id="localhost")
)
permissions: dict[UUID, Permission] = {}
orgs: dict[UUID, Org] = {}
roles: dict[UUID, Role] = {}
users: dict[UUID, User] = {}
credentials: dict[UUID, LegacyCredential] = {}
sessions: dict[str, LegacySession] = {}
reset_tokens: dict[str, ResetToken] = {}
oidc: OIDC = msgspec.field(default_factory=OIDC)
def _read_legacy(path: Path) -> LegacyDB:
"""Open a legacy database read-only and return its contents."""
kanta = Kanta(str(path), LegacyDB())
async def _read() -> LegacyDB:
await kanta.open(readonly=True)
return kanta.data
return asyncio.run(_read())
def _read_kantadb(path: Path) -> DB:
"""Open a current-format database read-only and return its contents."""
kanta = Kanta(str(path), DB())
async def _read() -> DB:
await kanta.open(readonly=True)
return kanta.data
return asyncio.run(_read())
def _legacy_to_db(old: LegacyDB) -> DB:
"""Convert legacy database contents to the combined kantadb format.
All credentials and sessions are stamped with the legacy database's
rp-id; the OIDC provider carries over as-is (it is instance-global).
"""
rp_id = old.config.rp_id
from paskia.domains import origin_key # noqa: PLC0415 (import cycle)
origins: dict[str, bool | OriginEntry] = {}
for origin in old.config.origins or []:
origins[origin_key(origin)] = True
if old.config.auth_host:
origins[origin_key(old.config.auth_host)] = OriginEntry(auth_host=True)
if not old.config.origins:
# Legacy semantics: no origins configured = the whole rp-id domain
# allowed, regardless of a dedicated auth host. The new format
# requires explicit entries.
origins[f"**.{rp_id}"] = True
new_config = Config(
domains={rp_id: DomainConfig(rp_name=old.config.rp_name, origins=origins)},
listen=old.config.listen,
)
credentials = {
uuid: Credential(
credential_id=c.credential_id,
user_uuid=c.user_uuid,
aaguid=c.aaguid,
public_key=c.public_key,
sign_count=c.sign_count,
created_at=c.created_at,
rp_id=rp_id,
last_used=c.last_used,
last_verified=c.last_verified,
)
for uuid, c in old.credentials.items()
}
sessions = {
key: Session(
user_uuid=s.user_uuid,
credential_uuid=s.credential_uuid,
host=s.host,
ip=s.ip,
user_agent=s.user_agent,
validated=s.validated,
client_uuid=s.client_uuid,
rp_id=rp_id,
)
for key, s in old.sessions.items()
}
converted = DB(
config=new_config,
permissions=old.permissions,
orgs=old.orgs,
roles=old.roles,
users=old.users,
credentials=credentials,
sessions=sessions,
reset_tokens=old.reset_tokens,
oidc=old.oidc,
)
return converted
def _migration_label(incoming: DB) -> str:
"""Transaction label for a migration; multiple rp-ids join with slashes."""
return f"migrate:cli:{'/'.join(incoming.config.domains)}"
def _write_fresh(data: DB, dst: Path, label: str) -> None:
"""Write a fresh database at ``dst`` with the given contents."""
new_db = DB()
kanta = Kanta(str(dst), new_db)
@kanta.bootstrap(action=label)
def _seed(target: DB) -> None:
target.config = data.config
target.permissions = data.permissions
target.orgs = data.orgs
target.roles = data.roles
target.users = data.users
target.credentials = data.credentials
target.sessions = data.sessions
target.reset_tokens = data.reset_tokens
target.oidc = data.oidc
async def _write() -> None:
async with kanta:
pass
asyncio.run(_write())
def convert_legacy_database(src: Path, dst: Path) -> Config:
"""Convert a legacy main.db file into the combined kantadb format.
Reads the legacy database at ``src`` and writes a fresh database at
``dst``. Returns the converted (new-format) configuration.
"""
converted = _legacy_to_db(_read_legacy(src))
_write_fresh(converted, dst, _migration_label(converted))
return converted.config
def _merge_data(data: DB, incoming: DB) -> None:
"""Merge ``incoming`` contents into the live ``data`` object.
Records are uuid-keyed (or hash-keyed for sessions/reset tokens), so
identical keys denote the same item: existing entries win, new entries
are added. Domains merge per rp-id with a union of allowed origins;
the existing instance's listen endpoints and OIDC signing key win.
"""
for rp_id, domain in incoming.config.domains.items():
existing = data.config.domains.get(rp_id)
if existing is None:
data.config.domains[rp_id] = domain
continue
for origin, entry in domain.origins.items():
existing.origins.setdefault(origin, entry)
if existing.rp_name is None:
existing.rp_name = domain.rp_name
for bucket in (
"permissions",
"orgs",
"roles",
"users",
"credentials",
"sessions",
"reset_tokens",
):
target_map = getattr(data, bucket)
for key, value in getattr(incoming, bucket).items():
target_map.setdefault(key, value)
for uuid, client in incoming.oidc.clients.items():
data.oidc.clients.setdefault(uuid, client)
if data.oidc.key is None:
data.oidc.key = incoming.oidc.key
def merge_database(dst: Path, incoming: DB) -> None:
"""Merge ``incoming`` contents into the existing database at ``dst``."""
kanta = Kanta(str(dst), DB())
async def _merge() -> None:
async with kanta:
with kanta.transaction(_migration_label(incoming)):
_merge_data(kanta.data, incoming)
asyncio.run(_merge())
def find_legacy_databases(cwd: Path | None = None) -> list[Path]:
"""Find legacy ``*.paskiadb`` databases in a directory.
A candidate is either a directory containing ``main.db`` or a legacy
single-file database. Empty directories and non-matching files are
ignored.
"""
cwd = cwd or Path.cwd()
candidates = []
for entry in sorted(cwd.glob("*.paskiadb")):
if entry.is_dir():
if (entry / "main.db").is_file():
candidates.append(entry)
elif entry.is_file():
candidates.append(entry)
return candidates
def _resolve_source(source: str | None) -> tuple[Path, bool, Path, Path | None]:
"""Resolve the migrate source.
``source`` may be an rp-id (selecting ``<rp-id>.paskiadb`` in the
current directory), a path to a legacy ``*.paskiadb`` directory or
file, or a path to a current-format ``*.kantadb`` file. Without
``source``, exactly one legacy candidate must exist in the current
directory.
Returns ``(db_file, is_legacy, users_dir, rename_target)`` where
``users_dir`` holds auxiliary user files (avatars) and
``rename_target`` is the legacy directory/file to rename aside after
a successful migration (None for current-format sources).
"""
def legacy(src: Path) -> tuple[Path, bool, Path, Path]:
return (
src / "main.db" if src.is_dir() else src,
True,
src / "users" if src.is_dir() else src.parent / "users",
src,
)
if source is not None:
path = Path(source)
if path.is_dir():
if (path / "main.db").is_file():
return legacy(path)
raise SystemExit(f"No legacy main.db found in directory {path}.")
if path.is_file():
if path.suffix == ".paskiadb":
return legacy(path)
return path, False, path.parent / "paskia.data" / "users", None
# Not a path: treat as rp-id selecting a legacy candidate by name
name = f"{source}.paskiadb"
matches = [c for c in find_legacy_databases() if c.name == name]
if not matches:
found = ", ".join(str(c) for c in find_legacy_databases()) or "none"
raise SystemExit(
f"No legacy database {name} in this directory (candidates: {found})."
)
return legacy(matches[0])
candidates = find_legacy_databases()
if not candidates:
raise SystemExit("No legacy *.paskiadb database found — nothing to migrate.")
if len(candidates) > 1:
names = ", ".join(str(c) for c in candidates)
raise SystemExit(
f"Multiple legacy databases found ({names}) — select one with "
"'paskia migrate <rp-id>'."
)
return legacy(candidates[0])
def _move_user_files(src_users: Path) -> None:
"""Move persisted user files (avatars) to the new data root."""
if not src_users.is_dir():
return
target_users = users_root_path(create_root=True)
for child in src_users.iterdir():
if (target_users / child.name).exists():
continue
shutil.move(str(child), str(target_users / child.name))
def migrate_database(source: str | None = None) -> list[str]:
"""Convert or merge a database into ``paskia.kantadb``.
The source may be a legacy ``<rp-id>.paskiadb`` database (selected by
rp-id or path) or a current-format ``*.kantadb`` file given by path.
When ``paskia.kantadb`` already exists, the incoming data is merged
into it (uuid-keyed records make conflicts a non-issue); otherwise a
fresh database is written. Returns the migrated domains' rp-ids. A
migrated legacy source is renamed aside to ``<name>.converted-bak``
rather than deleted; a merged kantadb source is left in place.
"""
target = db_file_path()
db_file, is_legacy, users_dir, rename_target = _resolve_source(source)
if db_file.resolve() == target.resolve():
raise SystemExit(f"{db_file} is the active database — nothing to migrate.")
incoming = (
_legacy_to_db(_read_legacy(db_file)) if is_legacy else _read_kantadb(db_file)
)
rp_ids = list(incoming.config.domains)
if target.exists():
merge_database(target, incoming)
else:
_write_fresh(incoming, target, _migration_label(incoming))
_move_user_files(users_dir)
if rename_target is not None and rename_target.exists():
shutil.move(
str(rename_target),
str(rename_target.with_name(rename_target.name + ".converted-bak")),
)
return rp_ids