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] = {}
|
||||
_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"]),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue