Files
mule-image/backend/alembic/versions/0015_oidc_and_avatar.py
dtoro e8e1adcf37 feat(auth): Authentik OIDC sign-in + Gravatar avatars
Adds optional SSO via Authentik (or any OIDC provider) alongside the
existing password flow, and pulls profile images from the provider's
`picture` claim or Gravatar so the sharing UI stops looking anonymous.
Password login stays available as a recovery path; JIT provisioning and
admin-group mapping are env-configurable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:06:32 +02:00

84 lines
3.1 KiB
Python

"""OIDC identity + avatar / display_name on users
Revision ID: 0015_oidc_and_avatar
Revises: 0014_share_status
Create Date: 2026-04-22
Lets users sign in via an OIDC provider (Authentik) and carry a profile
image / display name from the provider. Password-only users are
unaffected.
1. Add users.oidc_issuer, users.oidc_sub (identity pair from the IdP).
2. Add users.avatar_url, users.display_name (profile bits from claims
or manually set).
3. Make users.hashed_password nullable — OIDC-only users have no local
password. Existing rows all have hashes so the NULLability change
is backwards-compatible.
4. Partial unique index on (oidc_issuer, oidc_sub) WHERE oidc_sub IS
NOT NULL so multiple password-only users (both NULL) don't collide.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0015_oidc_and_avatar"
down_revision: Union[str, None] = "0014_share_status"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
# 1 + 2. Add new columns (idempotent — create_all on fresh installs
# already built them from the model).
for col_def in (
"oidc_issuer VARCHAR",
"oidc_sub VARCHAR",
"avatar_url VARCHAR",
"display_name VARCHAR",
):
conn.execute(sa.text(f"ALTER TABLE users ADD COLUMN IF NOT EXISTS {col_def}"))
# 3. Drop NOT NULL on hashed_password. Postgres only — SQLite can't
# alter column nullability in place, but the SQLite escape hatch is
# used for fresh local dev where create_all already wrote the new
# nullable definition.
if conn.dialect.name == "postgresql":
conn.execute(sa.text(
"ALTER TABLE users ALTER COLUMN hashed_password DROP NOT NULL"
))
# 4. Partial unique index — Postgres supports the WHERE clause so
# NULLs don't collide; SQLite treats NULLs as distinct in unique
# indexes already, so a plain unique index is safe there too.
if conn.dialect.name == "postgresql":
conn.execute(sa.text(
"CREATE UNIQUE INDEX IF NOT EXISTS ix_users_oidc_identity "
"ON users (oidc_issuer, oidc_sub) WHERE oidc_sub IS NOT NULL"
))
else:
conn.execute(sa.text(
"CREATE UNIQUE INDEX IF NOT EXISTS ix_users_oidc_identity "
"ON users (oidc_issuer, oidc_sub)"
))
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("DROP INDEX IF EXISTS ix_users_oidc_identity"))
if conn.dialect.name == "postgresql":
# Can't re-apply NOT NULL if any OIDC-only user has NULL — so
# only do it when safe.
conn.execute(sa.text(
"UPDATE users SET hashed_password = '' WHERE hashed_password IS NULL"
))
conn.execute(sa.text(
"ALTER TABLE users ALTER COLUMN hashed_password SET NOT NULL"
))
for col in ("display_name", "avatar_url", "oidc_sub", "oidc_issuer"):
conn.execute(sa.text(f"ALTER TABLE users DROP COLUMN IF EXISTS {col}"))