Files
reflector/server/scripts/migrate_user_ids.py
Sergey Mankovsky 9bec39808f feat: link transcript participants (#737)
* Sync authentik users

* Migrate user_id from uid to id

* Fix auth user id

* Fix ci migration test

* Fix meeting token creation

* Move user id migration to a script

* Add user on first login

* Fix migration chain

* Rename uid column to authentik_uid

* Fix broken ws test
2025-11-25 19:13:19 +01:00

293 lines
9.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Manual Migration Script: Migrate user_id from Authentik UID to internal user.id
This script should be run manually AFTER applying the database schema migrations.
Usage:
AUTHENTIK_API_URL=https://your-authentik-url \
AUTHENTIK_API_TOKEN=your-token \
DATABASE_URL=postgresql://... \
python scripts/migrate_user_ids.py
What this script does:
1. Collects all unique Authentik UIDs currently used in the database
2. Fetches only those users from Authentik API to populate the users table
3. Updates user_id in: user_api_key, transcript, room, meeting_consent
4. Uses user.authentik_uid to lookup the corresponding user.id
The script is idempotent:
- User inserts use ON CONFLICT DO NOTHING (safe if users already exist)
- Update queries only match authentik_uid->uuid pairs (no-op if already migrated)
- Safe to run multiple times without side effects
Prerequisites:
- AUTHENTIK_API_URL environment variable must be set
- AUTHENTIK_API_TOKEN environment variable must be set
- DATABASE_URL environment variable must be set
- Authentik API must be accessible
"""
import asyncio
import os
import sys
from datetime import datetime, timezone
from typing import Any
import httpx
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
TABLES_WITH_USER_ID = ["user_api_key", "transcript", "room", "meeting_consent"]
NULLABLE_USER_ID_TABLES = {"transcript", "meeting_consent"}
AUTHENTIK_PAGE_SIZE = 100
HTTP_TIMEOUT = 30.0
class AuthentikClient:
def __init__(self, api_url: str, api_token: str):
self.api_url = api_url
self.api_token = api_token
def _get_headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self.api_token}",
"Accept": "application/json",
}
async def fetch_all_users(self) -> list[dict[str, Any]]:
all_users = []
page = 1
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
while True:
url = f"{self.api_url}/api/v3/core/users/"
params = {
"page": page,
"page_size": AUTHENTIK_PAGE_SIZE,
"include_groups": "false",
}
print(f" Fetching users from Authentik (page {page})...")
response = await client.get(
url, headers=self._get_headers(), params=params
)
response.raise_for_status()
data = response.json()
results = data.get("results", [])
if not results:
break
all_users.extend(results)
print(f" Fetched {len(results)} users from page {page}")
if not data.get("next"):
break
page += 1
print(f" Total: {len(all_users)} users fetched from Authentik")
return all_users
except httpx.HTTPError as e:
raise Exception(f"Failed to fetch users from Authentik: {e}") from e
async def collect_used_authentik_uids(connection: AsyncConnection) -> set[str]:
print("\nStep 1: Collecting Authentik UIDs from database tables...")
used_authentik_uids = set()
for table in TABLES_WITH_USER_ID:
result = await connection.execute(
text(f'SELECT DISTINCT user_id FROM "{table}" WHERE user_id IS NOT NULL')
)
authentik_uids = [row[0] for row in result.fetchall()]
used_authentik_uids.update(authentik_uids)
print(f" Found {len(authentik_uids)} unique Authentik UIDs in {table}")
print(f" Total unique user IDs found: {len(used_authentik_uids)}")
if used_authentik_uids:
sample_id = next(iter(used_authentik_uids))
if len(sample_id) == 36 and sample_id.count("-") == 4:
print(
f"\n✅ User IDs are already in UUID format (e.g., {sample_id[:20]}...)"
)
print("Migration has already been completed!")
return set()
return used_authentik_uids
def filter_users_by_authentik_uid(
authentik_users: list[dict[str, Any]], used_authentik_uids: set[str]
) -> tuple[list[dict[str, Any]], set[str]]:
used_authentik_users = [
user for user in authentik_users if user.get("uid") in used_authentik_uids
]
missing_ids = used_authentik_uids - {u.get("uid") for u in used_authentik_users}
print(
f" Found {len(used_authentik_users)} matching users in Authentik "
f"(out of {len(authentik_users)} total)"
)
if missing_ids:
print(
f" ⚠ Warning: {len(missing_ids)} Authentik UIDs in database not found in Authentik:"
)
for user_id in sorted(missing_ids):
print(f" - {user_id}")
return used_authentik_users, missing_ids
async def sync_users_to_database(
connection: AsyncConnection, authentik_users: list[dict[str, Any]]
) -> tuple[int, int]:
created = 0
skipped = 0
now = datetime.now(timezone.utc)
for authentik_user in authentik_users:
user_id = authentik_user["uuid"]
authentik_uid = authentik_user["uid"]
email = authentik_user.get("email")
if not email:
print(f" ⚠ Skipping user {authentik_uid} (no email)")
skipped += 1
continue
result = await connection.execute(
text("""
INSERT INTO "user" (id, email, authentik_uid, created_at, updated_at)
VALUES (:id, :email, :authentik_uid, :created_at, :updated_at)
ON CONFLICT (id) DO NOTHING
"""),
{
"id": user_id,
"email": email,
"authentik_uid": authentik_uid,
"created_at": now,
"updated_at": now,
},
)
if result.rowcount > 0:
created += 1
return created, skipped
async def migrate_all_user_ids(connection: AsyncConnection) -> int:
print("\nStep 3: Migrating user_id columns from Authentik UID to internal UUID...")
print("(If no rows are updated, migration may have already been completed)")
total_updated = 0
for table in TABLES_WITH_USER_ID:
null_check = (
f"AND {table}.user_id IS NOT NULL"
if table in NULLABLE_USER_ID_TABLES
else ""
)
query = f"""
UPDATE {table}
SET user_id = u.id
FROM "user" u
WHERE {table}.user_id = u.authentik_uid
{null_check}
"""
print(f" Updating {table}.user_id...")
result = await connection.execute(text(query))
rows = result.rowcount
print(f" ✓ Updated {rows} rows")
total_updated += rows
return total_updated
async def run_migration(
database_url: str, authentik_api_url: str, authentik_api_token: str
) -> None:
engine = create_async_engine(database_url)
try:
async with engine.begin() as connection:
used_authentik_uids = await collect_used_authentik_uids(connection)
if not used_authentik_uids:
print("\n⚠️ No user IDs found in database. Nothing to migrate.")
print("Migration complete (no-op)!")
return
print("\nStep 2: Fetching user data from Authentik and syncing users...")
print("(This script is idempotent - safe to run multiple times)")
print(f"Authentik API URL: {authentik_api_url}")
client = AuthentikClient(authentik_api_url, authentik_api_token)
authentik_users = await client.fetch_all_users()
if not authentik_users:
print("\nERROR: No users returned from Authentik API.")
print(
"Please verify your Authentik configuration and ensure users exist."
)
sys.exit(1)
used_authentik_users, _ = filter_users_by_authentik_uid(
authentik_users, used_authentik_uids
)
created, skipped = await sync_users_to_database(
connection, used_authentik_users
)
if created > 0:
print(f"✓ Created {created} users from Authentik")
else:
print("✓ No new users created (users may already exist)")
if skipped > 0:
print(f" ⚠ Skipped {skipped} users without email")
result = await connection.execute(text('SELECT COUNT(*) FROM "user"'))
user_count = result.scalar()
print(f"✓ Users table now has {user_count} users")
total_updated = await migrate_all_user_ids(connection)
if total_updated > 0:
print(f"\n✅ Migration complete! Updated {total_updated} total rows.")
else:
print(
"\n✅ Migration complete! (No rows updated - migration may have already been completed)"
)
except Exception as e:
print(f"\n❌ ERROR: Migration failed: {e}")
sys.exit(1)
finally:
await engine.dispose()
async def main() -> None:
database_url = os.getenv("DATABASE_URL")
authentik_api_url = os.getenv("AUTHENTIK_API_URL")
authentik_api_token = os.getenv("AUTHENTIK_API_TOKEN")
if not database_url or not authentik_api_url or not authentik_api_token:
print(
"ERROR: DATABASE_URL, AUTHENTIK_API_URL, and AUTHENTIK_API_TOKEN must be set"
)
sys.exit(1)
await run_migration(database_url, authentik_api_url, authentik_api_token)
if __name__ == "__main__":
asyncio.run(main())