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>
98 lines
3 KiB
Python
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).
|