vinindkoeb/migrations/versions/237700c56d84_seed_initial_superuser.py
carsten 5be79a7c7a Add simple JWT login for User
Password hashing via pwdlib (argon2id), stateless JWT auth (2h expiry)
delivered as an Authorization: Bearer token, with an OAuth2PasswordBearer
dependency (app/dependencies.py) protecting future routes. Establishes
app/routers/ as the convention for feature routes, explicitly registered
in main.py (no auto-discovery, unlike app/models/).

The first admin user is seeded via an Alembic data migration gated on
SUPERUSER_EMAIL/SUPERUSER_PASSWORD env vars (read at migration-run time
only, never persisted to .env/git) rather than a script or open endpoint,
so a fresh `alembic upgrade head` still succeeds without them. The
migration mirrors the users/organization tables locally instead of
importing the live SQLModel classes, keeping it a stable schema
snapshot; it does import app.core.security.hash_password, a pure
utility with no table-shape dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 22:23:25 +02:00

98 lines
3 KiB
Python

"""seed initial superuser
Revision ID: 237700c56d84
Revises: 31918c010919
Create Date: 2026-09-27 22:21:39.063163
"""
import os
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
from app.core.security import hash_password
# revision identifiers, used by Alembic.
revision: str = '237700c56d84'
down_revision: Union[str, Sequence[str], None] = '31918c010919'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
ORGANIZATION_NAME = "Fælles Vinindkøb"
organization_table = sa.table(
"organization", sa.column("id", sa.Integer), sa.column("name", sa.String)
)
users_table = sa.table(
"users",
sa.column("id", sa.Integer),
sa.column("email", sa.String),
sa.column("name", sa.String),
sa.column("is_active", sa.Boolean),
sa.column("is_superadmin", sa.Boolean),
sa.column("hashed_password", sa.String),
sa.column("organization_id", sa.Integer),
)
def upgrade() -> None:
"""Upgrade schema."""
email = os.environ.get("SUPERUSER_EMAIL")
password = os.environ.get("SUPERUSER_PASSWORD")
name = os.environ.get("SUPERUSER_NAME", "Admin")
if not email or not password:
print(
"SUPERUSER_EMAIL/SUPERUSER_PASSWORD not set — skipping initial "
"superuser seed (expected in CI / fresh environments)."
)
return
bind = op.get_bind()
org_id = bind.execute(
sa.select(organization_table.c.id).where(organization_table.c.name == ORGANIZATION_NAME)
).scalar_one_or_none()
if org_id is None:
org_id = bind.execute(
sa.insert(organization_table)
.values(name=ORGANIZATION_NAME)
.returning(organization_table.c.id)
).scalar_one()
print(f"Created organization {ORGANIZATION_NAME!r} (id={org_id})")
else:
print(f"Using existing organization {ORGANIZATION_NAME!r} (id={org_id})")
existing_id = bind.execute(
sa.select(users_table.c.id).where(users_table.c.email == email)
).scalar_one_or_none()
if existing_id is not None:
print(f"User {email!r} already exists (id={existing_id}) — skipping.")
return
bind.execute(
sa.insert(users_table).values(
email=email,
name=name,
is_active=True,
is_superadmin=True,
hashed_password=hash_password(password),
organization_id=org_id,
)
)
print(f"Created initial superuser {email!r} in organization {ORGANIZATION_NAME!r}.")
def downgrade() -> None:
"""Downgrade schema."""
email = os.environ.get("SUPERUSER_EMAIL")
if not email:
print("SUPERUSER_EMAIL not set — nothing to remove, skipping.")
return
bind = op.get_bind()
bind.execute(sa.delete(users_table).where(users_table.c.email == email))
# Organization-rækken slettes bevidst ikke — kan være delt med data
# oprettet uden for denne migration (ruter, deltagere).