vignette/apps/api/app/deps.py
Yun Chan 4e6b0045e3 관리자 워크스페이스 접근 확장과 소유자 결정 7건 확정 반영
- 관리자(role=admin)가 학습자·교수자·관리자 워크스페이스를 모두 접근하도록
  can_access_role/require_role와 프론트 auth 헬퍼·Sidebar 내비를 정리하고
  admin 워크스페이스 내비 E2E를 추가.
- 소유자 결정 7건 전건 확정(2026-06-30)을 SSOT 대시보드·백로그에 반영하고
  결정 필요 7→0으로 동기화. SSOT drift 게이트 기대 카운트도 갱신.
- 확정된 H1 평가설계(κ≥0.70·ICC≥0.75·환각률≤0.03·t-검정 α=0.05·무작위 배정)를
  approved-export κ 게이트(checker·dataset_export·recursive export)와
  KPI 측정계획·export manifest 문서에 반영.

검증: npm run typecheck, npm run check:api-types, 백엔드 pytest 290 passed,
admin 내비 E2E 1 passed, 레이아웃 시각게이트 9/9, session-layout 4 passed,
SSOT drift 게이트 PASS.
2026-06-30 10:57:46 +09:00

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.admin_access:
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)]