796 lines
28 KiB
Python
796 lines
28 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
|
|
from typing import Literal
|
|
|
|
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: str
|
|
cohort_ids: list[str]
|
|
expires_at: float
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class ManagedUser:
|
|
user_id: str
|
|
email: str
|
|
display_name: str
|
|
role: str
|
|
cohort_ids: list[str]
|
|
affiliation: str
|
|
created_at: float
|
|
last_seen_at: float
|
|
|
|
|
|
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()
|
|
RoleName = Literal["learner", "teacher", "admin"]
|
|
DB_ROLE_BY_APP = {"learner": "learner", "teacher": "instructor", "admin": "admin"}
|
|
APP_ROLE_BY_DB = {"learner": "learner", "instructor": "teacher", "admin": "admin"}
|
|
|
|
|
|
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 _normalize_email(email: str) -> str:
|
|
return email.strip().lower()
|
|
|
|
|
|
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 _ts(value: datetime | None) -> float:
|
|
return (value or datetime.now(timezone.utc)).timestamp()
|
|
|
|
|
|
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', 'last_seen_at', 'updated_at')
|
|
GROUP BY table_schema, table_name
|
|
HAVING count(*) = 3
|
|
) AS has_user_columns,
|
|
to_regclass('app.auth_session') IS NOT NULL AS has_auth_session,
|
|
to_regclass('app.user_preferences') IS NOT NULL AS has_preferences,
|
|
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,
|
|
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_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_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:
|
|
await conn.execute(
|
|
"""
|
|
DO $$
|
|
BEGIN
|
|
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 last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
"""
|
|
)
|
|
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(
|
|
"""
|
|
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_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"]),
|
|
cohort_ids=_cohort_ids(row.get("cohort") if hasattr(row, "get") else row["cohort"]),
|
|
affiliation=row["affiliation"] or DEFAULT_AFFILIATION,
|
|
created_at=_ts(row["created_at"]),
|
|
last_seen_at=_ts(row["last_seen_at"]),
|
|
)
|
|
|
|
|
|
def _memory_upsert_managed_user(
|
|
*,
|
|
email: str,
|
|
display_name: str,
|
|
role: str,
|
|
cohort_ids: list[str] | None = None,
|
|
user_id: str | None = None,
|
|
affiliation: str | None = None,
|
|
reactivate: bool = False,
|
|
) -> ManagedUser:
|
|
now = time.time()
|
|
normalized_email = _normalize_email(email)
|
|
if normalized_email in _inactive_emails and not reactivate:
|
|
raise InactiveUserError("user is inactive")
|
|
if reactivate:
|
|
_inactive_emails.discard(normalized_email)
|
|
uid = 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=(display_name.strip() if display_name else "") or normalized_email,
|
|
role=role,
|
|
cohort_ids=list(cohort_ids or current.cohort_ids if current else cohort_ids or []),
|
|
affiliation=(
|
|
affiliation.strip()
|
|
if affiliation
|
|
else (current.affiliation if current else DEFAULT_AFFILIATION)
|
|
),
|
|
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: str,
|
|
cohort_ids: list[str] | None = None,
|
|
user_id: str | None = None,
|
|
affiliation: str | None = None,
|
|
reactivate: bool = False,
|
|
) -> ManagedUser:
|
|
normalized_email = _normalize_email(email)
|
|
try:
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO app.app_user (
|
|
external_id, email, display_name, role, cohort, affiliation, last_seen_at, updated_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, 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,
|
|
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,
|
|
last_seen_at = now(),
|
|
updated_at = now()
|
|
WHERE app.app_user.is_active OR $7
|
|
RETURNING user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
|
|
""",
|
|
f"email:{normalized_email}",
|
|
normalized_email,
|
|
(display_name.strip() if display_name else normalized_email),
|
|
_db_role(role),
|
|
_cohort_value(cohort_ids),
|
|
affiliation or DEFAULT_AFFILIATION,
|
|
reactivate,
|
|
)
|
|
if row is None:
|
|
_inactive_emails.add(normalized_email)
|
|
raise InactiveUserError("user is inactive")
|
|
user = _managed_user_from_row(row)
|
|
_memory_upsert_managed_user(
|
|
email=user.email,
|
|
display_name=user.display_name,
|
|
role=user.role,
|
|
cohort_ids=user.cohort_ids,
|
|
user_id=user.user_id,
|
|
affiliation=user.affiliation,
|
|
reactivate=True,
|
|
)
|
|
return user
|
|
except InactiveUserError:
|
|
raise
|
|
except Exception:
|
|
require_runtime_fallback_allowed("managed user")
|
|
return _memory_upsert_managed_user(
|
|
email=normalized_email,
|
|
display_name=display_name,
|
|
role=role,
|
|
cohort_ids=cohort_ids,
|
|
user_id=user_id,
|
|
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, cohort, affiliation, 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 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, cohort, affiliation, 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,
|
|
*,
|
|
display_name: str | None = None,
|
|
role: RoleName | None = None,
|
|
affiliation: str | None = None,
|
|
cohort_ids: list[str] | None = None,
|
|
) -> 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),
|
|
cohort = CASE WHEN $4 THEN $5 ELSE cohort END,
|
|
affiliation = COALESCE($6, affiliation),
|
|
updated_at = now(),
|
|
last_seen_at = now()
|
|
WHERE user_id = $1::uuid AND is_active
|
|
RETURNING user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
|
|
""",
|
|
user_id,
|
|
display_name.strip() if display_name is not None else None,
|
|
_db_role(role) if role is not None else None,
|
|
cohort_ids is not None,
|
|
_cohort_value(cohort_ids),
|
|
affiliation.strip() if affiliation is not None else None,
|
|
)
|
|
if row is not None:
|
|
next_user = _managed_user_from_row(row)
|
|
_memory_upsert_managed_user(
|
|
email=next_user.email,
|
|
display_name=next_user.display_name,
|
|
role=next_user.role,
|
|
cohort_ids=next_user.cohort_ids,
|
|
user_id=next_user.user_id,
|
|
affiliation=next_user.affiliation,
|
|
)
|
|
for session in _sessions.values():
|
|
if session.user_id == user_id:
|
|
session.display_name = next_user.display_name
|
|
session.role = next_user.role
|
|
session.cohort_ids = list(next_user.cohort_ids)
|
|
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=display_name.strip() if display_name is not None else current.display_name,
|
|
role=role if role is not None else current.role,
|
|
cohort_ids=list(cohort_ids) if cohort_ids is not None else current.cohort_ids,
|
|
affiliation=affiliation.strip() if affiliation is not None else current.affiliation,
|
|
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.cohort_ids = list(next_user.cohort_ids)
|
|
return next_user
|
|
|
|
|
|
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: str,
|
|
cohort_ids: list[str] | None = None,
|
|
user_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,
|
|
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,
|
|
cohort_ids=list(managed.cohort_ids),
|
|
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.cohort
|
|
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"]),
|
|
cohort_ids=_cohort_ids(row["cohort"]),
|
|
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()
|
|
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")
|