vignette/apps/api/app/auth_sessions.py
2026-06-28 11:31:07 +09:00

1719 lines
65 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 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.support_ticket') IS NOT NULL AS has_support_ticket,
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')
GROUP BY schemaname, tablename
HAVING count(*) = 2
) AS has_admin_health_event_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,
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_support_ticket"]
and row["has_admin_health_event_policies"]
and row["has_support_ticket_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.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 '',
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.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;
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');
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');
"""
)
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(
"""
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(
"""
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(
*,
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,
) -> ManagedUser:
normalized_email = _normalize_email(email)
normalized_external_id = _normalize_external_id(external_id, normalized_email)
manual_external_id = f"email:{normalized_email}"
desired_account_status = account_status or _initial_account_status(
email=normalized_email,
external_id=normalized_external_id,
reactivate=reactivate,
)
try:
pool = get_pool()
async with pool.acquire() as conn:
if 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,
(display_name.strip() if display_name else normalized_email),
_cohort_value(cohort_ids),
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,
(display_name.strip() if display_name else normalized_email),
_db_role(role),
_cohort_value(cohort_ids),
affiliation or DEFAULT_AFFILIATION,
reactivate,
desired_account_status,
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(user_id or "") or _users.get(_email_index.get(normalized_email, ""))
fallback_uid = 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=display_name,
role=role,
admin_access=admin_access,
account_status=fallback_account_status,
cohort_ids=cohort_ids,
user_id=fallback_uid,
affiliation=affiliation,
reactivate=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(
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")