Stabilize runtime auth and E2E coverage

This commit is contained in:
Yun Chan 2026-06-26 14:47:00 +09:00
parent 6a3e3b541c
commit 188e899394
133 changed files with 55987 additions and 6775 deletions

View file

@ -1,7 +1,8 @@
"""asyncpg 연결 풀 + pgvector 등록.
DB = NAS PostgreSQL 16 단일 SoR (마스터플랜 §0). 스키마 4분할: app / kb / audit / ds.
RLS 이중강제: 커넥션 획득 SET LOCAL app.current_role / app.current_ai_view 주입
RLS 이중강제: 커넥션 획득 SET LOCAL app.current_role / app.current_uid /
app.current_cohort / app.ai_context / app.current_ai_view / app.current_sens_max 주입
(deps.py RBAC 의존성과 ). 여기선 + 헬퍼만 제공한다.
"""
@ -9,7 +10,7 @@ from __future__ import annotations
import json
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator, Optional
from typing import Any, AsyncIterator, Optional, Sequence
import asyncpg
@ -70,17 +71,44 @@ def get_pool() -> asyncpg.Pool:
return _pool
def _db_role(role: str) -> str:
return "instructor" if role == "teacher" else role
def _default_sensitivity_max(ai_view: str | None) -> int | None:
return {
"client": 1,
"counselor": 0,
"evaluator": 2,
}.get(ai_view or "")
def _cohort_value(cohort_ids: Sequence[str] | None, cohort: str | None) -> str:
if cohort:
return cohort
if not cohort_ids:
return ""
return cohort_ids[0] or ""
@asynccontextmanager
async def acquire(
*,
role: Optional[str] = None,
user_id: Optional[str] = None,
cohort_ids: Optional[Sequence[str]] = None,
cohort: Optional[str] = None,
ai_view: Optional[str] = None,
ai_context: Optional[bool] = None,
sensitivity_max: Optional[int] = None,
) -> AsyncIterator[asyncpg.Connection]:
"""커넥션 획득 + RLS 컨텍스트 주입.
RLS 이중강제 (설계서 §4.1, F-30):
레이어1 = AI 정보비대칭: app.current_ai_view (visible_to[] WHERE 강제)
레이어2 = 인간 RBAC×cohort: app.current_role (RLS 정책)
레이어1 = AI 정보비대칭: app.ai_context + app.current_ai_view +
app.current_sens_max (visible_to[]/sensitivity WHERE 강제)
레이어2 = 인간 RBAC×cohort: app.current_role + app.current_uid +
app.current_cohort (RLS 정책)
트랜잭션 SET LOCAL 주입해 커넥션 재사용 누수 방지.
NOTE: RLS 정책/세션변수는 Phase 0 마이그레이션에서 정의(설계서 §3.3 / §4).
@ -89,20 +117,47 @@ async def acquire(
pool = get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
is_ai = ai_context if ai_context is not None else ai_view is not None
await conn.execute("SELECT set_config('app.ai_context', $1, true)", "1" if is_ai else "")
if role is not None:
await conn.execute("SELECT set_config('app.current_role', $1, true)", role)
await conn.execute("SELECT set_config('app.current_role', $1, true)", _db_role(role))
if user_id is not None:
await conn.execute("SELECT set_config('app.current_uid', $1, true)", user_id)
cohort_name = _cohort_value(cohort_ids, cohort)
if cohort_name:
await conn.execute("SELECT set_config('app.current_cohort', $1, true)", cohort_name)
if ai_view is not None:
await conn.execute("SELECT set_config('app.current_ai_view', $1, true)", ai_view)
sens = sensitivity_max if sensitivity_max is not None else _default_sensitivity_max(ai_view)
if sens is not None:
await conn.execute(
"SELECT set_config('app.current_sens_max', $1, true)",
str(sens),
)
yield conn
async def healthcheck() -> bool:
"""SELECT 1 핑. /health 에서 사용."""
"""Return true only when the DB is reachable and required app tables exist."""
try:
pool = get_pool()
async with pool.acquire() as conn:
val = await conn.fetchval("SELECT 1")
return val == 1
row = await conn.fetchrow(
"""
SELECT
to_regclass('app.app_user') IS NOT NULL AS has_user,
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
"""
)
return bool(
row
and row["has_user"]
and row["has_auth_session"]
and row["has_preferences"]
and row["has_engine_config"]
)
except Exception:
return False