대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·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:
parent
cb2aebd76c
commit
085460b5e0
327 changed files with 31226 additions and 1829 deletions
157
apps/api/app/saml.py
Normal file
157
apps/api/app/saml.py
Normal 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 ""
|
||||
Loading…
Add table
Add a link
Reference in a new issue