diff --git a/apps/api/app/auth_sessions.py b/apps/api/app/auth_sessions.py index 46fc35e..0412577 100644 --- a/apps/api/app/auth_sessions.py +++ b/apps/api/app/auth_sessions.py @@ -177,6 +177,7 @@ class InactiveUserError(Exception): _sessions: dict[str, SessionUser] = {} _users: dict[str, ManagedUser] = {} _email_index: dict[str, str] = {} +_auth_identity_alias_index: dict[str, str] = {} _inactive_emails: set[str] = set() DEFAULT_AFFILIATION = settings.default_affiliation.strip() @@ -373,6 +374,19 @@ async def _runtime_tables_ready(conn) -> bool: ) AS has_persona_triggers, 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_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, EXISTS ( 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_voice_map"] 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_engine_config"] 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( """ 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 "") +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]: try: pool = get_pool() @@ -2018,21 +2137,24 @@ async def create_session( user_id: str | None = None, external_id: str | None = None, account_status: AccountStatus | None = None, + managed_user: ManagedUser | None = None, ) -> tuple[str, SessionUser]: raw_sid = secrets.token_urlsafe(32) normalized_email = _normalize_email(email) - managed = await upsert_managed_user( - ManagedUserUpsertInput( - email=normalized_email, - display_name=display_name, - role=role, - cohort_ids=cohort_ids, - user_id=user_id, - external_id=external_id, - account_status=account_status, - reactivate=False, + managed = managed_user + if managed is None: + managed = await upsert_managed_user( + ManagedUserUpsertInput( + email=normalized_email, + display_name=display_name, + role=role, + cohort_ids=cohort_ids, + user_id=user_id, + external_id=external_id, + account_status=account_status, + reactivate=False, + ) ) - ) if managed.account_status == "pending": try: await notifications.enqueue_account_pending_approval( @@ -2067,14 +2189,16 @@ async def create_session( await conn.execute( """ 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 user_id = EXCLUDED.user_id, role = EXCLUDED.role, display_name = EXCLUDED.display_name, cohort_ids = EXCLUDED.cohort_ids, + login_email = EXCLUDED.login_email, expires_at = EXCLUDED.expires_at, revoked_at = NULL, last_seen_at = now() @@ -2084,6 +2208,7 @@ async def create_session( managed.role, managed.display_name, list(managed.cohort_ids), + normalized_email, datetime.fromtimestamp(expires_at, tz=timezone.utc), ) except Exception: @@ -2104,7 +2229,8 @@ async def get_session(raw_sid: str | None) -> SessionUser | None: SELECT s.expires_at, 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, u.role, u.admin_access, @@ -2128,7 +2254,7 @@ async def get_session(raw_sid: str | None) -> SessionUser | None: _row_value(row, "admin_access", False) ) effective_admin_access = has_admin_access( - row["email"], + row["canonical_email"], app_role, stored_admin_access, ) @@ -2155,11 +2281,11 @@ async def get_session(raw_sid: str | None) -> SessionUser | None: ) return SessionUser( user_id=str(row["user_id"]), - email=row["email"], + email=row["login_email"], display_name=row["display_name"], role=app_role, 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"]), cohort_ids=_cohort_ids(row["cohort"]), consent_at=_optional_ts(row["consent_at"]), diff --git a/apps/api/app/db.py b/apps/api/app/db.py index 0526f1c..d4bd95c 100644 --- a/apps/api/app/db.py +++ b/apps/api/app/db.py @@ -147,6 +147,13 @@ async def healthcheck() -> bool: SELECT 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_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, EXISTS ( SELECT 1 FROM information_schema.columns @@ -210,6 +217,8 @@ async def healthcheck() -> bool: row and row["has_user"] 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_engine_config"] and row["has_admin_health_event"] diff --git a/apps/api/app/routes/auth.py b/apps/api/app/routes/auth.py index 395b2f3..eafd54d 100644 --- a/apps/api/app/routes/auth.py +++ b/apps/api/app/routes/auth.py @@ -29,6 +29,7 @@ from ..auth_types import AccountStatus, RoleName from ..auth_sessions import ( ManagedUser, get_managed_user, + get_managed_user_by_auth_alias, get_managed_user_by_email, has_admin_access, InactiveUserError, @@ -705,7 +706,10 @@ async def _me_response(user: SessionUser | Principal) -> MeResponse: ) email = getattr(user, "email", "") 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( user_id=user.user_id, email=email, @@ -715,8 +719,8 @@ async def _me_response(user: SessionUser | Principal) -> MeResponse: or email ), role=role, - admin_access=has_admin_access(email, role, stored_admin_access), - super_admin=is_super_admin_email(email), + admin_access=has_admin_access(entitlement_email, role, stored_admin_access), + super_admin=is_super_admin_email(entitlement_email), account_status=account_status, approval_required=account_status != "approved", cohort_ids=user.cohort_ids, @@ -970,7 +974,17 @@ async def callback( email_domain=_email_domain(str(claims.get("email") or "")), ) 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)) display_name = str(claims.get("name") or email) cohort_ids = _cohort_ids_for_managed_user( @@ -980,16 +994,26 @@ async def callback( hosted_domain=str(claims.get("hd") or ""), ), ) - external_id = _provider_external_id("google", str(claims.get("sub") or ""), email) try: - sid, _ = await create_session( - email=email, - display_name=display_name, - role=role.value, - cohort_ids=cohort_ids, - external_id=external_id, - account_status="approved", - ) + if aliased_user is not None: + sid, _ = await create_session( + email=email, + display_name=display_name, + role=role.value, + cohort_ids=cohort_ids, + 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: _log_oauth_callback_failure( request, diff --git a/apps/api/app/test_auth_identity_alias.py b/apps/api/app/test_auth_identity_alias.py new file mode 100644 index 0000000..851959f --- /dev/null +++ b/apps/api/app/test_auth_identity_alias.py @@ -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) diff --git a/apps/api/app/test_auth_providers.py b/apps/api/app/test_auth_providers.py index 6472cea..a324a67 100644 --- a/apps/api/app/test_auth_providers.py +++ b/apps/api/app/test_auth_providers.py @@ -139,6 +139,7 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase): 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: @@ -147,6 +148,7 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase): 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_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"], ), 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", @@ -1272,6 +1279,11 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase): auth_email_cohort_map={"learner@hs.ac.kr": "pilot-a"}, ), 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", @@ -1363,6 +1375,11 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase): auth_super_admin_emails=[], ), 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, "create_session", create_session_mock), ): @@ -1456,6 +1473,11 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase): session_secret="signed-oauth-state-secret", ), 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", diff --git a/apps/api/app/test_runtime_policy.py b/apps/api/app/test_runtime_policy.py index 55a63ac..9fde572 100644 --- a/apps/api/app/test_runtime_policy.py +++ b/apps/api/app/test_runtime_policy.py @@ -305,6 +305,9 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase): "has_persona_triggers": True, "has_persona_voice_map": 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_engine_config": True, "has_session_columns": True, diff --git a/infra/db/init/19_auth_identity_alias.sql b/infra/db/init/19_auth_identity_alias.sql new file mode 100644 index 0000000..63092d9 --- /dev/null +++ b/infra/db/init/19_auth_identity_alias.sql @@ -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; diff --git a/scripts/run-outcome-os-release-agent.py b/scripts/run-outcome-os-release-agent.py index a0b27b8..ee53716 100644 --- a/scripts/run-outcome-os-release-agent.py +++ b/scripts/run-outcome-os-release-agent.py @@ -121,6 +121,7 @@ RELEASE_DB_MIGRATIONS = ( "15_self_directed_practice_runtime.sql", "16_calibration_transfer_actual_execution.sql", "17_improvement_workbook_contracts.sql", + "19_auth_identity_alias.sql", ) # 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/16_calibration_transfer_actual_execution.sql", "infra/db/init/17_improvement_workbook_contracts.sql", + "infra/db/init/19_auth_identity_alias.sql", ) REQUIRED_OPENAPI_PATHS = { diff --git a/scripts/test_outcome_os_release_agent.py b/scripts/test_outcome_os_release_agent.py index e5cdc26..55df46b 100644 --- a/scripts/test_outcome_os_release_agent.py +++ b/scripts/test_outcome_os_release_agent.py @@ -740,6 +740,7 @@ class OutcomeReleaseAgentTests(unittest.TestCase): "15_self_directed_practice_runtime.sql", "16_calibration_transfer_actual_execution.sql", "17_improvement_workbook_contracts.sql", + "19_auth_identity_alias.sql", ), self.agent_module.RELEASE_DB_MIGRATIONS, )