"""의존성 — RBAC × visible_to 정보비대칭 게이트. 2-레이어 강제 (설계서 §4.1, F-30): 레이어1 = AI 정보비대칭: current_ai_view (CLIENT_AI 분기엔 CCD/정답 로드 코드경로 자체 부재) 레이어2 = 인간 RBAC×cohort: current_role (RLS DB 레벨 방어선) 인간이 turn 읽을 때 두 게이트 AND. 여기선 요청 컨텍스트 추출 + DB 세션변수 주입 계약만 제공. NOTE: 실제 세션/쿠키 검증은 auth.py BFF + Redis 세션 구현 시 완성(현재 스텁). """ from __future__ import annotations from enum import Enum from typing import Annotated, AsyncIterator, Optional import asyncpg from fastapi import Cookie, Depends, HTTPException, status from .config import Settings, get_settings from .db import acquire # ── 인간 역할 (RBAC) ──────────────────────────────────── class Role(str, Enum): LEARNER = "learner" # 본인 세션만 (/learn) TEACHER = "teacher" # 담당 코호트 전체 열람+검수 (/teach) ADMIN = "admin" # 전부 + 교수활동 감사 (/admin) # ── AI 뷰 (정보비대칭, current_ai_view enum) ──────────── class AIView(str, Enum): CLIENT = "client" # 가상내담자 AI — CCD/정답/점수 절대 비노출 COUNSELOR = "counselor" # 상담사 AI(보조) — 표면 대화만, DSM 차단 EVALUATOR = "evaluator" # 평가 AI — 전부 봄 (학습자엔 비노출) class Principal: """인증된 요청 주체. 인간 role + (선택) cohort 범위.""" def __init__( self, user_id: str, role: Role, cohort_ids: Optional[list[str]] = None, ) -> None: self.user_id = user_id self.role = role self.cohort_ids = cohort_ids or [] def get_settings_dep() -> Settings: return get_settings() async def get_current_principal( # __Host- HttpOnly 쿠키 (config.cookie_name). 브라우저엔 토큰 미노출. session_cookie: Annotated[Optional[str], Cookie(alias="__Host-vignette_sid")] = None, ) -> Principal: """세션 쿠키 -> Principal. TODO(auth.py 완성 시): Redis 세션 조회로 user_id/role/cohort 복원. 현재 스텁: 쿠키 없으면 401, 있으면 LEARNER 더미(개발용). prod 에선 session_cookie 검증 실패 시 무조건 401. """ if not session_cookie: # dev 환경에선 쿠키 없어도 더미 학습자로 통과(로컬 라이브 테스트). prod 는 무조건 401. if get_settings().environment != "dev": raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="not authenticated", ) # TODO: Redis 세션 룩업. 아래는 개발 스텁. return Principal(user_id="dev-user", role=Role.LEARNER, cohort_ids=[]) def require_role(*allowed: Role): """역할 화이트리스트 의존성 팩토리. 예: Depends(require_role(Role.TEACHER, Role.ADMIN)).""" async def _checker( principal: Annotated[Principal, Depends(get_current_principal)], ) -> Principal: if principal.role not in allowed: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"role {principal.role.value} not permitted", ) return principal return _checker async def db_for_human( principal: Annotated[Principal, Depends(get_current_principal)], ) -> AsyncIterator[asyncpg.Connection]: """인간 요청용 RLS 컨텍스트 커넥션 (레이어2 강제). app.current_role 주입 -> RLS 정책이 코호트/소유권 필터. 라우트에서: conn: Annotated[asyncpg.Connection, Depends(db_for_human)] """ async with acquire(role=principal.role.value) as conn: # cohort 스코프는 RLS 정책이 current_role + 소유 테이블로 강제 (설계서 §4). yield conn def db_for_ai_view(view: AIView): """AI 역할용 RLS 컨텍스트 (레이어1 강제) 의존성 팩토리. app.current_ai_view 주입 -> visible_to[] WHERE 강제. CLIENT 분기는 ccd/정답 로드 함수 자체를 부르지 않음(코드경로 부재 1차방어). """ async def _provider() -> AsyncIterator[asyncpg.Connection]: async with acquire(ai_view=view.value) as conn: yield conn return _provider # 타입 별칭 (라우트 시그니처 간결화) CurrentPrincipal = Annotated[Principal, Depends(get_current_principal)] HumanDB = Annotated[asyncpg.Connection, Depends(db_for_human)]