1955 lines
76 KiB
Python
1955 lines
76 KiB
Python
"""Server-side browser sessions for the BFF auth boundary.
|
|
|
|
Sessions are DB-backed when PostgreSQL is available and fall back to the
|
|
process-local store for local degraded development. The browser only receives an
|
|
opaque HttpOnly cookie; roles and user identity stay on the server.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import secrets
|
|
import time
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from dataclasses import dataclass, replace
|
|
|
|
from .auth_types import AccountStatus, RoleName
|
|
from .config import settings
|
|
from .db import get_pool
|
|
from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class SessionUser:
|
|
user_id: str
|
|
email: str
|
|
display_name: str
|
|
role: RoleName
|
|
admin_access: bool
|
|
super_admin: bool
|
|
account_status: AccountStatus
|
|
cohort_ids: list[str]
|
|
consent_at: float | None
|
|
profile_completed_at: float | None
|
|
expires_at: float
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class ManagedUser:
|
|
user_id: str
|
|
email: str
|
|
display_name: str
|
|
role: RoleName
|
|
admin_access: bool
|
|
account_status: AccountStatus
|
|
cohort_ids: list[str]
|
|
affiliation: str
|
|
legal_name: str
|
|
department: str
|
|
grade_level: str
|
|
phone: str
|
|
contact_address: str
|
|
nickname: str
|
|
self_introduction: str
|
|
avatar_url: str
|
|
consent_at: float | None
|
|
profile_completed_at: float | None
|
|
terms_agreed_at: float | None
|
|
privacy_agreed_at: float | None
|
|
terms_version: str
|
|
privacy_version: str
|
|
created_at: float
|
|
last_seen_at: float
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class ManagedUserMemoryInput:
|
|
email: str
|
|
display_name: str
|
|
role: RoleName
|
|
admin_access: bool | None = None
|
|
account_status: AccountStatus | None = None
|
|
cohort_ids: list[str] | None = None
|
|
user_id: str | None = None
|
|
affiliation: str | None = None
|
|
legal_name: str | None = None
|
|
department: str | None = None
|
|
grade_level: str | None = None
|
|
phone: str | None = None
|
|
contact_address: str | None = None
|
|
nickname: str | None = None
|
|
self_introduction: str | None = None
|
|
avatar_url: str | None = None
|
|
consent_at: float | None = None
|
|
profile_completed_at: float | None = None
|
|
terms_agreed_at: float | None = None
|
|
privacy_agreed_at: float | None = None
|
|
terms_version: str | None = None
|
|
privacy_version: str | None = None
|
|
reactivate: bool = False
|
|
|
|
@classmethod
|
|
def from_user(
|
|
cls,
|
|
user: ManagedUser,
|
|
*,
|
|
reactivate: bool = False,
|
|
) -> "ManagedUserMemoryInput":
|
|
return cls(
|
|
email=user.email,
|
|
display_name=user.display_name,
|
|
role=user.role,
|
|
admin_access=user.admin_access,
|
|
account_status=user.account_status,
|
|
cohort_ids=list(user.cohort_ids),
|
|
user_id=user.user_id,
|
|
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=user.consent_at,
|
|
profile_completed_at=user.profile_completed_at,
|
|
terms_agreed_at=user.terms_agreed_at,
|
|
privacy_agreed_at=user.privacy_agreed_at,
|
|
terms_version=user.terms_version,
|
|
privacy_version=user.privacy_version,
|
|
reactivate=reactivate,
|
|
)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class ManagedUserUpsertInput:
|
|
email: str
|
|
display_name: str
|
|
role: RoleName
|
|
admin_access: bool | None = None
|
|
cohort_ids: list[str] | None = None
|
|
user_id: str | None = None
|
|
external_id: str | None = None
|
|
affiliation: str | None = None
|
|
account_status: AccountStatus | None = None
|
|
reactivate: bool = False
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class ManagedUserPatch:
|
|
display_name: str | None = None
|
|
role: RoleName | None = None
|
|
admin_access: bool | None = None
|
|
account_status: AccountStatus | None = None
|
|
affiliation: str | None = None
|
|
legal_name: str | None = None
|
|
department: str | None = None
|
|
grade_level: str | None = None
|
|
phone: str | None = None
|
|
contact_address: str | None = None
|
|
nickname: str | None = None
|
|
self_introduction: str | None = None
|
|
avatar_url: str | None = None
|
|
cohort_ids: list[str] | None = None
|
|
complete_onboarding: bool = False
|
|
terms_version: str | None = None
|
|
privacy_version: str | None = None
|
|
|
|
|
|
class InactiveUserError(Exception):
|
|
"""Raised when an inactive managed user attempts to create a login session."""
|
|
|
|
|
|
_sessions: dict[str, SessionUser] = {}
|
|
_users: dict[str, ManagedUser] = {}
|
|
_email_index: dict[str, str] = {}
|
|
_inactive_emails: set[str] = set()
|
|
|
|
DEFAULT_AFFILIATION = settings.default_affiliation.strip()
|
|
DB_ROLE_BY_APP = {"learner": "learner", "teacher": "instructor", "admin": "admin"}
|
|
APP_ROLE_BY_DB = {"learner": "learner", "instructor": "teacher", "admin": "admin"}
|
|
VALID_ACCOUNT_STATUSES: set[str] = {"pending", "approved", "suspended"}
|
|
|
|
|
|
def _sid_hash(raw_sid: str) -> str:
|
|
return hmac.new(
|
|
settings.session_secret.encode("utf-8"),
|
|
raw_sid.encode("utf-8"),
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
|
|
|
|
def user_id_from_email(email: str) -> str:
|
|
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"vignette:user:{email.strip().lower()}"))
|
|
|
|
|
|
def user_id_from_external_id(external_id: str) -> str:
|
|
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"vignette:user-external:{external_id.strip().lower()}"))
|
|
|
|
|
|
def _normalize_email(email: str) -> str:
|
|
return email.strip().lower()
|
|
|
|
|
|
def _normalize_external_id(external_id: str | None, email: str) -> str:
|
|
value = (external_id or "").strip().lower()
|
|
return value or f"email:{_normalize_email(email)}"
|
|
|
|
|
|
def _db_role(role: str) -> str:
|
|
return DB_ROLE_BY_APP.get(role, role)
|
|
|
|
|
|
def _app_role(role: str) -> str:
|
|
return APP_ROLE_BY_DB.get(role, role)
|
|
|
|
|
|
def _account_status(value: str | None) -> AccountStatus:
|
|
normalized = (value or "").strip().lower()
|
|
if normalized in VALID_ACCOUNT_STATUSES:
|
|
return normalized # type: ignore[return-value]
|
|
return "approved"
|
|
|
|
|
|
def _normalize_email_set(values: list[str]) -> set[str]:
|
|
return {email for value in values if (email := _normalize_email(value))}
|
|
|
|
|
|
def _auto_approved_email_set() -> set[str]:
|
|
return (
|
|
_normalize_email_set(settings.auth_super_admin_emails)
|
|
| _normalize_email_set(settings.auth_admin_emails)
|
|
| _normalize_email_set(settings.auth_teacher_emails)
|
|
| _normalize_email_set(settings.auth_approved_emails)
|
|
)
|
|
|
|
|
|
def is_super_admin_email(email: str) -> bool:
|
|
"""설정 기반 신뢰 루트인 슈퍼 관리자 계정인지 판정한다."""
|
|
normalized_email = _normalize_email(email)
|
|
return normalized_email in _normalize_email_set(settings.auth_super_admin_emails)
|
|
|
|
|
|
def has_admin_access(
|
|
email: str,
|
|
role: str | None = None,
|
|
stored_admin_access: bool = False,
|
|
) -> bool:
|
|
"""기본 역할을 유지한 채 관리자 화면에 들어갈 수 있는 계정인지 판정한다."""
|
|
normalized_email = _normalize_email(email)
|
|
return (
|
|
stored_admin_access
|
|
or role == "admin"
|
|
or is_super_admin_email(normalized_email)
|
|
or normalized_email in _normalize_email_set(settings.auth_admin_emails)
|
|
)
|
|
|
|
|
|
def _is_dev_login_external_id(external_id: str | None) -> bool:
|
|
return (external_id or "").strip().lower().startswith("dev:")
|
|
|
|
|
|
def _initial_account_status(
|
|
*,
|
|
email: str,
|
|
external_id: str | None,
|
|
reactivate: bool,
|
|
) -> AccountStatus:
|
|
normalized_email = _normalize_email(email)
|
|
if reactivate or normalized_email in _auto_approved_email_set():
|
|
return "approved"
|
|
if settings.environment == "dev" and _is_dev_login_external_id(external_id):
|
|
return "approved"
|
|
return _account_status(settings.auth_new_user_default_status)
|
|
|
|
|
|
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 []
|
|
|
|
|
|
def _cohort_value(cohort_ids: list[str] | None) -> str | None:
|
|
return (cohort_ids or [None])[0]
|
|
|
|
|
|
async def _runtime_tables_ready(conn) -> bool:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
SELECT
|
|
EXISTS (
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema = 'app'
|
|
AND table_name = 'app_user'
|
|
AND column_name IN (
|
|
'affiliation',
|
|
'legal_name',
|
|
'department',
|
|
'grade_level',
|
|
'phone',
|
|
'contact_address',
|
|
'nickname',
|
|
'self_introduction',
|
|
'avatar_url',
|
|
'consent_at',
|
|
'profile_completed_at',
|
|
'terms_agreed_at',
|
|
'privacy_agreed_at',
|
|
'terms_version',
|
|
'privacy_version',
|
|
'last_seen_at',
|
|
'account_status',
|
|
'admin_access',
|
|
'updated_at'
|
|
)
|
|
GROUP BY table_schema, table_name
|
|
HAVING count(*) = 19
|
|
) AS has_user_columns,
|
|
EXISTS (
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema = 'app'
|
|
AND table_name = 'persona_card'
|
|
AND column_name = 'triggers'
|
|
) 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.user_preferences') IS NOT NULL AS has_preferences,
|
|
to_regclass('app.admin_engine_config') IS NOT NULL AS has_engine_config,
|
|
EXISTS (
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema = 'app'
|
|
AND table_name = 'sessions'
|
|
AND column_name IN (
|
|
'runtime_case_id',
|
|
'persona_code',
|
|
'persona_display_name',
|
|
'persona_difficulty',
|
|
'prev_rapport_credit'
|
|
)
|
|
GROUP BY table_schema, table_name
|
|
HAVING count(*) = 5
|
|
) AS has_session_columns,
|
|
EXISTS (
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema = 'app'
|
|
AND table_name = 'session_state'
|
|
AND column_name = 'turns_in_stage'
|
|
) AS has_state_columns,
|
|
(
|
|
SELECT count(*) = 4
|
|
FROM app.stage_def
|
|
WHERE stage_code IN ('라포','탐색','개입','정리')
|
|
) AS has_stage_defs,
|
|
to_regclass('app.admin_health_event') IS NOT NULL AS has_admin_health_event,
|
|
to_regclass('app.admin_health_daily_rollup') IS NOT NULL AS has_admin_health_daily_rollup,
|
|
EXISTS (
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema = 'app'
|
|
AND table_name = 'admin_health_daily_rollup'
|
|
AND column_name = 'last_down_at'
|
|
) AS has_admin_health_daily_rollup_columns,
|
|
to_regclass('app.support_ticket') IS NOT NULL AS has_support_ticket,
|
|
EXISTS (
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema = 'app'
|
|
AND table_name = 'support_ticket'
|
|
AND column_name IN ('fingerprint', 'parent_ticket_id')
|
|
GROUP BY table_schema, table_name
|
|
HAVING count(*) = 2
|
|
) AS has_support_ticket_duplicate_columns,
|
|
EXISTS (
|
|
SELECT 1 FROM pg_policies
|
|
WHERE schemaname = 'app'
|
|
AND tablename = 'admin_health_event'
|
|
AND policyname IN (
|
|
'p_admin_health_event_select',
|
|
'p_admin_health_event_insert',
|
|
'p_admin_health_event_delete'
|
|
)
|
|
GROUP BY schemaname, tablename
|
|
HAVING count(*) = 3
|
|
) AS has_admin_health_event_policies,
|
|
EXISTS (
|
|
SELECT 1 FROM pg_policies
|
|
WHERE schemaname = 'app'
|
|
AND tablename = 'admin_health_daily_rollup'
|
|
AND policyname IN (
|
|
'p_admin_health_daily_rollup_select',
|
|
'p_admin_health_daily_rollup_insert',
|
|
'p_admin_health_daily_rollup_update'
|
|
)
|
|
GROUP BY schemaname, tablename
|
|
HAVING count(*) = 3
|
|
) AS has_admin_health_daily_rollup_policies,
|
|
EXISTS (
|
|
SELECT 1 FROM pg_policies
|
|
WHERE schemaname = 'app'
|
|
AND tablename = 'support_ticket'
|
|
AND policyname IN (
|
|
'p_support_ticket_select',
|
|
'p_support_ticket_insert',
|
|
'p_support_ticket_update',
|
|
'p_support_ticket_delete'
|
|
)
|
|
GROUP BY schemaname, tablename
|
|
HAVING count(*) = 4
|
|
) AS has_support_ticket_policies,
|
|
to_regclass('app.learner_prepost_measure') IS NOT NULL AS has_learner_prepost_measure,
|
|
EXISTS (
|
|
SELECT 1 FROM pg_policies
|
|
WHERE schemaname = 'app'
|
|
AND tablename = 'learner_prepost_measure'
|
|
AND policyname IN (
|
|
'p_learner_prepost_measure_select',
|
|
'p_learner_prepost_measure_insert',
|
|
'p_learner_prepost_measure_update'
|
|
)
|
|
GROUP BY schemaname, tablename
|
|
HAVING count(*) = 3
|
|
) AS has_learner_prepost_measure_policies,
|
|
EXISTS (
|
|
SELECT 1 FROM pg_policies
|
|
WHERE schemaname = 'app'
|
|
AND tablename = 'sessions'
|
|
AND policyname IN ('p_sessions_insert','p_sessions_update','p_sessions_delete')
|
|
GROUP BY schemaname, tablename
|
|
HAVING count(*) = 3
|
|
) AS has_session_write_policies,
|
|
NOT EXISTS (
|
|
SELECT 1 FROM pg_policies
|
|
WHERE schemaname = 'app'
|
|
AND tablename = 'sessions'
|
|
AND policyname = 'p_sessions_modify'
|
|
) AS removed_old_session_policy,
|
|
EXISTS (
|
|
SELECT 1 FROM pg_policies
|
|
WHERE schemaname = 'app'
|
|
AND tablename = 'turns'
|
|
AND policyname IN ('p_turns_insert','p_turns_update','p_turns_delete')
|
|
GROUP BY schemaname, tablename
|
|
HAVING count(*) = 3
|
|
) AS has_turn_write_policies,
|
|
NOT EXISTS (
|
|
SELECT 1 FROM pg_policies
|
|
WHERE schemaname = 'app'
|
|
AND tablename = 'turns'
|
|
AND policyname = 'p_turns_modify'
|
|
) AS removed_old_turn_policy
|
|
"""
|
|
)
|
|
return bool(
|
|
row
|
|
and row["has_user_columns"]
|
|
and row["has_persona_triggers"]
|
|
and row["has_persona_voice_map"]
|
|
and row["has_auth_session"]
|
|
and row["has_preferences"]
|
|
and row["has_engine_config"]
|
|
and row["has_session_columns"]
|
|
and row["has_state_columns"]
|
|
and row["has_stage_defs"]
|
|
and row["has_admin_health_event"]
|
|
and row["has_admin_health_daily_rollup"]
|
|
and row["has_admin_health_daily_rollup_columns"]
|
|
and row["has_support_ticket"]
|
|
and row["has_support_ticket_duplicate_columns"]
|
|
and row["has_admin_health_event_policies"]
|
|
and row["has_admin_health_daily_rollup_policies"]
|
|
and row["has_support_ticket_policies"]
|
|
and row["has_learner_prepost_measure"]
|
|
and row["has_learner_prepost_measure_policies"]
|
|
and row["has_session_write_policies"]
|
|
and row["removed_old_session_policy"]
|
|
and row["has_turn_write_policies"]
|
|
and row["removed_old_turn_policy"]
|
|
)
|
|
|
|
|
|
async def ensure_runtime_tables() -> None:
|
|
"""Ensure DB-backed auth/user runtime tables exist when a pool is available."""
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
if await _runtime_tables_ready(conn):
|
|
return
|
|
if settings.environment != "dev":
|
|
raise RuntimeError(
|
|
"runtime DB schema is incomplete; run owner migration/init and "
|
|
"scripts/check-deploy-preflight.py before starting the API"
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
DO $$
|
|
BEGIN
|
|
IF to_regclass('app.persona_card') IS NOT NULL THEN
|
|
ALTER TABLE app.persona_card
|
|
ADD COLUMN IF NOT EXISTS triggers JSONB NOT NULL DEFAULT '{}'::jsonb;
|
|
|
|
CREATE TABLE IF NOT EXISTS app.persona_voice_map (
|
|
persona_id UUID NOT NULL,
|
|
version INT NOT NULL,
|
|
voice_id TEXT NOT NULL,
|
|
provider TEXT NOT NULL CHECK (provider IN ('openai','higgs','melotts')),
|
|
base_params JSONB NOT NULL,
|
|
prosody_map JSONB NOT NULL,
|
|
PRIMARY KEY (persona_id, version),
|
|
FOREIGN KEY (persona_id, version)
|
|
REFERENCES app.persona_card(persona_id, version) ON DELETE CASCADE
|
|
);
|
|
END IF;
|
|
|
|
IF EXISTS (
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_schema = 'app'
|
|
AND table_name = 'app_user'
|
|
AND column_name = 'affiliation'
|
|
) THEN
|
|
ALTER TABLE app.app_user ALTER COLUMN affiliation SET DEFAULT '';
|
|
END IF;
|
|
END $$;
|
|
"""
|
|
)
|
|
if await _runtime_tables_ready(conn):
|
|
return
|
|
await conn.execute(
|
|
"""
|
|
ALTER TABLE app.app_user
|
|
ADD COLUMN IF NOT EXISTS affiliation TEXT NOT NULL DEFAULT '',
|
|
ADD COLUMN IF NOT EXISTS legal_name TEXT NOT NULL DEFAULT '',
|
|
ADD COLUMN IF NOT EXISTS department TEXT NOT NULL DEFAULT '',
|
|
ADD COLUMN IF NOT EXISTS grade_level TEXT NOT NULL DEFAULT '',
|
|
ADD COLUMN IF NOT EXISTS phone TEXT NOT NULL DEFAULT '',
|
|
ADD COLUMN IF NOT EXISTS contact_address TEXT NOT NULL DEFAULT '',
|
|
ADD COLUMN IF NOT EXISTS nickname TEXT NOT NULL DEFAULT '',
|
|
ADD COLUMN IF NOT EXISTS self_introduction TEXT NOT NULL DEFAULT '',
|
|
ADD COLUMN IF NOT EXISTS avatar_url TEXT NOT NULL DEFAULT '',
|
|
ADD COLUMN IF NOT EXISTS consent_at TIMESTAMPTZ,
|
|
ADD COLUMN IF NOT EXISTS profile_completed_at TIMESTAMPTZ,
|
|
ADD COLUMN IF NOT EXISTS terms_agreed_at TIMESTAMPTZ,
|
|
ADD COLUMN IF NOT EXISTS privacy_agreed_at TIMESTAMPTZ,
|
|
ADD COLUMN IF NOT EXISTS terms_version TEXT NOT NULL DEFAULT '',
|
|
ADD COLUMN IF NOT EXISTS privacy_version TEXT NOT NULL DEFAULT '',
|
|
ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
ADD COLUMN IF NOT EXISTS account_status TEXT NOT NULL DEFAULT 'approved',
|
|
ADD COLUMN IF NOT EXISTS admin_access BOOLEAN NOT NULL DEFAULT false,
|
|
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM pg_constraint
|
|
WHERE conname = 'app_user_account_status_check'
|
|
AND conrelid = 'app.app_user'::regclass
|
|
) THEN
|
|
ALTER TABLE app.app_user
|
|
ADD CONSTRAINT app_user_account_status_check
|
|
CHECK (account_status IN ('pending','approved','suspended'));
|
|
END IF;
|
|
END $$;
|
|
CREATE INDEX IF NOT EXISTS idx_app_user_account_status
|
|
ON app.app_user(account_status, last_seen_at DESC);
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS app.auth_session (
|
|
sid_hash TEXT PRIMARY KEY,
|
|
user_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE CASCADE,
|
|
role TEXT NOT NULL,
|
|
display_name TEXT NOT NULL,
|
|
cohort_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
revoked_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS idx_auth_session_user_active
|
|
ON app.auth_session(user_id, expires_at)
|
|
WHERE revoked_at IS NULL
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS app.user_preferences (
|
|
user_id UUID PRIMARY KEY REFERENCES app.app_user(user_id) ON DELETE CASCADE,
|
|
theme TEXT NOT NULL DEFAULT 'system',
|
|
voice_preset_id TEXT NOT NULL DEFAULT 'soft-young-fem',
|
|
voice_rate REAL NOT NULL DEFAULT 1.0,
|
|
notifications JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS app.admin_engine_config (
|
|
id BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (id),
|
|
engine_mode TEXT NOT NULL,
|
|
engine_url TEXT NOT NULL,
|
|
model TEXT NOT NULL,
|
|
updated_by TEXT,
|
|
updated_at TIMESTAMPTZ
|
|
)
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS app.admin_health_event (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
observed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
overall_status TEXT NOT NULL CHECK (overall_status IN ('ok','degraded','down')),
|
|
environment TEXT NOT NULL,
|
|
engine_mode TEXT NOT NULL,
|
|
service_key TEXT NOT NULL,
|
|
service_name TEXT NOT NULL,
|
|
service_status TEXT NOT NULL CHECK (service_status IN ('ok','degraded','down')),
|
|
detail TEXT NOT NULL DEFAULT '',
|
|
metric TEXT NOT NULL DEFAULT '',
|
|
load REAL NOT NULL DEFAULT 0.0 CHECK (load >= 0.0 AND load <= 1.0),
|
|
captured_by UUID REFERENCES app.app_user(user_id)
|
|
)
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS idx_admin_health_event_observed
|
|
ON app.admin_health_event(observed_at DESC, service_key)
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS app.admin_health_daily_rollup (
|
|
rollup_date DATE NOT NULL,
|
|
environment TEXT NOT NULL,
|
|
engine_mode TEXT NOT NULL,
|
|
service_key TEXT NOT NULL,
|
|
service_name TEXT NOT NULL,
|
|
sample_count INTEGER NOT NULL DEFAULT 0 CHECK (sample_count >= 0),
|
|
ok_samples INTEGER NOT NULL DEFAULT 0 CHECK (ok_samples >= 0),
|
|
degraded_samples INTEGER NOT NULL DEFAULT 0 CHECK (degraded_samples >= 0),
|
|
down_samples INTEGER NOT NULL DEFAULT 0 CHECK (down_samples >= 0),
|
|
first_observed_at TIMESTAMPTZ NOT NULL,
|
|
last_observed_at TIMESTAMPTZ NOT NULL,
|
|
latest_status TEXT NOT NULL CHECK (latest_status IN ('ok','degraded','down')),
|
|
last_down_at TIMESTAMPTZ,
|
|
max_load REAL NOT NULL DEFAULT 0.0 CHECK (max_load >= 0.0 AND max_load <= 1.0),
|
|
generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (rollup_date, environment, engine_mode, service_key)
|
|
)
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS idx_admin_health_daily_rollup_latest
|
|
ON app.admin_health_daily_rollup(last_observed_at DESC, service_key)
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS app.support_ticket (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
reporter_id UUID REFERENCES app.app_user(user_id) ON DELETE SET NULL,
|
|
reporter_email TEXT NOT NULL,
|
|
reporter_name TEXT NOT NULL,
|
|
reporter_role TEXT NOT NULL,
|
|
category TEXT NOT NULL DEFAULT 'other' CHECK (
|
|
category IN (
|
|
'account_access',
|
|
'session_review',
|
|
'voice_browser',
|
|
'content_scenario',
|
|
'safety',
|
|
'other'
|
|
)
|
|
),
|
|
priority TEXT NOT NULL DEFAULT 'normal' CHECK (priority IN ('low','normal','high','urgent')),
|
|
status TEXT NOT NULL DEFAULT 'open' CHECK (
|
|
status IN ('open','triaged','in_progress','resolved','closed')
|
|
),
|
|
subject TEXT NOT NULL,
|
|
body TEXT NOT NULL,
|
|
source_path TEXT NOT NULL DEFAULT '',
|
|
fingerprint TEXT NOT NULL DEFAULT '',
|
|
parent_ticket_id UUID REFERENCES app.support_ticket(id) ON DELETE SET NULL,
|
|
assigned_group TEXT NOT NULL DEFAULT '',
|
|
resolution_note TEXT NOT NULL DEFAULT '',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
resolved_at TIMESTAMPTZ,
|
|
last_activity_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS idx_support_ticket_status_priority
|
|
ON app.support_ticket(status, priority, updated_at DESC)
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS idx_support_ticket_reporter
|
|
ON app.support_ticket(reporter_id, created_at DESC)
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
ALTER TABLE app.support_ticket
|
|
ADD COLUMN IF NOT EXISTS fingerprint TEXT NOT NULL DEFAULT '',
|
|
ADD COLUMN IF NOT EXISTS parent_ticket_id UUID REFERENCES app.support_ticket(id) ON DELETE SET NULL
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS idx_support_ticket_fingerprint
|
|
ON app.support_ticket(fingerprint, created_at DESC)
|
|
WHERE fingerprint <> ''
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS idx_support_ticket_parent
|
|
ON app.support_ticket(parent_ticket_id)
|
|
WHERE parent_ticket_id IS NOT NULL
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS app.learner_prepost_measure (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
learner_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE CASCADE,
|
|
pilot_id TEXT NOT NULL DEFAULT 'phase3-pilot-draft',
|
|
measure_name TEXT NOT NULL CHECK (
|
|
measure_name IN ('self_efficacy','skill_proficiency','training_satisfaction')
|
|
),
|
|
timepoint TEXT NOT NULL CHECK (timepoint IN ('pre','post')),
|
|
raw_score NUMERIC(8,3) NOT NULL,
|
|
min_score NUMERIC(8,3) NOT NULL DEFAULT 1.0,
|
|
max_score NUMERIC(8,3) NOT NULL DEFAULT 5.0,
|
|
instrument_version TEXT NOT NULL DEFAULT 'pilot-prepost-scaffold-2026-06-28',
|
|
item_count INTEGER NOT NULL DEFAULT 1 CHECK (item_count >= 1 AND item_count <= 80),
|
|
collected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CHECK (max_score > min_score),
|
|
CHECK (raw_score >= min_score AND raw_score <= max_score),
|
|
UNIQUE (learner_id, pilot_id, measure_name, timepoint, instrument_version)
|
|
)
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS idx_learner_prepost_measure_pilot
|
|
ON app.learner_prepost_measure(pilot_id, measure_name, timepoint)
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS idx_learner_prepost_measure_learner
|
|
ON app.learner_prepost_measure(learner_id, pilot_id)
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
ALTER TABLE app.admin_health_event ENABLE ROW LEVEL SECURITY;
|
|
DROP POLICY IF EXISTS p_admin_health_event_select ON app.admin_health_event;
|
|
DROP POLICY IF EXISTS p_admin_health_event_insert ON app.admin_health_event;
|
|
DROP POLICY IF EXISTS p_admin_health_event_delete ON app.admin_health_event;
|
|
CREATE POLICY p_admin_health_event_select
|
|
ON app.admin_health_event FOR SELECT
|
|
USING (app.current_role_name() = 'admin');
|
|
CREATE POLICY p_admin_health_event_insert
|
|
ON app.admin_health_event FOR INSERT
|
|
WITH CHECK (app.current_role_name() = 'admin');
|
|
CREATE POLICY p_admin_health_event_delete
|
|
ON app.admin_health_event FOR DELETE
|
|
USING (app.current_role_name() = 'admin');
|
|
|
|
ALTER TABLE app.admin_health_daily_rollup ENABLE ROW LEVEL SECURITY;
|
|
DROP POLICY IF EXISTS p_admin_health_daily_rollup_select ON app.admin_health_daily_rollup;
|
|
DROP POLICY IF EXISTS p_admin_health_daily_rollup_insert ON app.admin_health_daily_rollup;
|
|
DROP POLICY IF EXISTS p_admin_health_daily_rollup_update ON app.admin_health_daily_rollup;
|
|
DROP POLICY IF EXISTS p_admin_health_daily_rollup_delete ON app.admin_health_daily_rollup;
|
|
ALTER TABLE app.admin_health_daily_rollup
|
|
ADD COLUMN IF NOT EXISTS last_down_at TIMESTAMPTZ;
|
|
CREATE POLICY p_admin_health_daily_rollup_select
|
|
ON app.admin_health_daily_rollup FOR SELECT
|
|
USING (app.current_role_name() = 'admin');
|
|
CREATE POLICY p_admin_health_daily_rollup_insert
|
|
ON app.admin_health_daily_rollup FOR INSERT
|
|
WITH CHECK (app.current_role_name() = 'admin');
|
|
CREATE POLICY p_admin_health_daily_rollup_update
|
|
ON app.admin_health_daily_rollup FOR UPDATE
|
|
USING (app.current_role_name() = 'admin')
|
|
WITH CHECK (app.current_role_name() = 'admin');
|
|
|
|
ALTER TABLE app.support_ticket ENABLE ROW LEVEL SECURITY;
|
|
DROP POLICY IF EXISTS p_support_ticket_select ON app.support_ticket;
|
|
DROP POLICY IF EXISTS p_support_ticket_insert ON app.support_ticket;
|
|
DROP POLICY IF EXISTS p_support_ticket_update ON app.support_ticket;
|
|
DROP POLICY IF EXISTS p_support_ticket_delete ON app.support_ticket;
|
|
CREATE POLICY p_support_ticket_select
|
|
ON app.support_ticket FOR SELECT
|
|
USING (
|
|
app.current_role_name() = 'admin'
|
|
OR reporter_id = app.current_uid()
|
|
);
|
|
CREATE POLICY p_support_ticket_insert
|
|
ON app.support_ticket FOR INSERT
|
|
WITH CHECK (
|
|
app.current_role_name() = 'admin'
|
|
OR reporter_id = app.current_uid()
|
|
);
|
|
CREATE POLICY p_support_ticket_update
|
|
ON app.support_ticket FOR UPDATE
|
|
USING (app.current_role_name() = 'admin')
|
|
WITH CHECK (app.current_role_name() = 'admin');
|
|
CREATE POLICY p_support_ticket_delete
|
|
ON app.support_ticket FOR DELETE
|
|
USING (app.current_role_name() = 'admin');
|
|
|
|
ALTER TABLE app.learner_prepost_measure ENABLE ROW LEVEL SECURITY;
|
|
DROP POLICY IF EXISTS p_learner_prepost_measure_select ON app.learner_prepost_measure;
|
|
DROP POLICY IF EXISTS p_learner_prepost_measure_insert ON app.learner_prepost_measure;
|
|
DROP POLICY IF EXISTS p_learner_prepost_measure_update ON app.learner_prepost_measure;
|
|
CREATE POLICY p_learner_prepost_measure_select
|
|
ON app.learner_prepost_measure FOR SELECT
|
|
USING (
|
|
app.current_role_name() = 'admin'
|
|
OR learner_id = app.current_uid()
|
|
OR (
|
|
app.current_role_name() = 'instructor'
|
|
AND EXISTS (
|
|
SELECT 1 FROM app.app_user u
|
|
WHERE u.user_id = app.learner_prepost_measure.learner_id
|
|
AND u.cohort = current_setting('app.current_cohort', true)
|
|
)
|
|
)
|
|
);
|
|
CREATE POLICY p_learner_prepost_measure_insert
|
|
ON app.learner_prepost_measure FOR INSERT
|
|
WITH CHECK (
|
|
app.current_role_name() = 'admin'
|
|
OR learner_id = app.current_uid()
|
|
);
|
|
CREATE POLICY p_learner_prepost_measure_update
|
|
ON app.learner_prepost_measure FOR UPDATE
|
|
USING (
|
|
app.current_role_name() = 'admin'
|
|
OR learner_id = app.current_uid()
|
|
)
|
|
WITH CHECK (
|
|
app.current_role_name() = 'admin'
|
|
OR learner_id = app.current_uid()
|
|
);
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
ALTER TABLE app.sessions
|
|
ADD COLUMN IF NOT EXISTS runtime_case_id UUID,
|
|
ADD COLUMN IF NOT EXISTS persona_code TEXT,
|
|
ADD COLUMN IF NOT EXISTS persona_display_name TEXT,
|
|
ADD COLUMN IF NOT EXISTS persona_difficulty TEXT,
|
|
ADD COLUMN IF NOT EXISTS prev_rapport_credit REAL NOT NULL DEFAULT 0.0
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
ALTER TABLE app.session_state
|
|
ADD COLUMN IF NOT EXISTS turns_in_stage INT NOT NULL DEFAULT 0
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
ALTER TABLE app.turns
|
|
ADD COLUMN IF NOT EXISTS provider_events JSONB NOT NULL DEFAULT '[]'::jsonb
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.stage_def (stage_code, display_name, seq, base_openness)
|
|
VALUES
|
|
('라포', '라포', 1, 0.15),
|
|
('탐색', '탐색', 2, 0.35),
|
|
('개입', '개입', 3, 0.55),
|
|
('정리', '정리', 4, 0.45)
|
|
ON CONFLICT (stage_code) DO UPDATE SET
|
|
display_name = EXCLUDED.display_name,
|
|
seq = EXCLUDED.seq,
|
|
base_openness = EXCLUDED.base_openness
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
DROP POLICY IF EXISTS p_sessions_modify ON app.sessions;
|
|
DROP POLICY IF EXISTS p_sessions_insert ON app.sessions;
|
|
DROP POLICY IF EXISTS p_sessions_update ON app.sessions;
|
|
DROP POLICY IF EXISTS p_sessions_delete ON app.sessions;
|
|
|
|
CREATE POLICY p_sessions_insert ON app.sessions FOR INSERT WITH CHECK (
|
|
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
|
|
OR learner_id = app.current_uid()
|
|
);
|
|
CREATE POLICY p_sessions_update ON app.sessions FOR UPDATE USING (
|
|
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
|
|
OR learner_id = app.current_uid()
|
|
) WITH CHECK (
|
|
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
|
|
OR learner_id = app.current_uid()
|
|
);
|
|
CREATE POLICY p_sessions_delete ON app.sessions FOR DELETE USING (
|
|
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
|
|
OR learner_id = app.current_uid()
|
|
)
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
DROP POLICY IF EXISTS p_case_insert ON app.case_profile;
|
|
DROP POLICY IF EXISTS p_case_update ON app.case_profile;
|
|
|
|
CREATE POLICY p_case_insert ON app.case_profile FOR INSERT WITH CHECK (
|
|
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
|
|
OR learner_id = app.current_uid()
|
|
);
|
|
CREATE POLICY p_case_update ON app.case_profile FOR UPDATE USING (
|
|
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
|
|
OR learner_id = app.current_uid()
|
|
) WITH CHECK (
|
|
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
|
|
OR learner_id = app.current_uid()
|
|
)
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
ALTER TABLE app.pinned_fact ENABLE ROW LEVEL SECURITY;
|
|
DROP POLICY IF EXISTS p_pinned_insert ON app.pinned_fact;
|
|
DROP POLICY IF EXISTS p_pinned_update ON app.pinned_fact;
|
|
|
|
CREATE POLICY p_pinned_insert ON app.pinned_fact FOR INSERT WITH CHECK (
|
|
app.current_role_name() IN ('admin','instructor')
|
|
OR EXISTS (
|
|
SELECT 1 FROM app.case_profile cp
|
|
WHERE cp.case_id = app.pinned_fact.case_id
|
|
AND cp.learner_id = app.current_uid()
|
|
)
|
|
);
|
|
CREATE POLICY p_pinned_update ON app.pinned_fact FOR UPDATE USING (
|
|
app.current_role_name() IN ('admin','instructor')
|
|
OR EXISTS (
|
|
SELECT 1 FROM app.case_profile cp
|
|
WHERE cp.case_id = app.pinned_fact.case_id
|
|
AND cp.learner_id = app.current_uid()
|
|
)
|
|
) WITH CHECK (
|
|
app.current_role_name() IN ('admin','instructor')
|
|
OR EXISTS (
|
|
SELECT 1 FROM app.case_profile cp
|
|
WHERE cp.case_id = app.pinned_fact.case_id
|
|
AND cp.learner_id = app.current_uid()
|
|
)
|
|
)
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
DROP POLICY IF EXISTS p_turns_modify ON app.turns;
|
|
DROP POLICY IF EXISTS p_turns_insert ON app.turns;
|
|
DROP POLICY IF EXISTS p_turns_update ON app.turns;
|
|
DROP POLICY IF EXISTS p_turns_delete ON app.turns;
|
|
|
|
CREATE POLICY p_turns_insert ON app.turns FOR INSERT WITH CHECK (
|
|
app.is_ai_context()
|
|
OR app.current_role_name() IN ('admin','instructor')
|
|
OR EXISTS (
|
|
SELECT 1 FROM app.sessions s
|
|
WHERE s.id = app.turns.session_id
|
|
AND s.learner_id = app.current_uid()
|
|
)
|
|
);
|
|
CREATE POLICY p_turns_update ON app.turns FOR UPDATE USING (
|
|
app.is_ai_context()
|
|
OR app.current_role_name() IN ('admin','instructor')
|
|
OR EXISTS (
|
|
SELECT 1 FROM app.sessions s
|
|
WHERE s.id = app.turns.session_id
|
|
AND s.learner_id = app.current_uid()
|
|
)
|
|
) WITH CHECK (
|
|
app.is_ai_context()
|
|
OR app.current_role_name() IN ('admin','instructor')
|
|
OR EXISTS (
|
|
SELECT 1 FROM app.sessions s
|
|
WHERE s.id = app.turns.session_id
|
|
AND s.learner_id = app.current_uid()
|
|
)
|
|
);
|
|
CREATE POLICY p_turns_delete ON app.turns FOR DELETE USING (
|
|
app.is_ai_context()
|
|
OR app.current_role_name() IN ('admin','instructor')
|
|
OR EXISTS (
|
|
SELECT 1 FROM app.sessions s
|
|
WHERE s.id = app.turns.session_id
|
|
AND s.learner_id = app.current_uid()
|
|
)
|
|
)
|
|
"""
|
|
)
|
|
|
|
|
|
def _managed_user_from_row(row) -> ManagedUser:
|
|
return ManagedUser(
|
|
user_id=str(row["user_id"]),
|
|
email=row["email"],
|
|
display_name=row["display_name"] or row["email"],
|
|
role=_app_role(row["role"]),
|
|
admin_access=bool(_row_value(row, "admin_access", False)),
|
|
account_status=_account_status(_row_value(row, "account_status", "approved")),
|
|
cohort_ids=_cohort_ids(_row_value(row, "cohort")),
|
|
affiliation=row["affiliation"] or DEFAULT_AFFILIATION,
|
|
legal_name=_row_value(row, "legal_name", "") or "",
|
|
department=_row_value(row, "department", "") or "",
|
|
grade_level=_row_value(row, "grade_level", "") or "",
|
|
phone=_row_value(row, "phone", "") or "",
|
|
contact_address=_row_value(row, "contact_address", "") or "",
|
|
nickname=_row_value(row, "nickname", "") or "",
|
|
self_introduction=_row_value(row, "self_introduction", "") or "",
|
|
avatar_url=_row_value(row, "avatar_url", "") or "",
|
|
consent_at=_optional_ts(_row_value(row, "consent_at")),
|
|
profile_completed_at=_optional_ts(_row_value(row, "profile_completed_at")),
|
|
terms_agreed_at=_optional_ts(_row_value(row, "terms_agreed_at")),
|
|
privacy_agreed_at=_optional_ts(_row_value(row, "privacy_agreed_at")),
|
|
terms_version=_row_value(row, "terms_version", "") or "",
|
|
privacy_version=_row_value(row, "privacy_version", "") or "",
|
|
created_at=_ts(row["created_at"]),
|
|
last_seen_at=_ts(row["last_seen_at"]),
|
|
)
|
|
|
|
|
|
def _memory_upsert_managed_user(data: ManagedUserMemoryInput) -> ManagedUser:
|
|
now = time.time()
|
|
normalized_email = _normalize_email(data.email)
|
|
if normalized_email in _inactive_emails and not data.reactivate:
|
|
raise InactiveUserError("user is inactive")
|
|
if data.reactivate:
|
|
_inactive_emails.discard(normalized_email)
|
|
uid = data.user_id or _email_index.get(normalized_email) or user_id_from_email(normalized_email)
|
|
current = _users.get(uid)
|
|
user = ManagedUser(
|
|
user_id=uid,
|
|
email=normalized_email,
|
|
display_name=(data.display_name.strip() if data.display_name else "") or normalized_email,
|
|
role=data.role,
|
|
admin_access=data.admin_access if data.admin_access is not None else (current.admin_access if current else False),
|
|
account_status=data.account_status or (current.account_status if current else "approved"),
|
|
cohort_ids=(
|
|
list(data.cohort_ids)
|
|
if data.cohort_ids is not None
|
|
else (list(current.cohort_ids) if current else [])
|
|
),
|
|
affiliation=(
|
|
data.affiliation.strip()
|
|
if data.affiliation
|
|
else (current.affiliation if current else DEFAULT_AFFILIATION)
|
|
),
|
|
legal_name=(
|
|
data.legal_name.strip()
|
|
if data.legal_name is not None
|
|
else (current.legal_name if current else "")
|
|
),
|
|
department=(
|
|
data.department.strip()
|
|
if data.department is not None
|
|
else (current.department if current else "")
|
|
),
|
|
grade_level=(
|
|
data.grade_level.strip()
|
|
if data.grade_level is not None
|
|
else (current.grade_level if current else "")
|
|
),
|
|
phone=data.phone.strip() if data.phone is not None else (current.phone if current else ""),
|
|
contact_address=(
|
|
data.contact_address.strip()
|
|
if data.contact_address is not None
|
|
else (current.contact_address if current else "")
|
|
),
|
|
nickname=data.nickname.strip() if data.nickname is not None else (current.nickname if current else ""),
|
|
self_introduction=(
|
|
data.self_introduction.strip()
|
|
if data.self_introduction is not None
|
|
else (current.self_introduction if current else "")
|
|
),
|
|
avatar_url=data.avatar_url.strip() if data.avatar_url is not None else (current.avatar_url if current else ""),
|
|
consent_at=data.consent_at if data.consent_at is not None else (current.consent_at if current else None),
|
|
profile_completed_at=(
|
|
data.profile_completed_at
|
|
if data.profile_completed_at is not None
|
|
else (current.profile_completed_at if current else None)
|
|
),
|
|
terms_agreed_at=(
|
|
data.terms_agreed_at
|
|
if data.terms_agreed_at is not None
|
|
else (current.terms_agreed_at if current else None)
|
|
),
|
|
privacy_agreed_at=(
|
|
data.privacy_agreed_at
|
|
if data.privacy_agreed_at is not None
|
|
else (current.privacy_agreed_at if current else None)
|
|
),
|
|
terms_version=(
|
|
data.terms_version.strip()
|
|
if data.terms_version is not None
|
|
else (current.terms_version if current else "")
|
|
),
|
|
privacy_version=(
|
|
data.privacy_version.strip()
|
|
if data.privacy_version is not None
|
|
else (current.privacy_version if current else "")
|
|
),
|
|
created_at=current.created_at if current else now,
|
|
last_seen_at=now,
|
|
)
|
|
_users[uid] = user
|
|
_email_index[normalized_email] = uid
|
|
return user
|
|
|
|
|
|
async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
|
|
normalized_email = _normalize_email(data.email)
|
|
normalized_external_id = _normalize_external_id(data.external_id, normalized_email)
|
|
manual_external_id = f"email:{normalized_email}"
|
|
desired_account_status = data.account_status or _initial_account_status(
|
|
email=normalized_email,
|
|
external_id=normalized_external_id,
|
|
reactivate=data.reactivate,
|
|
)
|
|
try:
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
if data.user_id is None and normalized_external_id != manual_external_id:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
UPDATE app.app_user SET
|
|
external_id = $1,
|
|
display_name = COALESCE(NULLIF(display_name, ''), $3),
|
|
cohort = COALESCE(cohort, $4),
|
|
affiliation = COALESCE(NULLIF(affiliation, ''), $5),
|
|
last_seen_at = now(),
|
|
updated_at = now()
|
|
WHERE user_id = (
|
|
SELECT user_id
|
|
FROM app.app_user
|
|
WHERE lower(email) = $2
|
|
AND is_active
|
|
AND (external_id = $6 OR external_id IS NULL)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM app.app_user existing
|
|
WHERE existing.external_id = $1
|
|
)
|
|
ORDER BY last_seen_at DESC
|
|
LIMIT 1
|
|
)
|
|
RETURNING
|
|
user_id,
|
|
email,
|
|
display_name,
|
|
role,
|
|
admin_access,
|
|
account_status,
|
|
cohort,
|
|
affiliation,
|
|
legal_name,
|
|
department,
|
|
grade_level,
|
|
phone,
|
|
contact_address,
|
|
nickname,
|
|
self_introduction,
|
|
avatar_url,
|
|
consent_at,
|
|
profile_completed_at,
|
|
terms_agreed_at,
|
|
privacy_agreed_at,
|
|
terms_version,
|
|
privacy_version,
|
|
created_at,
|
|
last_seen_at
|
|
""",
|
|
normalized_external_id,
|
|
normalized_email,
|
|
(data.display_name.strip() if data.display_name else normalized_email),
|
|
_cohort_value(data.cohort_ids),
|
|
data.affiliation or DEFAULT_AFFILIATION,
|
|
manual_external_id,
|
|
)
|
|
if row is not None:
|
|
user = _managed_user_from_row(row)
|
|
_memory_upsert_managed_user(ManagedUserMemoryInput.from_user(user, reactivate=True))
|
|
return user
|
|
row = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO app.app_user (
|
|
external_id,
|
|
email,
|
|
display_name,
|
|
role,
|
|
admin_access,
|
|
cohort,
|
|
affiliation,
|
|
account_status,
|
|
last_seen_at,
|
|
updated_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, COALESCE($9, false), $5, $6, $8, now(), now())
|
|
ON CONFLICT (external_id) DO UPDATE SET
|
|
email = EXCLUDED.email,
|
|
display_name = COALESCE(NULLIF(EXCLUDED.display_name, ''), app.app_user.display_name),
|
|
role = EXCLUDED.role,
|
|
admin_access = CASE
|
|
WHEN $9 IS NULL THEN app.app_user.admin_access
|
|
ELSE EXCLUDED.admin_access
|
|
END,
|
|
cohort = COALESCE(EXCLUDED.cohort, app.app_user.cohort),
|
|
affiliation = COALESCE(NULLIF(EXCLUDED.affiliation, ''), app.app_user.affiliation),
|
|
is_active = CASE WHEN $7 THEN TRUE ELSE app.app_user.is_active END,
|
|
account_status = CASE
|
|
WHEN app.app_user.account_status = 'suspended' THEN 'suspended'
|
|
WHEN EXCLUDED.account_status = 'approved' THEN 'approved'
|
|
WHEN app.app_user.account_status = 'approved' THEN 'approved'
|
|
ELSE EXCLUDED.account_status
|
|
END,
|
|
last_seen_at = now(),
|
|
updated_at = now()
|
|
WHERE app.app_user.is_active OR $7
|
|
RETURNING
|
|
user_id,
|
|
email,
|
|
display_name,
|
|
role,
|
|
admin_access,
|
|
account_status,
|
|
cohort,
|
|
affiliation,
|
|
legal_name,
|
|
department,
|
|
grade_level,
|
|
phone,
|
|
contact_address,
|
|
nickname,
|
|
self_introduction,
|
|
avatar_url,
|
|
consent_at,
|
|
profile_completed_at,
|
|
terms_agreed_at,
|
|
privacy_agreed_at,
|
|
terms_version,
|
|
privacy_version,
|
|
created_at,
|
|
last_seen_at
|
|
""",
|
|
normalized_external_id,
|
|
normalized_email,
|
|
(data.display_name.strip() if data.display_name else normalized_email),
|
|
_db_role(data.role),
|
|
_cohort_value(data.cohort_ids),
|
|
data.affiliation or DEFAULT_AFFILIATION,
|
|
data.reactivate,
|
|
desired_account_status,
|
|
data.admin_access,
|
|
)
|
|
if row is None:
|
|
_inactive_emails.add(normalized_email)
|
|
raise InactiveUserError("user is inactive")
|
|
user = _managed_user_from_row(row)
|
|
_memory_upsert_managed_user(ManagedUserMemoryInput.from_user(user, reactivate=True))
|
|
return user
|
|
except InactiveUserError:
|
|
raise
|
|
except Exception:
|
|
require_runtime_fallback_allowed("managed user")
|
|
current = _users.get(data.user_id or "") or _users.get(_email_index.get(normalized_email, ""))
|
|
fallback_uid = data.user_id or (
|
|
current.user_id if current is not None else user_id_from_external_id(normalized_external_id)
|
|
)
|
|
fallback_account_status = desired_account_status
|
|
if current is not None:
|
|
if current.account_status == "suspended":
|
|
fallback_account_status = "suspended"
|
|
elif current.account_status == "approved" and desired_account_status == "pending":
|
|
fallback_account_status = "approved"
|
|
return _memory_upsert_managed_user(
|
|
ManagedUserMemoryInput(
|
|
email=normalized_email,
|
|
display_name=data.display_name,
|
|
role=data.role,
|
|
admin_access=data.admin_access,
|
|
account_status=fallback_account_status,
|
|
cohort_ids=data.cohort_ids,
|
|
user_id=fallback_uid,
|
|
affiliation=data.affiliation,
|
|
reactivate=data.reactivate,
|
|
)
|
|
)
|
|
|
|
|
|
async def get_managed_user(user_id: str) -> ManagedUser | None:
|
|
try:
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
SELECT
|
|
user_id,
|
|
email,
|
|
display_name,
|
|
role,
|
|
admin_access,
|
|
account_status,
|
|
cohort,
|
|
affiliation,
|
|
legal_name,
|
|
department,
|
|
grade_level,
|
|
phone,
|
|
contact_address,
|
|
nickname,
|
|
self_introduction,
|
|
avatar_url,
|
|
consent_at,
|
|
profile_completed_at,
|
|
terms_agreed_at,
|
|
privacy_agreed_at,
|
|
terms_version,
|
|
privacy_version,
|
|
created_at,
|
|
last_seen_at
|
|
FROM app.app_user
|
|
WHERE user_id = $1::uuid AND is_active
|
|
""",
|
|
user_id,
|
|
)
|
|
if row is not None:
|
|
return _managed_user_from_row(row)
|
|
except Exception:
|
|
require_runtime_fallback_allowed("managed user")
|
|
if not runtime_fallback_allowed():
|
|
return None
|
|
return _users.get(user_id)
|
|
|
|
|
|
async def get_managed_user_by_email(email: str) -> ManagedUser | None:
|
|
normalized_email = _normalize_email(email)
|
|
if not normalized_email:
|
|
return None
|
|
manual_external_id = f"email:{normalized_email}"
|
|
try:
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
SELECT
|
|
user_id,
|
|
email,
|
|
display_name,
|
|
role,
|
|
admin_access,
|
|
account_status,
|
|
cohort,
|
|
affiliation,
|
|
legal_name,
|
|
department,
|
|
grade_level,
|
|
phone,
|
|
contact_address,
|
|
nickname,
|
|
self_introduction,
|
|
avatar_url,
|
|
consent_at,
|
|
profile_completed_at,
|
|
terms_agreed_at,
|
|
privacy_agreed_at,
|
|
terms_version,
|
|
privacy_version,
|
|
created_at,
|
|
last_seen_at
|
|
FROM app.app_user
|
|
WHERE lower(email) = $1 AND is_active
|
|
ORDER BY
|
|
CASE WHEN external_id = $2 THEN 0 ELSE 1 END,
|
|
last_seen_at DESC
|
|
LIMIT 1
|
|
""",
|
|
normalized_email,
|
|
manual_external_id,
|
|
)
|
|
if row is not None:
|
|
return _managed_user_from_row(row)
|
|
except Exception:
|
|
require_runtime_fallback_allowed("managed user lookup")
|
|
if not runtime_fallback_allowed():
|
|
return None
|
|
uid = _email_index.get(normalized_email)
|
|
return _users.get(uid or "")
|
|
|
|
|
|
async def list_managed_users() -> tuple[list[ManagedUser], bool]:
|
|
try:
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT
|
|
user_id,
|
|
email,
|
|
display_name,
|
|
role,
|
|
admin_access,
|
|
account_status,
|
|
cohort,
|
|
affiliation,
|
|
legal_name,
|
|
department,
|
|
grade_level,
|
|
phone,
|
|
contact_address,
|
|
nickname,
|
|
self_introduction,
|
|
avatar_url,
|
|
consent_at,
|
|
profile_completed_at,
|
|
terms_agreed_at,
|
|
privacy_agreed_at,
|
|
terms_version,
|
|
privacy_version,
|
|
created_at,
|
|
last_seen_at
|
|
FROM app.app_user
|
|
WHERE is_active
|
|
ORDER BY last_seen_at DESC
|
|
"""
|
|
)
|
|
return [_managed_user_from_row(row) for row in rows], True
|
|
except Exception:
|
|
require_runtime_fallback_allowed("managed user list")
|
|
return sorted(_users.values(), key=lambda u: u.last_seen_at, reverse=True), False
|
|
|
|
|
|
async def update_managed_user(
|
|
user_id: str,
|
|
patch: ManagedUserPatch,
|
|
) -> ManagedUser | None:
|
|
try:
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
UPDATE app.app_user SET
|
|
display_name = COALESCE($2, display_name),
|
|
role = COALESCE($3, role),
|
|
account_status = COALESCE($18, account_status),
|
|
admin_access = COALESCE($19, admin_access),
|
|
cohort = CASE WHEN $4 THEN $5 ELSE cohort END,
|
|
affiliation = COALESCE($6, affiliation),
|
|
legal_name = COALESCE($7, legal_name),
|
|
department = COALESCE($8, department),
|
|
grade_level = COALESCE($9, grade_level),
|
|
phone = COALESCE($10, phone),
|
|
contact_address = COALESCE($11, contact_address),
|
|
nickname = COALESCE($12, nickname),
|
|
self_introduction = COALESCE($13, self_introduction),
|
|
avatar_url = COALESCE($14, avatar_url),
|
|
profile_completed_at = CASE WHEN $15 THEN now() ELSE profile_completed_at END,
|
|
terms_agreed_at = CASE WHEN $15 THEN now() ELSE terms_agreed_at END,
|
|
privacy_agreed_at = CASE WHEN $15 THEN now() ELSE privacy_agreed_at END,
|
|
terms_version = CASE WHEN $15 THEN COALESCE($16, terms_version) ELSE terms_version END,
|
|
privacy_version = CASE WHEN $15 THEN COALESCE($17, privacy_version) ELSE privacy_version END,
|
|
updated_at = now(),
|
|
last_seen_at = now()
|
|
WHERE user_id = $1::uuid AND is_active
|
|
RETURNING
|
|
user_id,
|
|
email,
|
|
display_name,
|
|
role,
|
|
admin_access,
|
|
account_status,
|
|
cohort,
|
|
affiliation,
|
|
legal_name,
|
|
department,
|
|
grade_level,
|
|
phone,
|
|
contact_address,
|
|
nickname,
|
|
self_introduction,
|
|
avatar_url,
|
|
consent_at,
|
|
profile_completed_at,
|
|
terms_agreed_at,
|
|
privacy_agreed_at,
|
|
terms_version,
|
|
privacy_version,
|
|
created_at,
|
|
last_seen_at
|
|
""",
|
|
user_id,
|
|
patch.display_name.strip() if patch.display_name is not None else None,
|
|
_db_role(patch.role) if patch.role is not None else None,
|
|
patch.cohort_ids is not None,
|
|
_cohort_value(patch.cohort_ids),
|
|
patch.affiliation.strip() if patch.affiliation is not None else None,
|
|
patch.legal_name.strip() if patch.legal_name is not None else None,
|
|
patch.department.strip() if patch.department is not None else None,
|
|
patch.grade_level.strip() if patch.grade_level is not None else None,
|
|
patch.phone.strip() if patch.phone is not None else None,
|
|
patch.contact_address.strip() if patch.contact_address is not None else None,
|
|
patch.nickname.strip() if patch.nickname is not None else None,
|
|
patch.self_introduction.strip() if patch.self_introduction is not None else None,
|
|
patch.avatar_url.strip() if patch.avatar_url is not None else None,
|
|
patch.complete_onboarding,
|
|
patch.terms_version.strip() if patch.terms_version is not None else None,
|
|
patch.privacy_version.strip() if patch.privacy_version is not None else None,
|
|
patch.account_status,
|
|
patch.admin_access,
|
|
)
|
|
if row is not None:
|
|
next_user = _managed_user_from_row(row)
|
|
_memory_upsert_managed_user(ManagedUserMemoryInput.from_user(next_user))
|
|
for session in _sessions.values():
|
|
if session.user_id == user_id:
|
|
session.display_name = next_user.display_name
|
|
session.role = next_user.role
|
|
session.admin_access = has_admin_access(
|
|
next_user.email,
|
|
next_user.role,
|
|
next_user.admin_access,
|
|
)
|
|
session.super_admin = is_super_admin_email(next_user.email)
|
|
session.account_status = next_user.account_status
|
|
session.cohort_ids = list(next_user.cohort_ids)
|
|
session.consent_at = next_user.consent_at
|
|
session.profile_completed_at = next_user.profile_completed_at
|
|
return next_user
|
|
except Exception:
|
|
require_runtime_fallback_allowed("managed user update")
|
|
|
|
if not runtime_fallback_allowed():
|
|
return None
|
|
current = _users.get(user_id)
|
|
if current is None:
|
|
return None
|
|
|
|
next_user = ManagedUser(
|
|
user_id=current.user_id,
|
|
email=current.email,
|
|
display_name=patch.display_name.strip() if patch.display_name is not None else current.display_name,
|
|
role=patch.role if patch.role is not None else current.role,
|
|
admin_access=patch.admin_access if patch.admin_access is not None else current.admin_access,
|
|
account_status=patch.account_status if patch.account_status is not None else current.account_status,
|
|
cohort_ids=list(patch.cohort_ids) if patch.cohort_ids is not None else current.cohort_ids,
|
|
affiliation=patch.affiliation.strip() if patch.affiliation is not None else current.affiliation,
|
|
legal_name=patch.legal_name.strip() if patch.legal_name is not None else current.legal_name,
|
|
department=patch.department.strip() if patch.department is not None else current.department,
|
|
grade_level=patch.grade_level.strip() if patch.grade_level is not None else current.grade_level,
|
|
phone=patch.phone.strip() if patch.phone is not None else current.phone,
|
|
contact_address=(
|
|
patch.contact_address.strip()
|
|
if patch.contact_address is not None
|
|
else current.contact_address
|
|
),
|
|
nickname=patch.nickname.strip() if patch.nickname is not None else current.nickname,
|
|
self_introduction=(
|
|
patch.self_introduction.strip()
|
|
if patch.self_introduction is not None
|
|
else current.self_introduction
|
|
),
|
|
avatar_url=patch.avatar_url.strip() if patch.avatar_url is not None else current.avatar_url,
|
|
consent_at=current.consent_at,
|
|
profile_completed_at=time.time() if patch.complete_onboarding else current.profile_completed_at,
|
|
terms_agreed_at=time.time() if patch.complete_onboarding else current.terms_agreed_at,
|
|
privacy_agreed_at=time.time() if patch.complete_onboarding else current.privacy_agreed_at,
|
|
terms_version=(
|
|
patch.terms_version.strip()
|
|
if patch.complete_onboarding and patch.terms_version is not None
|
|
else current.terms_version
|
|
),
|
|
privacy_version=(
|
|
patch.privacy_version.strip()
|
|
if patch.complete_onboarding and patch.privacy_version is not None
|
|
else current.privacy_version
|
|
),
|
|
created_at=current.created_at,
|
|
last_seen_at=time.time(),
|
|
)
|
|
_users[user_id] = next_user
|
|
|
|
for session in _sessions.values():
|
|
if session.user_id == user_id:
|
|
session.display_name = next_user.display_name
|
|
session.role = next_user.role
|
|
session.admin_access = has_admin_access(
|
|
next_user.email,
|
|
next_user.role,
|
|
next_user.admin_access,
|
|
)
|
|
session.super_admin = is_super_admin_email(next_user.email)
|
|
session.account_status = next_user.account_status
|
|
session.cohort_ids = list(next_user.cohort_ids)
|
|
session.consent_at = next_user.consent_at
|
|
session.profile_completed_at = next_user.profile_completed_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] = replace(
|
|
memory_user,
|
|
consent_at=consent_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] = replace(current, consent_at=consent_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] = replace(current, consent_at=None, 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 user_onboarding_complete(user_id: str) -> bool:
|
|
managed = await get_managed_user(user_id)
|
|
return bool(
|
|
managed
|
|
and managed.profile_completed_at is not None
|
|
and managed.terms_agreed_at is not None
|
|
and managed.privacy_agreed_at is not None
|
|
and bool(managed.nickname.strip())
|
|
and bool(managed.self_introduction.strip())
|
|
)
|
|
|
|
|
|
async def deactivate_managed_user(user_id: str) -> bool:
|
|
"""Deactivate a managed user and revoke their active browser sessions."""
|
|
changed = False
|
|
inactive_email: str | None = None
|
|
try:
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"SELECT email FROM app.app_user WHERE user_id = $1::uuid",
|
|
user_id,
|
|
)
|
|
if row is not None and row["email"]:
|
|
inactive_email = _normalize_email(row["email"])
|
|
result = await conn.execute(
|
|
"""
|
|
UPDATE app.app_user SET
|
|
is_active = FALSE,
|
|
updated_at = now(),
|
|
last_seen_at = now()
|
|
WHERE user_id = $1::uuid AND is_active
|
|
""",
|
|
user_id,
|
|
)
|
|
changed = result.endswith(" 1")
|
|
await conn.execute(
|
|
"UPDATE app.auth_session SET revoked_at = now() WHERE user_id = $1::uuid",
|
|
user_id,
|
|
)
|
|
except Exception:
|
|
require_runtime_fallback_allowed("managed user deactivation")
|
|
|
|
if not runtime_fallback_allowed():
|
|
return changed
|
|
memory_user = _users.pop(user_id, None)
|
|
if memory_user is not None:
|
|
inactive_email = _normalize_email(memory_user.email)
|
|
if inactive_email:
|
|
_inactive_emails.add(inactive_email)
|
|
for email, uid in list(_email_index.items()):
|
|
if uid == user_id:
|
|
_email_index.pop(email, None)
|
|
for sid_hash, session in list(_sessions.items()):
|
|
if session.user_id == user_id:
|
|
_sessions.pop(sid_hash, None)
|
|
return changed or memory_user is not None
|
|
|
|
|
|
async def active_session_count(user_id: str) -> int:
|
|
try:
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
return int(
|
|
await conn.fetchval(
|
|
"""
|
|
SELECT count(*)
|
|
FROM app.auth_session
|
|
WHERE user_id = $1::uuid
|
|
AND revoked_at IS NULL
|
|
AND expires_at > now()
|
|
""",
|
|
user_id,
|
|
)
|
|
or 0
|
|
)
|
|
except Exception:
|
|
require_runtime_fallback_allowed("auth session count")
|
|
now = time.time()
|
|
count = 0
|
|
for session in _sessions.values():
|
|
if session.user_id == user_id and session.expires_at > now:
|
|
count += 1
|
|
return count
|
|
|
|
|
|
async def create_session(
|
|
*,
|
|
email: str,
|
|
display_name: str,
|
|
role: RoleName,
|
|
cohort_ids: list[str] | None = None,
|
|
user_id: str | None = None,
|
|
external_id: str | 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,
|
|
reactivate=False,
|
|
)
|
|
)
|
|
expires_at = time.time() + settings.session_ttl_seconds
|
|
user = SessionUser(
|
|
user_id=managed.user_id,
|
|
email=normalized_email,
|
|
display_name=managed.display_name,
|
|
role=managed.role,
|
|
admin_access=has_admin_access(managed.email, managed.role, managed.admin_access),
|
|
super_admin=is_super_admin_email(managed.email),
|
|
account_status=managed.account_status,
|
|
cohort_ids=list(managed.cohort_ids),
|
|
consent_at=managed.consent_at,
|
|
profile_completed_at=managed.profile_completed_at,
|
|
expires_at=expires_at,
|
|
)
|
|
sid_hash = _sid_hash(raw_sid)
|
|
try:
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.auth_session (
|
|
sid_hash, user_id, role, display_name, cohort_ids, expires_at, last_seen_at
|
|
)
|
|
VALUES ($1, $2::uuid, $3, $4, $5::jsonb, $6, 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,
|
|
expires_at = EXCLUDED.expires_at,
|
|
revoked_at = NULL,
|
|
last_seen_at = now()
|
|
""",
|
|
sid_hash,
|
|
managed.user_id,
|
|
managed.role,
|
|
managed.display_name,
|
|
list(managed.cohort_ids),
|
|
datetime.fromtimestamp(expires_at, tz=timezone.utc),
|
|
)
|
|
except Exception:
|
|
require_runtime_fallback_allowed("browser session")
|
|
_sessions[sid_hash] = user
|
|
return raw_sid, user
|
|
|
|
|
|
async def get_session(raw_sid: str | None) -> SessionUser | None:
|
|
if not raw_sid:
|
|
return None
|
|
key = _sid_hash(raw_sid)
|
|
try:
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
SELECT
|
|
s.expires_at,
|
|
u.user_id,
|
|
u.email,
|
|
COALESCE(u.display_name, s.display_name, u.email) AS display_name,
|
|
u.role,
|
|
u.admin_access,
|
|
u.account_status,
|
|
u.cohort,
|
|
u.consent_at,
|
|
u.profile_completed_at
|
|
FROM app.auth_session s
|
|
JOIN app.app_user u ON u.user_id = s.user_id
|
|
WHERE s.sid_hash = $1
|
|
AND s.revoked_at IS NULL
|
|
AND s.expires_at > now()
|
|
AND u.is_active
|
|
""",
|
|
key,
|
|
)
|
|
if row is not None:
|
|
await conn.execute(
|
|
"UPDATE app.auth_session SET last_seen_at = now() WHERE sid_hash = $1",
|
|
key,
|
|
)
|
|
await conn.execute(
|
|
"UPDATE app.app_user SET last_seen_at = now() WHERE user_id = $1",
|
|
row["user_id"],
|
|
)
|
|
return SessionUser(
|
|
user_id=str(row["user_id"]),
|
|
email=row["email"],
|
|
display_name=row["display_name"],
|
|
role=_app_role(row["role"]),
|
|
admin_access=has_admin_access(
|
|
row["email"],
|
|
_app_role(row["role"]),
|
|
bool(_row_value(row, "admin_access", False)),
|
|
),
|
|
super_admin=is_super_admin_email(row["email"]),
|
|
account_status=_account_status(row["account_status"]),
|
|
cohort_ids=_cohort_ids(row["cohort"]),
|
|
consent_at=_optional_ts(row["consent_at"]),
|
|
profile_completed_at=_optional_ts(row["profile_completed_at"]),
|
|
expires_at=_ts(row["expires_at"]),
|
|
)
|
|
except Exception:
|
|
require_runtime_fallback_allowed("browser session")
|
|
|
|
if not runtime_fallback_allowed():
|
|
return None
|
|
user = _sessions.get(key)
|
|
if user is None:
|
|
return None
|
|
if user.expires_at <= time.time():
|
|
_sessions.pop(key, None)
|
|
return None
|
|
managed = _users.get(user.user_id)
|
|
if managed is not None:
|
|
managed.last_seen_at = time.time()
|
|
user.display_name = managed.display_name
|
|
user.role = managed.role
|
|
user.admin_access = has_admin_access(managed.email, managed.role, managed.admin_access)
|
|
user.super_admin = is_super_admin_email(managed.email)
|
|
user.account_status = managed.account_status
|
|
user.cohort_ids = list(managed.cohort_ids)
|
|
user.consent_at = managed.consent_at
|
|
user.profile_completed_at = managed.profile_completed_at
|
|
return user
|
|
|
|
|
|
async def revoke_session(raw_sid: str | None) -> None:
|
|
if not raw_sid:
|
|
return
|
|
key = _sid_hash(raw_sid)
|
|
_sessions.pop(key, None)
|
|
try:
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
await conn.execute(
|
|
"UPDATE app.auth_session SET revoked_at = now() WHERE sid_hash = $1",
|
|
key,
|
|
)
|
|
except Exception:
|
|
require_runtime_fallback_allowed("browser session revoke")
|