- 누적 작업트리 커밋: 회기 평가 복구·durable 저장, 라이브 코치 이력/근거, 교수자 학생분석, 음성 비언어 메타, PII 마스킹, 운영 티켓/헬스 등 - 문서: 완료 기록 docs/archive/ 냉동 보관, docs/ 단일 인덱스(docs/README.md)+통합 TODO(docs/TODO.md)로 정리 - 리팩터(행위 보존): Stage enum SSOT(taxonomy 소유·state_machine re-export), store recent/masked_turns 중복 제거, speaker_ko_label 단일 헬퍼, _list_sessions N+1 제거(state/turns 배치 + 턴평가 하이드레이션 배치) - 검증: 백엔드 pytest 352 passed, _list_sessions E2E chromium-single-run 2 passed
188 lines
5.7 KiB
Python
188 lines
5.7 KiB
Python
"""FastAPI dependencies for authentication, RBAC, and RLS context."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from enum import Enum
|
|
from typing import Annotated, AsyncIterator, Optional
|
|
|
|
import asyncpg
|
|
from fastapi import Cookie, Depends, HTTPException, Request, status
|
|
|
|
from .auth_types import AccountStatus
|
|
from .auth_sessions import get_session
|
|
from .config import Settings, get_settings
|
|
from .db import acquire
|
|
|
|
|
|
class Role(str, Enum):
|
|
LEARNER = "learner"
|
|
TEACHER = "teacher"
|
|
ADMIN = "admin"
|
|
|
|
|
|
class AIView(str, Enum):
|
|
CLIENT = "client"
|
|
COUNSELOR = "counselor"
|
|
EVALUATOR = "evaluator"
|
|
|
|
|
|
class Principal:
|
|
"""Authenticated human principal."""
|
|
|
|
def __init__(
|
|
self,
|
|
user_id: str,
|
|
role: Role,
|
|
admin_access: bool = False,
|
|
super_admin: bool = False,
|
|
account_status: AccountStatus = "approved",
|
|
cohort_ids: Optional[list[str]] = None,
|
|
email: str = "",
|
|
display_name: str = "",
|
|
consent_at: float | None = None,
|
|
profile_completed_at: float | None = None,
|
|
) -> None:
|
|
self.user_id = user_id
|
|
self.role = role
|
|
self.admin_access = admin_access
|
|
self.super_admin = super_admin
|
|
self.account_status = account_status
|
|
self.cohort_ids = cohort_ids or []
|
|
self.email = email
|
|
self.display_name = display_name
|
|
self.consent_at = consent_at
|
|
self.profile_completed_at = profile_completed_at
|
|
|
|
def can_access_role(self, role: Role) -> bool:
|
|
if self.role == role:
|
|
return True
|
|
if self.super_admin:
|
|
return True
|
|
if self.role == Role.ADMIN:
|
|
return True
|
|
if role == Role.ADMIN:
|
|
return self.admin_access
|
|
return False
|
|
|
|
def with_role(self, role: Role) -> "Principal":
|
|
return Principal(
|
|
user_id=self.user_id,
|
|
role=role,
|
|
admin_access=self.admin_access,
|
|
super_admin=self.super_admin,
|
|
account_status=self.account_status,
|
|
cohort_ids=list(self.cohort_ids),
|
|
email=self.email,
|
|
display_name=self.display_name,
|
|
consent_at=self.consent_at,
|
|
profile_completed_at=self.profile_completed_at,
|
|
)
|
|
|
|
|
|
def get_settings_dep() -> Settings:
|
|
return get_settings()
|
|
|
|
|
|
async def get_current_principal(
|
|
request: Request,
|
|
session_cookie: Annotated[Optional[str], Cookie(alias="__Host-vignette_sid")] = None,
|
|
dev_session_cookie: Annotated[Optional[str], Cookie(alias="vignette_sid")] = None,
|
|
) -> Principal:
|
|
"""Restore the principal from the opaque HttpOnly browser session cookie."""
|
|
raw_cookie = session_cookie or (dev_session_cookie if get_settings().environment == "dev" else None)
|
|
session = await get_session(raw_cookie)
|
|
if session is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="not authenticated",
|
|
)
|
|
|
|
try:
|
|
role = Role(session.role)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="invalid session role",
|
|
) from exc
|
|
|
|
if session.account_status != "approved" and request.url.path != "/auth/me":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=f"account_{session.account_status}",
|
|
)
|
|
|
|
return Principal(
|
|
user_id=session.user_id,
|
|
role=role,
|
|
admin_access=session.admin_access,
|
|
super_admin=session.super_admin,
|
|
account_status=session.account_status,
|
|
cohort_ids=session.cohort_ids,
|
|
email=session.email,
|
|
display_name=session.display_name,
|
|
consent_at=session.consent_at,
|
|
profile_completed_at=session.profile_completed_at,
|
|
)
|
|
|
|
|
|
def require_role(*allowed: Role):
|
|
"""Role allowlist dependency factory."""
|
|
|
|
async def _checker(
|
|
principal: Annotated[Principal, Depends(get_current_principal)],
|
|
) -> Principal:
|
|
if principal.role in allowed:
|
|
return principal
|
|
if principal.super_admin and Role.ADMIN in allowed:
|
|
return principal.with_role(Role.ADMIN)
|
|
for role in allowed:
|
|
if principal.can_access_role(role):
|
|
return principal.with_role(role)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=f"role {principal.role.value} not permitted",
|
|
)
|
|
|
|
return _checker
|
|
|
|
|
|
def require_admin_access():
|
|
"""기본 역할과 별개로 관리자 권한을 가진 사용자만 통과시킨다."""
|
|
|
|
async def _checker(
|
|
principal: Annotated[Principal, Depends(get_current_principal)],
|
|
) -> Principal:
|
|
if not principal.can_access_role(Role.ADMIN):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="admin access required",
|
|
)
|
|
return principal
|
|
|
|
return _checker
|
|
|
|
|
|
async def db_for_human(
|
|
principal: Annotated[Principal, Depends(get_current_principal)],
|
|
) -> AsyncIterator[asyncpg.Connection]:
|
|
"""Acquire a DB connection with the human RBAC context attached."""
|
|
async with acquire(
|
|
role=principal.role.value,
|
|
user_id=principal.user_id,
|
|
cohort_ids=principal.cohort_ids,
|
|
) as conn:
|
|
yield conn
|
|
|
|
|
|
def db_for_ai_view(view: AIView):
|
|
"""Dependency factory for AI-side RLS visibility context."""
|
|
|
|
async def _provider() -> AsyncIterator[asyncpg.Connection]:
|
|
async with acquire(ai_view=view.value, ai_context=True) as conn:
|
|
yield conn
|
|
|
|
return _provider
|
|
|
|
|
|
CurrentPrincipal = Annotated[Principal, Depends(get_current_principal)]
|
|
HumanDB = Annotated[asyncpg.Connection, Depends(db_for_human)]
|