268 lines
9.4 KiB
Python
268 lines
9.4 KiB
Python
"""
|
|
SQL to JSON migration module for Paskia.
|
|
|
|
This module contains the legacy SQL database implementation and migration tools
|
|
for converting from the old SQLite database to the new JSONL format.
|
|
|
|
Usage:
|
|
python -m paskia.migrate --sql sqlite+aiosqlite:///paskia.sqlite --json paskia.jsonl
|
|
|
|
Or via the CLI entry point (if installed):
|
|
paskia-migrate --sql sqlite+aiosqlite:///paskia.sqlite --json paskia.jsonl
|
|
"""
|
|
|
|
import asyncio
|
|
from datetime import datetime, timezone
|
|
|
|
import base64url
|
|
|
|
from paskia.authsession import EXPIRES
|
|
|
|
from .sql import (
|
|
DB as SQLDB,
|
|
)
|
|
from .sql import (
|
|
CredentialModel,
|
|
ResetTokenModel,
|
|
SessionModel,
|
|
UserModel,
|
|
)
|
|
|
|
# Re-export for convenience
|
|
__all__ = ["migrate_from_sql", "main", "SQLDB"]
|
|
|
|
# Default paths
|
|
SQL_DB_DEFAULT = "sqlite+aiosqlite:///paskia.sqlite"
|
|
JSON_DB_DEFAULT = "paskia.jsonl"
|
|
|
|
|
|
def _bytes_to_str(b: bytes | None) -> str | None:
|
|
"""Convert bytes to base64url string."""
|
|
if b is None:
|
|
return None
|
|
return base64url.enc(b)
|
|
|
|
|
|
async def migrate_from_sql(
|
|
sql_db_path: str = SQL_DB_DEFAULT,
|
|
json_db_path: str = JSON_DB_DEFAULT,
|
|
) -> None:
|
|
"""Migrate data from SQL database to JSON format.
|
|
|
|
Args:
|
|
sql_db_path: SQLAlchemy connection string for the source SQL database
|
|
json_db_path: Path for the destination JSONL file
|
|
"""
|
|
# Import here to avoid circular imports and to not require JSON db at import time
|
|
import re
|
|
|
|
import uuid7
|
|
from sqlalchemy import select
|
|
|
|
from paskia.db.operations import DB as JSONDB
|
|
from paskia.db.structs import (
|
|
_CredentialData,
|
|
_OrgData,
|
|
_PermissionData,
|
|
_ResetTokenData,
|
|
_RoleData,
|
|
_SessionData,
|
|
_UserData,
|
|
)
|
|
|
|
# Initialize source SQL database
|
|
sql_db = SQLDB(sql_db_path)
|
|
await sql_db.init_db()
|
|
|
|
# Initialize destination JSON database
|
|
json_db = JSONDB(json_db_path)
|
|
json_db.load()
|
|
|
|
print(f"Migrating from {sql_db_path} to {json_db_path}...")
|
|
|
|
# Build all data directly without saving (we'll save once at the end)
|
|
with json_db._lock:
|
|
# Track old permission ID -> new scope mapping for migration
|
|
# Also track org-specific admin permissions to consolidate
|
|
old_org_admin_pattern = re.compile(r"^auth:org:([0-9a-f-]+)$", re.IGNORECASE)
|
|
org_admin_uuids = set() # org UUIDs that had org-specific admin permissions
|
|
|
|
# First pass: identify org-specific admin permissions
|
|
permissions = await sql_db.list_permissions()
|
|
for perm in permissions:
|
|
match = old_org_admin_pattern.match(perm.id)
|
|
if match:
|
|
org_admin_uuids.add(match.group(1).lower())
|
|
|
|
# Migrate permissions with UUID keys and scope field
|
|
# Always create exactly one common auth:org:admin permission for all org admin needs
|
|
org_admin_perm_uuid = str(uuid7.create())
|
|
json_db._data.permissions[org_admin_perm_uuid] = _PermissionData(
|
|
scope="auth:org:admin",
|
|
display_name="Org Admin",
|
|
orgs={},
|
|
)
|
|
|
|
# Mapping from old permission ID to new scope
|
|
perm_id_to_scope: dict[str, str] = {}
|
|
|
|
for perm in permissions:
|
|
# Skip old org-specific admin permissions (auth:org:{uuid}) - they map to auth:org:admin
|
|
match = old_org_admin_pattern.match(perm.id)
|
|
if match:
|
|
perm_id_to_scope[perm.id] = "auth:org:admin"
|
|
continue
|
|
|
|
# Skip if this is already auth:org:admin - we created one above
|
|
if perm.id == "auth:org:admin":
|
|
perm_id_to_scope[perm.id] = "auth:org:admin"
|
|
continue
|
|
|
|
# Regular permission - create with UUID key
|
|
perm_uuid = str(uuid7.create())
|
|
json_db._data.permissions[perm_uuid] = _PermissionData(
|
|
scope=perm.id, # Old ID becomes the scope
|
|
display_name=perm.display_name,
|
|
orgs={},
|
|
)
|
|
perm_id_to_scope[perm.id] = perm.id # Scope same as old ID
|
|
print(
|
|
f" Migrated {len(permissions)} permissions (with {len(org_admin_uuids)} org-specific admins consolidated to auth:org:admin)"
|
|
)
|
|
|
|
# Migrate organizations
|
|
orgs = await sql_db.list_organizations()
|
|
for org in orgs:
|
|
key = str(org.uuid)
|
|
json_db._data.orgs[key] = _OrgData(
|
|
display_name=org.display_name,
|
|
)
|
|
# Update permissions to allow this org to grant them (by scope)
|
|
for old_perm_id in org.permissions:
|
|
new_scope = perm_id_to_scope.get(old_perm_id, old_perm_id)
|
|
# Find permission with this scope and add org
|
|
for pid, p in json_db._data.permissions.items():
|
|
if p.scope == new_scope:
|
|
p.orgs[key] = True
|
|
break
|
|
# Ensure every org can grant auth:org:admin
|
|
json_db._data.permissions[org_admin_perm_uuid].orgs[key] = True
|
|
print(f" Migrated {len(orgs)} organizations")
|
|
|
|
# Migrate roles - convert old permission IDs to scopes
|
|
role_count = 0
|
|
for org in orgs:
|
|
for role in org.roles:
|
|
key = str(role.uuid)
|
|
# Convert old permission IDs to scopes
|
|
new_permissions = {}
|
|
for old_perm_id in role.permissions or []:
|
|
new_scope = perm_id_to_scope.get(old_perm_id, old_perm_id)
|
|
new_permissions[new_scope] = True
|
|
json_db._data.roles[key] = _RoleData(
|
|
org=str(role.org_uuid),
|
|
display_name=role.display_name,
|
|
permissions=new_permissions,
|
|
)
|
|
role_count += 1
|
|
print(f" Migrated {role_count} roles")
|
|
|
|
# Migrate users
|
|
async with sql_db.session() as session:
|
|
result = await session.execute(select(UserModel))
|
|
user_models = result.scalars().all()
|
|
for um in user_models:
|
|
user = um.as_dataclass()
|
|
key = str(user.uuid)
|
|
json_db._data.users[key] = _UserData(
|
|
display_name=user.display_name,
|
|
role=str(user.role_uuid),
|
|
created_at=user.created_at or datetime.now(timezone.utc),
|
|
last_seen=user.last_seen,
|
|
visits=user.visits,
|
|
)
|
|
print(f" Migrated {len(user_models)} users")
|
|
|
|
# Migrate credentials
|
|
async with sql_db.session() as session:
|
|
result = await session.execute(select(CredentialModel))
|
|
cred_models = result.scalars().all()
|
|
for cm in cred_models:
|
|
cred = cm.as_dataclass()
|
|
key = str(cred.uuid)
|
|
json_db._data.credentials[key] = _CredentialData(
|
|
credential_id=cred.credential_id,
|
|
user=str(cred.user_uuid),
|
|
aaguid=str(cred.aaguid),
|
|
public_key=cred.public_key,
|
|
sign_count=cred.sign_count,
|
|
created_at=cred.created_at,
|
|
last_used=cred.last_used,
|
|
last_verified=cred.last_verified,
|
|
)
|
|
print(f" Migrated {len(cred_models)} credentials")
|
|
|
|
# Migrate sessions
|
|
async with sql_db.session() as session:
|
|
result = await session.execute(select(SessionModel))
|
|
session_models = result.scalars().all()
|
|
for sm in session_models:
|
|
sess = sm.as_dataclass()
|
|
key_b64 = _bytes_to_str(sess.key)
|
|
json_db._data.sessions[key_b64] = _SessionData(
|
|
user=str(sess.user_uuid),
|
|
credential=str(sess.credential_uuid),
|
|
host=sess.host,
|
|
ip=sess.ip,
|
|
user_agent=sess.user_agent,
|
|
expiry=sess.renewed + EXPIRES, # Convert renewed to expiry
|
|
)
|
|
print(f" Migrated {len(session_models)} sessions")
|
|
|
|
# Migrate reset tokens
|
|
async with sql_db.session() as session:
|
|
result = await session.execute(select(ResetTokenModel))
|
|
token_models = result.scalars().all()
|
|
for tm in token_models:
|
|
token = tm.as_dataclass()
|
|
key_b64 = _bytes_to_str(token.key)
|
|
json_db._data.reset_tokens[key_b64] = _ResetTokenData(
|
|
user=str(token.user_uuid),
|
|
expiry=token.expiry,
|
|
token_type=token.token_type,
|
|
)
|
|
print(f" Migrated {len(token_models)} reset tokens")
|
|
|
|
# Queue and flush all changes with actor "migrate"
|
|
json_db._current_actor = "migrate"
|
|
json_db._queue_change()
|
|
json_db.flush()
|
|
|
|
print("Migration complete!")
|
|
|
|
|
|
def main():
|
|
"""CLI entry point for migration."""
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="Migrate Paskia database from SQL to JSON"
|
|
)
|
|
parser.add_argument(
|
|
"--sql",
|
|
default=SQL_DB_DEFAULT,
|
|
help=f"Source SQL database connection string (default: {SQL_DB_DEFAULT})",
|
|
)
|
|
parser.add_argument(
|
|
"--json",
|
|
default=JSON_DB_DEFAULT,
|
|
help=f"Destination JSONL file path (default: {JSON_DB_DEFAULT})",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
asyncio.run(migrate_from_sql(args.sql, args.json))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|