Google 계정 데이터 별칭 복구를 추가
This commit is contained in:
parent
15f609368c
commit
dba9b75a38
9 changed files with 642 additions and 30 deletions
|
|
@ -177,6 +177,7 @@ class InactiveUserError(Exception):
|
||||||
_sessions: dict[str, SessionUser] = {}
|
_sessions: dict[str, SessionUser] = {}
|
||||||
_users: dict[str, ManagedUser] = {}
|
_users: dict[str, ManagedUser] = {}
|
||||||
_email_index: dict[str, str] = {}
|
_email_index: dict[str, str] = {}
|
||||||
|
_auth_identity_alias_index: dict[str, str] = {}
|
||||||
_inactive_emails: set[str] = set()
|
_inactive_emails: set[str] = set()
|
||||||
|
|
||||||
DEFAULT_AFFILIATION = settings.default_affiliation.strip()
|
DEFAULT_AFFILIATION = settings.default_affiliation.strip()
|
||||||
|
|
@ -373,6 +374,19 @@ async def _runtime_tables_ready(conn) -> bool:
|
||||||
) AS has_persona_triggers,
|
) AS has_persona_triggers,
|
||||||
to_regclass('app.persona_voice_map') IS NOT NULL AS has_persona_voice_map,
|
to_regclass('app.persona_voice_map') IS NOT NULL AS has_persona_voice_map,
|
||||||
to_regclass('app.auth_session') IS NOT NULL AS has_auth_session,
|
to_regclass('app.auth_session') IS NOT NULL AS has_auth_session,
|
||||||
|
to_regclass('app.auth_identity_alias') IS NOT NULL AS has_auth_identity_alias,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'app'
|
||||||
|
AND table_name = 'auth_session'
|
||||||
|
AND column_name = 'login_email'
|
||||||
|
) AS has_auth_session_login_email,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM pg_policies
|
||||||
|
WHERE schemaname = 'app'
|
||||||
|
AND tablename = 'auth_identity_alias'
|
||||||
|
AND policyname = 'p_auth_identity_alias_select'
|
||||||
|
) AS has_auth_identity_alias_select_policy,
|
||||||
to_regclass('app.user_preferences') IS NOT NULL AS has_preferences,
|
to_regclass('app.user_preferences') IS NOT NULL AS has_preferences,
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1 FROM information_schema.columns
|
SELECT 1 FROM information_schema.columns
|
||||||
|
|
@ -536,6 +550,9 @@ async def _runtime_tables_ready(conn) -> bool:
|
||||||
and row["has_persona_triggers"]
|
and row["has_persona_triggers"]
|
||||||
and row["has_persona_voice_map"]
|
and row["has_persona_voice_map"]
|
||||||
and row["has_auth_session"]
|
and row["has_auth_session"]
|
||||||
|
and row["has_auth_identity_alias"]
|
||||||
|
and row["has_auth_session_login_email"]
|
||||||
|
and row["has_auth_identity_alias_select_policy"]
|
||||||
and row["has_preferences"]
|
and row["has_preferences"]
|
||||||
and row["has_engine_config"]
|
and row["has_engine_config"]
|
||||||
and row["has_session_columns"]
|
and row["has_session_columns"]
|
||||||
|
|
@ -669,6 +686,36 @@ async def ensure_runtime_tables() -> None:
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE app.auth_session
|
||||||
|
ADD COLUMN IF NOT EXISTS login_email TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app.auth_identity_alias (
|
||||||
|
external_id TEXT PRIMARY KEY
|
||||||
|
CHECK (external_id ~ '^[a-z0-9_-]+:.+$'),
|
||||||
|
user_id UUID NOT NULL
|
||||||
|
REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
||||||
|
source_user_id UUID
|
||||||
|
REFERENCES app.app_user(user_id) ON DELETE SET NULL,
|
||||||
|
linked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
linked_by TEXT NOT NULL,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
CHECK (source_user_id IS NULL OR source_user_id <> user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_auth_identity_alias_user
|
||||||
|
ON app.auth_identity_alias(user_id, linked_at DESC);
|
||||||
|
|
||||||
|
ALTER TABLE app.auth_identity_alias ENABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS p_auth_identity_alias_select
|
||||||
|
ON app.auth_identity_alias;
|
||||||
|
CREATE POLICY p_auth_identity_alias_select
|
||||||
|
ON app.auth_identity_alias
|
||||||
|
FOR SELECT
|
||||||
|
USING (true);
|
||||||
|
"""
|
||||||
|
)
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_auth_session_user_active
|
CREATE INDEX IF NOT EXISTS idx_auth_session_user_active
|
||||||
|
|
@ -1619,6 +1666,78 @@ async def get_managed_user_by_email(email: str) -> ManagedUser | None:
|
||||||
return _users.get(uid or "")
|
return _users.get(uid or "")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_managed_user_by_auth_alias(external_id: str) -> ManagedUser | None:
|
||||||
|
"""Resolve an explicitly approved provider identity to its canonical user.
|
||||||
|
|
||||||
|
Email equality never creates an alias. A configured alias whose canonical
|
||||||
|
target is inactive or suspended fails closed instead of creating a new user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
normalized_external_id = (external_id or "").strip().lower()
|
||||||
|
if not normalized_external_id:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
pool = get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
u.user_id,
|
||||||
|
u.email,
|
||||||
|
u.display_name,
|
||||||
|
u.role,
|
||||||
|
u.admin_access,
|
||||||
|
u.learner_feedback_enabled,
|
||||||
|
u.account_status,
|
||||||
|
u.cohort,
|
||||||
|
u.affiliation,
|
||||||
|
u.legal_name,
|
||||||
|
u.department,
|
||||||
|
u.grade_level,
|
||||||
|
u.phone,
|
||||||
|
u.contact_address,
|
||||||
|
u.nickname,
|
||||||
|
u.self_introduction,
|
||||||
|
u.avatar_url,
|
||||||
|
u.consent_at,
|
||||||
|
u.profile_completed_at,
|
||||||
|
u.terms_agreed_at,
|
||||||
|
u.privacy_agreed_at,
|
||||||
|
u.terms_version,
|
||||||
|
u.privacy_version,
|
||||||
|
u.created_at,
|
||||||
|
u.last_seen_at,
|
||||||
|
u.is_active
|
||||||
|
FROM app.auth_identity_alias AS alias
|
||||||
|
JOIN app.app_user AS u ON u.user_id = alias.user_id
|
||||||
|
WHERE alias.external_id = $1
|
||||||
|
""",
|
||||||
|
normalized_external_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
if not bool(row["is_active"]) or _account_status(row["account_status"]) == "suspended":
|
||||||
|
raise InactiveUserError("aliased user is inactive")
|
||||||
|
user = _managed_user_from_row(row)
|
||||||
|
_memory_upsert_managed_user(
|
||||||
|
ManagedUserMemoryInput.from_user(user, reactivate=True)
|
||||||
|
)
|
||||||
|
_auth_identity_alias_index[normalized_external_id] = user.user_id
|
||||||
|
return user
|
||||||
|
except InactiveUserError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
require_runtime_fallback_allowed("auth identity alias lookup")
|
||||||
|
|
||||||
|
uid = _auth_identity_alias_index.get(normalized_external_id)
|
||||||
|
user = _users.get(uid or "")
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
if user.account_status == "suspended" or user.email in _inactive_emails:
|
||||||
|
raise InactiveUserError("aliased user is inactive")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def list_managed_users() -> tuple[list[ManagedUser], bool]:
|
async def list_managed_users() -> tuple[list[ManagedUser], bool]:
|
||||||
try:
|
try:
|
||||||
pool = get_pool()
|
pool = get_pool()
|
||||||
|
|
@ -2018,21 +2137,24 @@ async def create_session(
|
||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
external_id: str | None = None,
|
external_id: str | None = None,
|
||||||
account_status: AccountStatus | None = None,
|
account_status: AccountStatus | None = None,
|
||||||
|
managed_user: ManagedUser | None = None,
|
||||||
) -> tuple[str, SessionUser]:
|
) -> tuple[str, SessionUser]:
|
||||||
raw_sid = secrets.token_urlsafe(32)
|
raw_sid = secrets.token_urlsafe(32)
|
||||||
normalized_email = _normalize_email(email)
|
normalized_email = _normalize_email(email)
|
||||||
managed = await upsert_managed_user(
|
managed = managed_user
|
||||||
ManagedUserUpsertInput(
|
if managed is None:
|
||||||
email=normalized_email,
|
managed = await upsert_managed_user(
|
||||||
display_name=display_name,
|
ManagedUserUpsertInput(
|
||||||
role=role,
|
email=normalized_email,
|
||||||
cohort_ids=cohort_ids,
|
display_name=display_name,
|
||||||
user_id=user_id,
|
role=role,
|
||||||
external_id=external_id,
|
cohort_ids=cohort_ids,
|
||||||
account_status=account_status,
|
user_id=user_id,
|
||||||
reactivate=False,
|
external_id=external_id,
|
||||||
|
account_status=account_status,
|
||||||
|
reactivate=False,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
if managed.account_status == "pending":
|
if managed.account_status == "pending":
|
||||||
try:
|
try:
|
||||||
await notifications.enqueue_account_pending_approval(
|
await notifications.enqueue_account_pending_approval(
|
||||||
|
|
@ -2067,14 +2189,16 @@ async def create_session(
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO app.auth_session (
|
INSERT INTO app.auth_session (
|
||||||
sid_hash, user_id, role, display_name, cohort_ids, expires_at, last_seen_at
|
sid_hash, user_id, role, display_name, cohort_ids,
|
||||||
|
login_email, expires_at, last_seen_at
|
||||||
)
|
)
|
||||||
VALUES ($1, $2::uuid, $3, $4, $5::jsonb, $6, now())
|
VALUES ($1, $2::uuid, $3, $4, $5::jsonb, $6, $7, now())
|
||||||
ON CONFLICT (sid_hash) DO UPDATE SET
|
ON CONFLICT (sid_hash) DO UPDATE SET
|
||||||
user_id = EXCLUDED.user_id,
|
user_id = EXCLUDED.user_id,
|
||||||
role = EXCLUDED.role,
|
role = EXCLUDED.role,
|
||||||
display_name = EXCLUDED.display_name,
|
display_name = EXCLUDED.display_name,
|
||||||
cohort_ids = EXCLUDED.cohort_ids,
|
cohort_ids = EXCLUDED.cohort_ids,
|
||||||
|
login_email = EXCLUDED.login_email,
|
||||||
expires_at = EXCLUDED.expires_at,
|
expires_at = EXCLUDED.expires_at,
|
||||||
revoked_at = NULL,
|
revoked_at = NULL,
|
||||||
last_seen_at = now()
|
last_seen_at = now()
|
||||||
|
|
@ -2084,6 +2208,7 @@ async def create_session(
|
||||||
managed.role,
|
managed.role,
|
||||||
managed.display_name,
|
managed.display_name,
|
||||||
list(managed.cohort_ids),
|
list(managed.cohort_ids),
|
||||||
|
normalized_email,
|
||||||
datetime.fromtimestamp(expires_at, tz=timezone.utc),
|
datetime.fromtimestamp(expires_at, tz=timezone.utc),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -2104,7 +2229,8 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
|
||||||
SELECT
|
SELECT
|
||||||
s.expires_at,
|
s.expires_at,
|
||||||
u.user_id,
|
u.user_id,
|
||||||
u.email,
|
COALESCE(NULLIF(s.login_email, ''), u.email) AS login_email,
|
||||||
|
u.email AS canonical_email,
|
||||||
COALESCE(u.display_name, s.display_name, u.email) AS display_name,
|
COALESCE(u.display_name, s.display_name, u.email) AS display_name,
|
||||||
u.role,
|
u.role,
|
||||||
u.admin_access,
|
u.admin_access,
|
||||||
|
|
@ -2128,7 +2254,7 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
|
||||||
_row_value(row, "admin_access", False)
|
_row_value(row, "admin_access", False)
|
||||||
)
|
)
|
||||||
effective_admin_access = has_admin_access(
|
effective_admin_access = has_admin_access(
|
||||||
row["email"],
|
row["canonical_email"],
|
||||||
app_role,
|
app_role,
|
||||||
stored_admin_access,
|
stored_admin_access,
|
||||||
)
|
)
|
||||||
|
|
@ -2155,11 +2281,11 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
|
||||||
)
|
)
|
||||||
return SessionUser(
|
return SessionUser(
|
||||||
user_id=str(row["user_id"]),
|
user_id=str(row["user_id"]),
|
||||||
email=row["email"],
|
email=row["login_email"],
|
||||||
display_name=row["display_name"],
|
display_name=row["display_name"],
|
||||||
role=app_role,
|
role=app_role,
|
||||||
admin_access=effective_admin_access,
|
admin_access=effective_admin_access,
|
||||||
super_admin=is_super_admin_email(row["email"]),
|
super_admin=is_super_admin_email(row["canonical_email"]),
|
||||||
account_status=_account_status(row["account_status"]),
|
account_status=_account_status(row["account_status"]),
|
||||||
cohort_ids=_cohort_ids(row["cohort"]),
|
cohort_ids=_cohort_ids(row["cohort"]),
|
||||||
consent_at=_optional_ts(row["consent_at"]),
|
consent_at=_optional_ts(row["consent_at"]),
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,13 @@ async def healthcheck() -> bool:
|
||||||
SELECT
|
SELECT
|
||||||
to_regclass('app.app_user') IS NOT NULL AS has_user,
|
to_regclass('app.app_user') IS NOT NULL AS has_user,
|
||||||
to_regclass('app.auth_session') IS NOT NULL AS has_auth_session,
|
to_regclass('app.auth_session') IS NOT NULL AS has_auth_session,
|
||||||
|
to_regclass('app.auth_identity_alias') IS NOT NULL AS has_auth_identity_alias,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'app'
|
||||||
|
AND table_name = 'auth_session'
|
||||||
|
AND column_name = 'login_email'
|
||||||
|
) AS has_auth_session_login_email,
|
||||||
to_regclass('app.user_preferences') IS NOT NULL AS has_preferences,
|
to_regclass('app.user_preferences') IS NOT NULL AS has_preferences,
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1 FROM information_schema.columns
|
SELECT 1 FROM information_schema.columns
|
||||||
|
|
@ -210,6 +217,8 @@ async def healthcheck() -> bool:
|
||||||
row
|
row
|
||||||
and row["has_user"]
|
and row["has_user"]
|
||||||
and row["has_auth_session"]
|
and row["has_auth_session"]
|
||||||
|
and row["has_auth_identity_alias"]
|
||||||
|
and row["has_auth_session_login_email"]
|
||||||
and row["has_preferences"]
|
and row["has_preferences"]
|
||||||
and row["has_engine_config"]
|
and row["has_engine_config"]
|
||||||
and row["has_admin_health_event"]
|
and row["has_admin_health_event"]
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ from ..auth_types import AccountStatus, RoleName
|
||||||
from ..auth_sessions import (
|
from ..auth_sessions import (
|
||||||
ManagedUser,
|
ManagedUser,
|
||||||
get_managed_user,
|
get_managed_user,
|
||||||
|
get_managed_user_by_auth_alias,
|
||||||
get_managed_user_by_email,
|
get_managed_user_by_email,
|
||||||
has_admin_access,
|
has_admin_access,
|
||||||
InactiveUserError,
|
InactiveUserError,
|
||||||
|
|
@ -705,7 +706,10 @@ async def _me_response(user: SessionUser | Principal) -> MeResponse:
|
||||||
)
|
)
|
||||||
email = getattr(user, "email", "")
|
email = getattr(user, "email", "")
|
||||||
role = user.role.value if isinstance(user.role, Role) else user.role
|
role = user.role.value if isinstance(user.role, Role) else user.role
|
||||||
stored_admin_access = managed.admin_access if managed else getattr(user, "admin_access", False)
|
stored_admin_access = (
|
||||||
|
managed.admin_access if managed else getattr(user, "admin_access", False)
|
||||||
|
)
|
||||||
|
entitlement_email = managed.email if managed else email
|
||||||
return MeResponse(
|
return MeResponse(
|
||||||
user_id=user.user_id,
|
user_id=user.user_id,
|
||||||
email=email,
|
email=email,
|
||||||
|
|
@ -715,8 +719,8 @@ async def _me_response(user: SessionUser | Principal) -> MeResponse:
|
||||||
or email
|
or email
|
||||||
),
|
),
|
||||||
role=role,
|
role=role,
|
||||||
admin_access=has_admin_access(email, role, stored_admin_access),
|
admin_access=has_admin_access(entitlement_email, role, stored_admin_access),
|
||||||
super_admin=is_super_admin_email(email),
|
super_admin=is_super_admin_email(entitlement_email),
|
||||||
account_status=account_status,
|
account_status=account_status,
|
||||||
approval_required=account_status != "approved",
|
approval_required=account_status != "approved",
|
||||||
cohort_ids=user.cohort_ids,
|
cohort_ids=user.cohort_ids,
|
||||||
|
|
@ -970,7 +974,17 @@ async def callback(
|
||||||
email_domain=_email_domain(str(claims.get("email") or "")),
|
email_domain=_email_domain(str(claims.get("email") or "")),
|
||||||
)
|
)
|
||||||
return _oauth_callback_error("id_token_invalid", request)
|
return _oauth_callback_error("id_token_invalid", request)
|
||||||
managed_user = await get_managed_user_by_email(email)
|
external_id = _provider_external_id("google", str(claims.get("sub") or ""), email)
|
||||||
|
try:
|
||||||
|
aliased_user = await get_managed_user_by_auth_alias(external_id)
|
||||||
|
except InactiveUserError:
|
||||||
|
_log_oauth_callback_failure(
|
||||||
|
request,
|
||||||
|
"inactive_user",
|
||||||
|
email_domain=_email_domain(email),
|
||||||
|
)
|
||||||
|
return _oauth_callback_error("inactive_user", request)
|
||||||
|
managed_user = aliased_user or await get_managed_user_by_email(email)
|
||||||
role = _role_for_managed_user(managed_user, _role_for_email(email))
|
role = _role_for_managed_user(managed_user, _role_for_email(email))
|
||||||
display_name = str(claims.get("name") or email)
|
display_name = str(claims.get("name") or email)
|
||||||
cohort_ids = _cohort_ids_for_managed_user(
|
cohort_ids = _cohort_ids_for_managed_user(
|
||||||
|
|
@ -980,16 +994,26 @@ async def callback(
|
||||||
hosted_domain=str(claims.get("hd") or ""),
|
hosted_domain=str(claims.get("hd") or ""),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
external_id = _provider_external_id("google", str(claims.get("sub") or ""), email)
|
|
||||||
try:
|
try:
|
||||||
sid, _ = await create_session(
|
if aliased_user is not None:
|
||||||
email=email,
|
sid, _ = await create_session(
|
||||||
display_name=display_name,
|
email=email,
|
||||||
role=role.value,
|
display_name=display_name,
|
||||||
cohort_ids=cohort_ids,
|
role=role.value,
|
||||||
external_id=external_id,
|
cohort_ids=cohort_ids,
|
||||||
account_status="approved",
|
external_id=external_id,
|
||||||
)
|
account_status="approved",
|
||||||
|
managed_user=aliased_user,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sid, _ = await create_session(
|
||||||
|
email=email,
|
||||||
|
display_name=display_name,
|
||||||
|
role=role.value,
|
||||||
|
cohort_ids=cohort_ids,
|
||||||
|
external_id=external_id,
|
||||||
|
account_status="approved",
|
||||||
|
)
|
||||||
except InactiveUserError:
|
except InactiveUserError:
|
||||||
_log_oauth_callback_failure(
|
_log_oauth_callback_failure(
|
||||||
request,
|
request,
|
||||||
|
|
|
||||||
387
apps/api/app/test_auth_identity_alias.py
Normal file
387
apps/api/app/test_auth_identity_alias.py
Normal file
|
|
@ -0,0 +1,387 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from . import auth_sessions
|
||||||
|
from .config import settings
|
||||||
|
from .routes import auth as auth_routes
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def patched_settings(**values: Any):
|
||||||
|
previous = {key: getattr(settings, key) for key in values}
|
||||||
|
for key, value in values.items():
|
||||||
|
setattr(settings, key, value)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
for key, value in previous.items():
|
||||||
|
setattr(settings, key, value)
|
||||||
|
|
||||||
|
|
||||||
|
def managed_user(
|
||||||
|
*,
|
||||||
|
user_id: str = "f646404b-a9e5-4422-a97d-548c074334d5",
|
||||||
|
email: str = "yunchan@twentyoz.kr",
|
||||||
|
account_status: str = "approved",
|
||||||
|
) -> auth_sessions.ManagedUser:
|
||||||
|
return auth_sessions.ManagedUser(
|
||||||
|
user_id=user_id,
|
||||||
|
email=email,
|
||||||
|
display_name="Yun Chan",
|
||||||
|
role="admin",
|
||||||
|
admin_access=True,
|
||||||
|
account_status=account_status,
|
||||||
|
cohort_ids=["owner"],
|
||||||
|
affiliation="TwentyOZ",
|
||||||
|
legal_name="Yun Chan",
|
||||||
|
department="",
|
||||||
|
grade_level="",
|
||||||
|
phone="",
|
||||||
|
contact_address="",
|
||||||
|
nickname="윤찬",
|
||||||
|
self_introduction="관리자",
|
||||||
|
avatar_url="",
|
||||||
|
consent_at=1.0,
|
||||||
|
profile_completed_at=1.0,
|
||||||
|
terms_agreed_at=1.0,
|
||||||
|
privacy_agreed_at=1.0,
|
||||||
|
terms_version="v1",
|
||||||
|
privacy_version="v1",
|
||||||
|
created_at=1.0,
|
||||||
|
last_seen_at=1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def alias_row(*, active: bool = True, status: str = "approved") -> dict[str, Any]:
|
||||||
|
user = managed_user(account_status=status)
|
||||||
|
return {
|
||||||
|
"user_id": user.user_id,
|
||||||
|
"email": user.email,
|
||||||
|
"display_name": user.display_name,
|
||||||
|
"role": "admin",
|
||||||
|
"admin_access": user.admin_access,
|
||||||
|
"learner_feedback_enabled": True,
|
||||||
|
"account_status": status,
|
||||||
|
"cohort": "owner",
|
||||||
|
"affiliation": user.affiliation,
|
||||||
|
"legal_name": user.legal_name,
|
||||||
|
"department": user.department,
|
||||||
|
"grade_level": user.grade_level,
|
||||||
|
"phone": user.phone,
|
||||||
|
"contact_address": user.contact_address,
|
||||||
|
"nickname": user.nickname,
|
||||||
|
"self_introduction": user.self_introduction,
|
||||||
|
"avatar_url": user.avatar_url,
|
||||||
|
"consent_at": datetime.fromtimestamp(1.0, tz=timezone.utc),
|
||||||
|
"profile_completed_at": datetime.fromtimestamp(1.0, tz=timezone.utc),
|
||||||
|
"terms_agreed_at": datetime.fromtimestamp(1.0, tz=timezone.utc),
|
||||||
|
"privacy_agreed_at": datetime.fromtimestamp(1.0, tz=timezone.utc),
|
||||||
|
"terms_version": user.terms_version,
|
||||||
|
"privacy_version": user.privacy_version,
|
||||||
|
"created_at": datetime.fromtimestamp(1.0, tz=timezone.utc),
|
||||||
|
"last_seen_at": datetime.fromtimestamp(1.0, tz=timezone.utc),
|
||||||
|
"is_active": active,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAcquire:
|
||||||
|
def __init__(self, conn: Any) -> None:
|
||||||
|
self.conn = conn
|
||||||
|
|
||||||
|
async def __aenter__(self) -> Any:
|
||||||
|
return self.conn
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class FakePool:
|
||||||
|
def __init__(self, conn: Any) -> None:
|
||||||
|
self.conn = conn
|
||||||
|
|
||||||
|
def acquire(self) -> FakeAcquire:
|
||||||
|
return FakeAcquire(self.conn)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthIdentityAliasTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def asyncSetUp(self) -> None:
|
||||||
|
auth_routes._oauth_states.clear()
|
||||||
|
auth_sessions._sessions.clear()
|
||||||
|
auth_sessions._users.clear()
|
||||||
|
auth_sessions._email_index.clear()
|
||||||
|
auth_sessions._auth_identity_alias_index.clear()
|
||||||
|
auth_sessions._inactive_emails.clear()
|
||||||
|
|
||||||
|
async def asyncTearDown(self) -> None:
|
||||||
|
auth_routes._oauth_states.clear()
|
||||||
|
auth_sessions._sessions.clear()
|
||||||
|
auth_sessions._users.clear()
|
||||||
|
auth_sessions._email_index.clear()
|
||||||
|
auth_sessions._auth_identity_alias_index.clear()
|
||||||
|
auth_sessions._inactive_emails.clear()
|
||||||
|
|
||||||
|
async def test_explicit_alias_resolves_canonical_user_without_email_merge(self) -> None:
|
||||||
|
class Conn:
|
||||||
|
query = ""
|
||||||
|
args: tuple[Any, ...] = ()
|
||||||
|
|
||||||
|
async def fetchrow(self, query: str, *args: Any) -> dict[str, Any]:
|
||||||
|
self.query = query
|
||||||
|
self.args = args
|
||||||
|
return alias_row()
|
||||||
|
|
||||||
|
conn = Conn()
|
||||||
|
with patch.object(auth_sessions, "get_pool", return_value=FakePool(conn)):
|
||||||
|
user = await auth_sessions.get_managed_user_by_auth_alias(
|
||||||
|
"google:gmail-subject"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(user)
|
||||||
|
self.assertEqual(user.user_id, managed_user().user_id)
|
||||||
|
self.assertEqual(user.email, "yunchan@twentyoz.kr")
|
||||||
|
self.assertIn("app.auth_identity_alias", conn.query)
|
||||||
|
self.assertEqual(conn.args, ("google:gmail-subject",))
|
||||||
|
|
||||||
|
async def test_alias_to_inactive_or_suspended_user_fails_closed(self) -> None:
|
||||||
|
class Conn:
|
||||||
|
def __init__(self, row: dict[str, Any]) -> None:
|
||||||
|
self.row = row
|
||||||
|
|
||||||
|
async def fetchrow(self, query: str, *args: Any) -> dict[str, Any]:
|
||||||
|
return self.row
|
||||||
|
|
||||||
|
for row in (alias_row(active=False), alias_row(status="suspended")):
|
||||||
|
with (
|
||||||
|
self.subTest(row=row),
|
||||||
|
patch.object(auth_sessions, "get_pool", return_value=FakePool(Conn(row))),
|
||||||
|
):
|
||||||
|
with self.assertRaises(auth_sessions.InactiveUserError):
|
||||||
|
await auth_sessions.get_managed_user_by_auth_alias(
|
||||||
|
"google:gmail-subject"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_alias_session_keeps_login_email_and_canonical_entitlements(self) -> None:
|
||||||
|
class Conn:
|
||||||
|
query = ""
|
||||||
|
args: tuple[Any, ...] = ()
|
||||||
|
|
||||||
|
async def execute(self, query: str, *args: Any) -> str:
|
||||||
|
self.query = query
|
||||||
|
self.args = args
|
||||||
|
return "INSERT 0 1"
|
||||||
|
|
||||||
|
conn = Conn()
|
||||||
|
canonical = managed_user()
|
||||||
|
with (
|
||||||
|
patched_settings(
|
||||||
|
environment="prod",
|
||||||
|
auth_super_admin_emails=[canonical.email],
|
||||||
|
),
|
||||||
|
patch.object(auth_sessions, "get_pool", return_value=FakePool(conn)),
|
||||||
|
):
|
||||||
|
_, session_user = await auth_sessions.create_session(
|
||||||
|
email="yunchan8804@gmail.com",
|
||||||
|
display_name="Yun Chan",
|
||||||
|
role="admin",
|
||||||
|
external_id="google:gmail-subject",
|
||||||
|
account_status="approved",
|
||||||
|
managed_user=canonical,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(session_user.user_id, canonical.user_id)
|
||||||
|
self.assertEqual(session_user.email, "yunchan8804@gmail.com")
|
||||||
|
self.assertTrue(session_user.super_admin)
|
||||||
|
self.assertTrue(session_user.admin_access)
|
||||||
|
self.assertIn("login_email", conn.query)
|
||||||
|
self.assertEqual(conn.args[1], canonical.user_id)
|
||||||
|
self.assertEqual(conn.args[5], "yunchan8804@gmail.com")
|
||||||
|
|
||||||
|
async def test_restored_session_uses_login_email_but_canonical_super_admin(self) -> None:
|
||||||
|
canonical = managed_user()
|
||||||
|
|
||||||
|
class Conn:
|
||||||
|
query = ""
|
||||||
|
|
||||||
|
async def fetchrow(self, query: str, *args: Any) -> dict[str, Any]:
|
||||||
|
self.query = query
|
||||||
|
return {
|
||||||
|
"expires_at": datetime.fromtimestamp(
|
||||||
|
time.time() + 3600, tz=timezone.utc
|
||||||
|
),
|
||||||
|
"user_id": canonical.user_id,
|
||||||
|
"login_email": "yunchan8804@gmail.com",
|
||||||
|
"canonical_email": canonical.email,
|
||||||
|
"display_name": canonical.display_name,
|
||||||
|
"role": "admin",
|
||||||
|
"admin_access": True,
|
||||||
|
"learner_feedback_enabled": True,
|
||||||
|
"account_status": "approved",
|
||||||
|
"cohort": "owner",
|
||||||
|
"consent_at": datetime.fromtimestamp(1.0, tz=timezone.utc),
|
||||||
|
"profile_completed_at": datetime.fromtimestamp(
|
||||||
|
1.0, tz=timezone.utc
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def execute(self, query: str, *args: Any) -> str:
|
||||||
|
return "UPDATE 1"
|
||||||
|
|
||||||
|
conn = Conn()
|
||||||
|
with (
|
||||||
|
patched_settings(auth_super_admin_emails=[canonical.email]),
|
||||||
|
patch.object(auth_sessions, "get_pool", return_value=FakePool(conn)),
|
||||||
|
):
|
||||||
|
restored = await auth_sessions.get_session("opaque-session")
|
||||||
|
|
||||||
|
self.assertIsNotNone(restored)
|
||||||
|
self.assertEqual(restored.email, "yunchan8804@gmail.com")
|
||||||
|
self.assertTrue(restored.super_admin)
|
||||||
|
self.assertIn("s.login_email", conn.query)
|
||||||
|
self.assertIn("canonical_email", conn.query)
|
||||||
|
|
||||||
|
async def test_me_response_uses_canonical_entitlement_not_login_email(self) -> None:
|
||||||
|
canonical = managed_user()
|
||||||
|
session_user = auth_sessions.SessionUser(
|
||||||
|
user_id=canonical.user_id,
|
||||||
|
email="yunchan8804@gmail.com",
|
||||||
|
display_name=canonical.display_name,
|
||||||
|
role="admin",
|
||||||
|
admin_access=True,
|
||||||
|
super_admin=True,
|
||||||
|
account_status="approved",
|
||||||
|
cohort_ids=canonical.cohort_ids,
|
||||||
|
consent_at=canonical.consent_at,
|
||||||
|
profile_completed_at=canonical.profile_completed_at,
|
||||||
|
expires_at=time.time() + 3600,
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patched_settings(auth_super_admin_emails=[canonical.email]),
|
||||||
|
patch.object(
|
||||||
|
auth_routes,
|
||||||
|
"get_managed_user",
|
||||||
|
AsyncMock(return_value=canonical),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
response = await auth_routes._me_response(session_user)
|
||||||
|
|
||||||
|
self.assertEqual(response.email, "yunchan8804@gmail.com")
|
||||||
|
self.assertTrue(response.admin_access)
|
||||||
|
self.assertTrue(response.super_admin)
|
||||||
|
self.assertIsNotNone(response.onboarding_completed_at)
|
||||||
|
|
||||||
|
async def test_google_callback_resolves_alias_before_email_and_redirects_admin(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
state = "gmail-alias-state"
|
||||||
|
auth_routes._oauth_states[state] = auth_routes.OAuthState(
|
||||||
|
code_verifier="verifier",
|
||||||
|
next_path="/learn",
|
||||||
|
created_at=time.time(),
|
||||||
|
)
|
||||||
|
canonical = managed_user()
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
|
def __init__(self, payload: dict[str, Any]) -> None:
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
def json(self) -> dict[str, Any]:
|
||||||
|
return self.payload
|
||||||
|
|
||||||
|
class FakeAsyncClient:
|
||||||
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "FakeAsyncClient":
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def post(self, url: str, **kwargs: Any) -> FakeResponse:
|
||||||
|
return FakeResponse({"id_token": "id-token"})
|
||||||
|
|
||||||
|
async def get(self, url: str, **kwargs: Any) -> FakeResponse:
|
||||||
|
return FakeResponse(
|
||||||
|
{
|
||||||
|
"aud": "google-client",
|
||||||
|
"iss": "https://accounts.google.com",
|
||||||
|
"email": "yunchan8804@gmail.com",
|
||||||
|
"email_verified": True,
|
||||||
|
"name": "Yun Chan",
|
||||||
|
"sub": "gmail-subject",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
request = Request(
|
||||||
|
{
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/auth/callback",
|
||||||
|
"headers": [(b"host", b"api-vignette.test")],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
create_session = AsyncMock(return_value=("opaque-session", object()))
|
||||||
|
email_lookup = AsyncMock(side_effect=AssertionError("email lookup must not run"))
|
||||||
|
with (
|
||||||
|
patched_settings(
|
||||||
|
environment="prod",
|
||||||
|
oauth_google_client_id="google-client",
|
||||||
|
oauth_google_client_secret="google-secret",
|
||||||
|
frontend_base_url="https://vignette.test",
|
||||||
|
auth_super_admin_emails=[canonical.email],
|
||||||
|
),
|
||||||
|
patch.object(auth_routes.httpx, "AsyncClient", FakeAsyncClient),
|
||||||
|
patch.object(
|
||||||
|
auth_routes,
|
||||||
|
"get_managed_user_by_auth_alias",
|
||||||
|
AsyncMock(return_value=canonical),
|
||||||
|
),
|
||||||
|
patch.object(auth_routes, "get_managed_user_by_email", email_lookup),
|
||||||
|
patch.object(auth_routes, "create_session", create_session),
|
||||||
|
):
|
||||||
|
response = await auth_routes.callback(
|
||||||
|
request,
|
||||||
|
code="auth-code",
|
||||||
|
state=state,
|
||||||
|
oauth_state_cookie=state,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.headers["location"], "https://vignette.test/admin")
|
||||||
|
email_lookup.assert_not_awaited()
|
||||||
|
create_session.assert_awaited_once_with(
|
||||||
|
email="yunchan8804@gmail.com",
|
||||||
|
display_name="Yun Chan",
|
||||||
|
role="admin",
|
||||||
|
cohort_ids=["owner"],
|
||||||
|
external_id="google:gmail-subject",
|
||||||
|
account_status="approved",
|
||||||
|
managed_user=canonical,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_migration_makes_alias_runtime_read_only(self) -> None:
|
||||||
|
migration = (
|
||||||
|
Path(__file__).resolve().parents[3]
|
||||||
|
/ "infra"
|
||||||
|
/ "db"
|
||||||
|
/ "init"
|
||||||
|
/ "19_auth_identity_alias.sql"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("CREATE TABLE IF NOT EXISTS app.auth_identity_alias", migration)
|
||||||
|
self.assertIn("ENABLE ROW LEVEL SECURITY", migration)
|
||||||
|
self.assertIn("FOR SELECT", migration)
|
||||||
|
self.assertNotIn("FOR INSERT", migration)
|
||||||
|
self.assertNotIn("FOR UPDATE", migration)
|
||||||
|
self.assertNotIn("FOR DELETE", migration)
|
||||||
|
self.assertIn("ADD COLUMN IF NOT EXISTS login_email", migration)
|
||||||
|
|
@ -139,6 +139,7 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
||||||
auth_sessions._sessions.clear()
|
auth_sessions._sessions.clear()
|
||||||
auth_sessions._users.clear()
|
auth_sessions._users.clear()
|
||||||
auth_sessions._email_index.clear()
|
auth_sessions._email_index.clear()
|
||||||
|
auth_sessions._auth_identity_alias_index.clear()
|
||||||
auth_sessions._inactive_emails.clear()
|
auth_sessions._inactive_emails.clear()
|
||||||
|
|
||||||
async def asyncTearDown(self) -> None:
|
async def asyncTearDown(self) -> None:
|
||||||
|
|
@ -147,6 +148,7 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
||||||
auth_sessions._sessions.clear()
|
auth_sessions._sessions.clear()
|
||||||
auth_sessions._users.clear()
|
auth_sessions._users.clear()
|
||||||
auth_sessions._email_index.clear()
|
auth_sessions._email_index.clear()
|
||||||
|
auth_sessions._auth_identity_alias_index.clear()
|
||||||
auth_sessions._inactive_emails.clear()
|
auth_sessions._inactive_emails.clear()
|
||||||
|
|
||||||
async def test_auth_config_reports_google_and_saml_provider_status(self) -> None:
|
async def test_auth_config_reports_google_and_saml_provider_status(self) -> None:
|
||||||
|
|
@ -1188,6 +1190,11 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
||||||
auth_allowed_email_domains=["hs.ac.kr"],
|
auth_allowed_email_domains=["hs.ac.kr"],
|
||||||
),
|
),
|
||||||
patch.object(auth_routes.httpx, "AsyncClient", FakeAsyncClient),
|
patch.object(auth_routes.httpx, "AsyncClient", FakeAsyncClient),
|
||||||
|
patch.object(
|
||||||
|
auth_routes,
|
||||||
|
"get_managed_user_by_auth_alias",
|
||||||
|
AsyncMock(return_value=None),
|
||||||
|
),
|
||||||
patch.object(
|
patch.object(
|
||||||
auth_routes,
|
auth_routes,
|
||||||
"get_managed_user_by_email",
|
"get_managed_user_by_email",
|
||||||
|
|
@ -1272,6 +1279,11 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
||||||
auth_email_cohort_map={"learner@hs.ac.kr": "pilot-a"},
|
auth_email_cohort_map={"learner@hs.ac.kr": "pilot-a"},
|
||||||
),
|
),
|
||||||
patch.object(auth_routes.httpx, "AsyncClient", FakeAsyncClient),
|
patch.object(auth_routes.httpx, "AsyncClient", FakeAsyncClient),
|
||||||
|
patch.object(
|
||||||
|
auth_routes,
|
||||||
|
"get_managed_user_by_auth_alias",
|
||||||
|
AsyncMock(return_value=None),
|
||||||
|
),
|
||||||
patch.object(
|
patch.object(
|
||||||
auth_routes,
|
auth_routes,
|
||||||
"get_managed_user_by_email",
|
"get_managed_user_by_email",
|
||||||
|
|
@ -1363,6 +1375,11 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
||||||
auth_super_admin_emails=[],
|
auth_super_admin_emails=[],
|
||||||
),
|
),
|
||||||
patch.object(auth_routes.httpx, "AsyncClient", FakeAsyncClient),
|
patch.object(auth_routes.httpx, "AsyncClient", FakeAsyncClient),
|
||||||
|
patch.object(
|
||||||
|
auth_routes,
|
||||||
|
"get_managed_user_by_auth_alias",
|
||||||
|
AsyncMock(return_value=None),
|
||||||
|
),
|
||||||
patch.object(auth_routes, "get_managed_user_by_email", AsyncMock(return_value=managed)),
|
patch.object(auth_routes, "get_managed_user_by_email", AsyncMock(return_value=managed)),
|
||||||
patch.object(auth_routes, "create_session", create_session_mock),
|
patch.object(auth_routes, "create_session", create_session_mock),
|
||||||
):
|
):
|
||||||
|
|
@ -1456,6 +1473,11 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
||||||
session_secret="signed-oauth-state-secret",
|
session_secret="signed-oauth-state-secret",
|
||||||
),
|
),
|
||||||
patch.object(auth_routes.httpx, "AsyncClient", FakeAsyncClient),
|
patch.object(auth_routes.httpx, "AsyncClient", FakeAsyncClient),
|
||||||
|
patch.object(
|
||||||
|
auth_routes,
|
||||||
|
"get_managed_user_by_auth_alias",
|
||||||
|
AsyncMock(return_value=None),
|
||||||
|
),
|
||||||
patch.object(
|
patch.object(
|
||||||
auth_routes,
|
auth_routes,
|
||||||
"get_managed_user_by_email",
|
"get_managed_user_by_email",
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,9 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
|
||||||
"has_persona_triggers": True,
|
"has_persona_triggers": True,
|
||||||
"has_persona_voice_map": True,
|
"has_persona_voice_map": True,
|
||||||
"has_auth_session": True,
|
"has_auth_session": True,
|
||||||
|
"has_auth_identity_alias": True,
|
||||||
|
"has_auth_session_login_email": True,
|
||||||
|
"has_auth_identity_alias_select_policy": True,
|
||||||
"has_preferences": True,
|
"has_preferences": True,
|
||||||
"has_engine_config": True,
|
"has_engine_config": True,
|
||||||
"has_session_columns": True,
|
"has_session_columns": True,
|
||||||
|
|
|
||||||
38
infra/db/init/19_auth_identity_alias.sql
Normal file
38
infra/db/init/19_auth_identity_alias.sql
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
-- Multiple provider identities may resolve to one canonical app user.
|
||||||
|
-- Alias writes are owner-only: the runtime app role can read through RLS but
|
||||||
|
-- receives no INSERT/UPDATE/DELETE policy.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app.auth_identity_alias (
|
||||||
|
external_id TEXT PRIMARY KEY
|
||||||
|
CHECK (external_id ~ '^[a-z0-9_-]+:.+$'),
|
||||||
|
user_id UUID NOT NULL
|
||||||
|
REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
||||||
|
source_user_id UUID
|
||||||
|
REFERENCES app.app_user(user_id) ON DELETE SET NULL,
|
||||||
|
linked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
linked_by TEXT NOT NULL,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
CHECK (source_user_id IS NULL OR source_user_id <> user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_auth_identity_alias_user
|
||||||
|
ON app.auth_identity_alias(user_id, linked_at DESC);
|
||||||
|
|
||||||
|
ALTER TABLE app.auth_identity_alias ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS p_auth_identity_alias_select
|
||||||
|
ON app.auth_identity_alias;
|
||||||
|
CREATE POLICY p_auth_identity_alias_select
|
||||||
|
ON app.auth_identity_alias
|
||||||
|
FOR SELECT
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
ALTER TABLE app.auth_session
|
||||||
|
ADD COLUMN IF NOT EXISTS login_email TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
UPDATE app.auth_session AS s
|
||||||
|
SET login_email = lower(u.email)
|
||||||
|
FROM app.app_user AS u
|
||||||
|
WHERE u.user_id = s.user_id
|
||||||
|
AND s.login_email = ''
|
||||||
|
AND u.email IS NOT NULL;
|
||||||
|
|
@ -121,6 +121,7 @@ RELEASE_DB_MIGRATIONS = (
|
||||||
"15_self_directed_practice_runtime.sql",
|
"15_self_directed_practice_runtime.sql",
|
||||||
"16_calibration_transfer_actual_execution.sql",
|
"16_calibration_transfer_actual_execution.sql",
|
||||||
"17_improvement_workbook_contracts.sql",
|
"17_improvement_workbook_contracts.sql",
|
||||||
|
"19_auth_identity_alias.sql",
|
||||||
)
|
)
|
||||||
|
|
||||||
# These files are runtime-critical but were added after the first manifest
|
# These files are runtime-critical but were added after the first manifest
|
||||||
|
|
@ -132,6 +133,7 @@ REQUIRED_RELEASE_PAYLOAD_PATHS = (
|
||||||
"infra/db/init/15_self_directed_practice_runtime.sql",
|
"infra/db/init/15_self_directed_practice_runtime.sql",
|
||||||
"infra/db/init/16_calibration_transfer_actual_execution.sql",
|
"infra/db/init/16_calibration_transfer_actual_execution.sql",
|
||||||
"infra/db/init/17_improvement_workbook_contracts.sql",
|
"infra/db/init/17_improvement_workbook_contracts.sql",
|
||||||
|
"infra/db/init/19_auth_identity_alias.sql",
|
||||||
)
|
)
|
||||||
|
|
||||||
REQUIRED_OPENAPI_PATHS = {
|
REQUIRED_OPENAPI_PATHS = {
|
||||||
|
|
|
||||||
|
|
@ -740,6 +740,7 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
|
||||||
"15_self_directed_practice_runtime.sql",
|
"15_self_directed_practice_runtime.sql",
|
||||||
"16_calibration_transfer_actual_execution.sql",
|
"16_calibration_transfer_actual_execution.sql",
|
||||||
"17_improvement_workbook_contracts.sql",
|
"17_improvement_workbook_contracts.sql",
|
||||||
|
"19_auth_identity_alias.sql",
|
||||||
),
|
),
|
||||||
self.agent_module.RELEASE_DB_MIGRATIONS,
|
self.agent_module.RELEASE_DB_MIGRATIONS,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue