동의 게이트와 런타임 안정화

This commit is contained in:
Yun Chan 2026-06-27 17:22:38 +09:00
parent 0eb7d925ed
commit 0ec266a761
34 changed files with 1186 additions and 158 deletions

View file

@ -28,6 +28,7 @@ class SessionUser:
display_name: str
role: str
cohort_ids: list[str]
consent_at: float | None
expires_at: float
@ -39,6 +40,7 @@ class ManagedUser:
role: str
cohort_ids: list[str]
affiliation: str
consent_at: float | None
created_at: float
last_seen_at: float
@ -95,6 +97,17 @@ def _ts(value: datetime | None) -> float:
return (value or datetime.now(timezone.utc)).timestamp()
def _optional_ts(value: datetime | None) -> float | None:
return value.timestamp() if value is not None else None
def _row_value(row, key: str, default=None):
try:
return row[key]
except (IndexError, KeyError, TypeError):
return default
def _cohort_ids(cohort: str | None) -> list[str]:
return [cohort] if cohort else []
@ -111,9 +124,9 @@ async def _runtime_tables_ready(conn) -> bool:
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'app_user'
AND column_name IN ('affiliation', 'last_seen_at', 'updated_at')
AND column_name IN ('affiliation', 'consent_at', 'last_seen_at', 'updated_at')
GROUP BY table_schema, table_name
HAVING count(*) = 3
HAVING count(*) = 4
) AS has_user_columns,
to_regclass('app.auth_session') IS NOT NULL AS has_auth_session,
to_regclass('app.user_preferences') IS NOT NULL AS has_preferences,
@ -214,6 +227,7 @@ async def ensure_runtime_tables() -> None:
"""
ALTER TABLE app.app_user
ADD COLUMN IF NOT EXISTS affiliation TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS consent_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
"""
@ -388,8 +402,9 @@ def _managed_user_from_row(row) -> ManagedUser:
email=row["email"],
display_name=row["display_name"] or row["email"],
role=_app_role(row["role"]),
cohort_ids=_cohort_ids(row.get("cohort") if hasattr(row, "get") else row["cohort"]),
cohort_ids=_cohort_ids(_row_value(row, "cohort")),
affiliation=row["affiliation"] or DEFAULT_AFFILIATION,
consent_at=_optional_ts(_row_value(row, "consent_at")),
created_at=_ts(row["created_at"]),
last_seen_at=_ts(row["last_seen_at"]),
)
@ -403,6 +418,7 @@ def _memory_upsert_managed_user(
cohort_ids: list[str] | None = None,
user_id: str | None = None,
affiliation: str | None = None,
consent_at: float | None = None,
reactivate: bool = False,
) -> ManagedUser:
now = time.time()
@ -424,6 +440,7 @@ def _memory_upsert_managed_user(
if affiliation
else (current.affiliation if current else DEFAULT_AFFILIATION)
),
consent_at=consent_at if consent_at is not None else (current.consent_at if current else None),
created_at=current.created_at if current else now,
last_seen_at=now,
)
@ -464,7 +481,7 @@ async def upsert_managed_user(
last_seen_at = now(),
updated_at = now()
WHERE app.app_user.is_active OR $7
RETURNING user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
RETURNING user_id, email, display_name, role, cohort, affiliation, consent_at, created_at, last_seen_at
""",
normalized_external_id,
normalized_email,
@ -485,6 +502,7 @@ async def upsert_managed_user(
cohort_ids=user.cohort_ids,
user_id=user.user_id,
affiliation=user.affiliation,
consent_at=user.consent_at,
reactivate=True,
)
return user
@ -509,7 +527,7 @@ async def get_managed_user(user_id: str) -> ManagedUser | None:
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
SELECT user_id, email, display_name, role, cohort, affiliation, consent_at, created_at, last_seen_at
FROM app.app_user
WHERE user_id = $1::uuid AND is_active
""",
@ -530,7 +548,7 @@ async def list_managed_users() -> tuple[list[ManagedUser], bool]:
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
SELECT user_id, email, display_name, role, cohort, affiliation, consent_at, created_at, last_seen_at
FROM app.app_user
WHERE is_active
ORDER BY last_seen_at DESC
@ -563,7 +581,7 @@ async def update_managed_user(
updated_at = now(),
last_seen_at = now()
WHERE user_id = $1::uuid AND is_active
RETURNING user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
RETURNING user_id, email, display_name, role, cohort, affiliation, consent_at, created_at, last_seen_at
""",
user_id,
display_name.strip() if display_name is not None else None,
@ -581,12 +599,14 @@ async def update_managed_user(
cohort_ids=next_user.cohort_ids,
user_id=next_user.user_id,
affiliation=next_user.affiliation,
consent_at=next_user.consent_at,
)
for session in _sessions.values():
if session.user_id == user_id:
session.display_name = next_user.display_name
session.role = next_user.role
session.cohort_ids = list(next_user.cohort_ids)
session.consent_at = next_user.consent_at
return next_user
except Exception:
require_runtime_fallback_allowed("managed user update")
@ -604,6 +624,7 @@ async def update_managed_user(
role=role if role is not None else current.role,
cohort_ids=list(cohort_ids) if cohort_ids is not None else current.cohort_ids,
affiliation=affiliation.strip() if affiliation is not None else current.affiliation,
consent_at=current.consent_at,
created_at=current.created_at,
last_seen_at=time.time(),
)
@ -614,9 +635,118 @@ async def update_managed_user(
session.display_name = next_user.display_name
session.role = next_user.role
session.cohort_ids = list(next_user.cohort_ids)
session.consent_at = next_user.consent_at
return next_user
async def record_user_consent(user_id: str) -> float | None:
"""Mark a learner consent receipt timestamp for the current user."""
try:
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
UPDATE app.app_user SET
consent_at = now(),
updated_at = now(),
last_seen_at = now()
WHERE user_id = $1::uuid AND is_active
RETURNING consent_at
""",
user_id,
)
if row is not None:
consent_at = _optional_ts(row["consent_at"])
memory_user = _users.get(user_id)
if memory_user is not None:
_users[user_id] = ManagedUser(
user_id=memory_user.user_id,
email=memory_user.email,
display_name=memory_user.display_name,
role=memory_user.role,
cohort_ids=list(memory_user.cohort_ids),
affiliation=memory_user.affiliation,
consent_at=consent_at,
created_at=memory_user.created_at,
last_seen_at=time.time(),
)
for session in _sessions.values():
if session.user_id == user_id:
session.consent_at = consent_at
return consent_at
except Exception:
require_runtime_fallback_allowed("consent update")
if not runtime_fallback_allowed():
return None
current = _users.get(user_id)
if current is None:
return None
consent_at = time.time()
_users[user_id] = ManagedUser(
user_id=current.user_id,
email=current.email,
display_name=current.display_name,
role=current.role,
cohort_ids=list(current.cohort_ids),
affiliation=current.affiliation,
consent_at=consent_at,
created_at=current.created_at,
last_seen_at=consent_at,
)
for session in _sessions.values():
if session.user_id == user_id:
session.consent_at = consent_at
return consent_at
async def withdraw_user_consent(user_id: str) -> bool:
"""Clear consent and revoke practice eligibility until the user consents again."""
changed = False
try:
pool = get_pool()
async with pool.acquire() as conn:
result = await conn.execute(
"""
UPDATE app.app_user SET
consent_at = NULL,
updated_at = now(),
last_seen_at = now()
WHERE user_id = $1::uuid AND is_active
""",
user_id,
)
changed = result.endswith(" 1")
except Exception:
require_runtime_fallback_allowed("consent withdrawal")
if not runtime_fallback_allowed():
return changed
current = _users.get(user_id)
if current is not None:
_users[user_id] = ManagedUser(
user_id=current.user_id,
email=current.email,
display_name=current.display_name,
role=current.role,
cohort_ids=list(current.cohort_ids),
affiliation=current.affiliation,
consent_at=None,
created_at=current.created_at,
last_seen_at=time.time(),
)
changed = True
for session in _sessions.values():
if session.user_id == user_id:
session.consent_at = None
return changed
async def user_has_consent(user_id: str) -> bool:
managed = await get_managed_user(user_id)
return bool(managed and managed.consent_at is not None)
async def deactivate_managed_user(user_id: str) -> bool:
"""Deactivate a managed user and revoke their active browser sessions."""
changed = False
@ -718,6 +848,7 @@ async def create_session(
display_name=managed.display_name,
role=managed.role,
cohort_ids=list(managed.cohort_ids),
consent_at=managed.consent_at,
expires_at=expires_at,
)
sid_hash = _sid_hash(raw_sid)
@ -767,7 +898,8 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
u.email,
COALESCE(u.display_name, s.display_name, u.email) AS display_name,
u.role,
u.cohort
u.cohort,
u.consent_at
FROM app.auth_session s
JOIN app.app_user u ON u.user_id = s.user_id
WHERE s.sid_hash = $1
@ -792,6 +924,7 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
display_name=row["display_name"],
role=_app_role(row["role"]),
cohort_ids=_cohort_ids(row["cohort"]),
consent_at=_optional_ts(row["consent_at"]),
expires_at=_ts(row["expires_at"]),
)
except Exception: