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>
84 lines
3.1 KiB
Python
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}"))
|