대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정

SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리

페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침

버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)

검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
This commit is contained in:
Yun Chan 2026-06-27 02:30:46 +09:00
parent cb2aebd76c
commit 085460b5e0
327 changed files with 31226 additions and 1829 deletions

View file

@ -25,6 +25,13 @@ from pydantic import BaseModel
from ..auth_sessions import InactiveUserError, SessionUser, create_session, revoke_session
from ..config import settings
from ..deps import CurrentPrincipal, Principal, Role
from ..saml import (
SamlIdentity,
acs_url_for_entity_id,
build_authn_request,
parse_fixture_response,
redirect_binding_url,
)
router = APIRouter(prefix="/auth", tags=["auth"])
@ -41,7 +48,15 @@ class OAuthState:
created_at: float
@dataclass(slots=True)
class SamlState:
request_id: str
next_path: str
created_at: float
_oauth_states: dict[str, OAuthState] = {}
_saml_states: dict[str, SamlState] = {}
class MeResponse(BaseModel):
@ -52,8 +67,17 @@ class MeResponse(BaseModel):
cohort_ids: list[str]
class AuthProviderStatus(BaseModel):
provider: Literal["google", "saml"]
configured: bool
enabled: bool
login_path: str
class AuthConfigResponse(BaseModel):
google_oauth_configured: bool
saml_configured: bool
providers: list[AuthProviderStatus]
allowed_email_domains: list[str]
redirect_uri: str
dev_login_enabled: bool
@ -84,6 +108,37 @@ def _normalize_email_set(values: list[str]) -> set[str]:
return {email for value in values if (email := _normalize_email(value))}
def _google_configured() -> bool:
return bool(settings.oauth_google_client_id and settings.oauth_google_client_secret)
def _saml_configured() -> bool:
return bool(
settings.auth_saml_enabled
and settings.saml_sp_entity_id.strip()
and settings.saml_sso_url.strip()
)
def _auth_provider_statuses() -> list[AuthProviderStatus]:
google_ready = _google_configured()
saml_ready = _saml_configured()
return [
AuthProviderStatus(
provider="google",
configured=google_ready,
enabled=google_ready,
login_path="/auth/login?provider=google",
),
AuthProviderStatus(
provider="saml",
configured=saml_ready,
enabled=saml_ready,
login_path="/auth/login?provider=saml",
),
]
def allowed_email_domains() -> set[str]:
"""Configured login email domains, normalized for claim checks."""
return {
@ -137,6 +192,15 @@ def _role_for_email(email: str) -> Role:
return Role.LEARNER
def _role_for_saml_identity(identity: SamlIdentity) -> Role:
hinted = (identity.role_hint or "").strip().lower()
if hinted in {"admin", "administrator"}:
return Role.ADMIN
if hinted in {"teacher", "instructor", "faculty"}:
return Role.TEACHER
return _role_for_email(identity.email)
def _safe_next_path(next_path: str | None) -> str:
if not next_path or not next_path.startswith("/") or next_path.startswith("//"):
return "/"
@ -211,6 +275,13 @@ def _prune_oauth_states() -> None:
_oauth_states.pop(key, None)
def _prune_saml_states() -> None:
cutoff = time.time() - OAUTH_STATE_TTL_SECONDS
stale = [key for key, value in _saml_states.items() if value.created_at < cutoff]
for key in stale:
_saml_states.pop(key, None)
def _cookie_secure() -> bool:
# The __Host- prefix requires Secure, Path=/, and no Domain. Modern Chrome
# accepts Secure cookies on localhost, which keeps dev and prod semantics
@ -291,10 +362,12 @@ def _dev_login_available(request: Request) -> bool:
@router.get("/config", response_model=AuthConfigResponse)
async def auth_config(request: Request) -> AuthConfigResponse:
"""Return non-secret login configuration for the browser login screen."""
google_ready = _google_configured()
saml_ready = _saml_configured()
return AuthConfigResponse(
google_oauth_configured=bool(
settings.oauth_google_client_id and settings.oauth_google_client_secret
),
google_oauth_configured=google_ready,
saml_configured=saml_ready,
providers=_auth_provider_statuses(),
allowed_email_domains=sorted(allowed_email_domains()),
redirect_uri=settings.oauth_redirect_uri,
dev_login_enabled=_dev_login_available(request),
@ -308,9 +381,33 @@ async def login(
next: Annotated[str | None, Query()] = None,
) -> RedirectResponse:
"""Start Google OIDC authorization code + PKCE login."""
if provider == "saml":
if not _saml_configured():
return _frontend_login_redirect("saml_not_configured", request)
_prune_saml_states()
relay_state = secrets.token_urlsafe(32)
acs_url = acs_url_for_entity_id(settings.saml_sp_entity_id)
request_id, authn_request_xml = build_authn_request(
sp_entity_id=settings.saml_sp_entity_id,
sso_url=settings.saml_sso_url,
acs_url=acs_url,
)
_saml_states[relay_state] = SamlState(
request_id=request_id,
next_path=_safe_next_path(next),
created_at=time.time(),
)
return RedirectResponse(
redirect_binding_url(
sso_url=settings.saml_sso_url,
authn_request_xml=authn_request_xml,
relay_state=relay_state,
),
status_code=302,
)
if provider != "google":
return _frontend_login_redirect("unsupported_provider", request)
if not settings.oauth_google_client_id or not settings.oauth_google_client_secret:
if not _google_configured():
return _frontend_login_redirect("not_configured", request)
_prune_oauth_states()
@ -406,6 +503,58 @@ async def callback(
return response
@router.post("/saml/acs")
async def saml_acs(request: Request) -> RedirectResponse:
"""Accept a minimal unsigned SAMLResponse for local fixture SAML proof.
Signed SAML verification is intentionally not implemented. When
SAML_X509_CERT_FINGERPRINT is configured, this endpoint refuses to trust the
response so production does not silently run unsigned SAML.
"""
if not _saml_configured():
return _frontend_login_redirect("saml_not_configured", request)
if settings.saml_x509_cert_fingerprint.strip():
return _frontend_login_redirect("saml_signature_verification_required", request)
if settings.environment != "dev":
return _frontend_login_redirect("saml_fixture_acs_dev_only", request)
form = await request.form()
relay_state = str(form.get("RelayState") or "")
encoded_response = str(form.get("SAMLResponse") or "")
if not relay_state or not encoded_response:
return _frontend_login_redirect("saml_missing_callback", request)
_prune_saml_states()
stored = _saml_states.pop(relay_state, None)
if stored is None:
return _frontend_login_redirect("saml_invalid_state", request)
try:
identity = parse_fixture_response(encoded_response)
email = validate_google_identity_domain(
email=identity.email,
email_verified=True,
hosted_domain=_email_domain(identity.email),
)
except (HTTPException, ValueError):
return _frontend_login_redirect("saml_assertion_invalid", request)
role = _role_for_saml_identity(identity)
try:
sid, _ = await create_session(
email=email,
display_name=identity.display_name or email,
role=role.value,
cohort_ids=[],
)
except InactiveUserError:
return _frontend_login_redirect("inactive_user", request)
response = RedirectResponse(_frontend_url(stored.next_path, request), status_code=302)
_set_session_cookie(response, sid)
return response
@router.post("/dev-login", response_model=MeResponse)
async def dev_login(request: Request, body: DevLoginRequest, response: Response) -> MeResponse:
"""Dev-only server login for local E2E and manual testing.