Google 계정 데이터 별칭 복구를 추가

This commit is contained in:
Yun Chan 2026-08-29 15:34:50 +09:00
parent 15f609368c
commit dba9b75a38
9 changed files with 642 additions and 30 deletions

View file

@ -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"]),