대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·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

157
apps/api/app/saml.py Normal file
View file

@ -0,0 +1,157 @@
"""Minimal SAML SP helpers for local fixture authentication tests.
This module intentionally implements only the Redirect-binding AuthnRequest and
unsigned fixture ACS parsing needed for backend proof. Signed production SAML
assertion verification is not implemented here.
"""
from __future__ import annotations
import base64
import html
import uuid
import zlib
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Iterable
from urllib.parse import urlsplit, urlunsplit, urlencode
from xml.etree import ElementTree
SAML_PROTOCOL_NS = "urn:oasis:names:tc:SAML:2.0:protocol"
SAML_ASSERTION_NS = "urn:oasis:names:tc:SAML:2.0:assertion"
SAML_ATTRIBUTE_ROLE_NAMES = {
"role",
"roles",
"groups",
"memberOf",
"http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
}
SAML_ATTRIBUTE_EMAIL_NAMES = {
"email",
"mail",
"emailaddress",
"EmailAddress",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
}
SAML_ATTRIBUTE_DISPLAY_NAME_NAMES = {
"display_name",
"displayName",
"name",
"cn",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
}
@dataclass(frozen=True, slots=True)
class SamlIdentity:
email: str
display_name: str
role_hint: str | None = None
def acs_url_for_entity_id(entity_id: str) -> str:
parsed = urlsplit(entity_id.strip())
if parsed.scheme and parsed.netloc:
path = parsed.path.rstrip("/")
if path.endswith("/metadata"):
path = path[: -len("/metadata")]
return urlunsplit((parsed.scheme, parsed.netloc, f"{path}/acs", "", ""))
value = entity_id.strip().rstrip("/")
if value.endswith("/metadata"):
value = value[: -len("/metadata")]
return value + "/acs"
def build_authn_request(
*,
sp_entity_id: str,
sso_url: str,
acs_url: str,
) -> tuple[str, str]:
request_id = "_" + uuid.uuid4().hex
issued_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
xml = (
f'<samlp:AuthnRequest xmlns:samlp="{SAML_PROTOCOL_NS}" '
f'xmlns:saml="{SAML_ASSERTION_NS}" ID="{request_id}" Version="2.0" '
f'IssueInstant="{issued_at}" Destination="{html.escape(sso_url, quote=True)}" '
f'AssertionConsumerServiceURL="{html.escape(acs_url, quote=True)}" '
f'ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST">'
f"<saml:Issuer>{html.escape(sp_entity_id)}</saml:Issuer>"
"</samlp:AuthnRequest>"
)
return request_id, xml
def redirect_binding_url(*, sso_url: str, authn_request_xml: str, relay_state: str) -> str:
compressor = zlib.compressobj(wbits=-15)
deflated = compressor.compress(authn_request_xml.encode("utf-8")) + compressor.flush()
params = urlencode(
{
"SAMLRequest": base64.b64encode(deflated).decode("ascii"),
"RelayState": relay_state,
}
)
separator = "&" if "?" in sso_url else "?"
return f"{sso_url}{separator}{params}"
def inflate_redirect_request(encoded_request: str) -> str:
payload = base64.b64decode(encoded_request)
return zlib.decompress(payload, wbits=-15).decode("utf-8")
def parse_fixture_response(encoded_response: str) -> SamlIdentity:
try:
xml = base64.b64decode(encoded_response).decode("utf-8")
root = ElementTree.fromstring(xml)
except Exception as exc:
raise ValueError("invalid SAMLResponse") from exc
name_id = _first_text(root, f".//{{{SAML_ASSERTION_NS}}}NameID")
attributes = _attributes(root)
email = _first_attribute(attributes, SAML_ATTRIBUTE_EMAIL_NAMES) or name_id
if not email:
raise ValueError("email claim is required")
display_name = (
_first_attribute(attributes, SAML_ATTRIBUTE_DISPLAY_NAME_NAMES)
or name_id
or email
)
role_hint = _first_attribute(attributes, SAML_ATTRIBUTE_ROLE_NAMES)
return SamlIdentity(email=email, display_name=display_name or email, role_hint=role_hint)
def _first_text(root: ElementTree.Element, selector: str) -> str:
node = root.find(selector)
return (node.text or "").strip() if node is not None else ""
def _attributes(root: ElementTree.Element) -> dict[str, list[str]]:
values: dict[str, list[str]] = {}
for attribute in root.findall(f".//{{{SAML_ASSERTION_NS}}}Attribute"):
name = (attribute.attrib.get("Name") or "").strip()
if not name:
continue
collected: list[str] = []
for value in attribute.findall(f".//{{{SAML_ASSERTION_NS}}}AttributeValue"):
text = (value.text or "").strip()
if text:
collected.append(text)
if collected:
values[name] = collected
return values
def _first_attribute(attributes: dict[str, list[str]], names: Iterable[str]) -> str:
for name in names:
values = attributes.get(name)
if values:
return values[0]
lowered = {key.lower(): value for key, value in attributes.items()}
for name in names:
values = lowered.get(name.lower())
if values:
return values[0]
return ""