refs #699: align HMM portal audit experience

This commit is contained in:
devmrko
2026-07-23 12:51:40 +09:00
parent 9e3840a4d7
commit a495e7a962
7 changed files with 393 additions and 378 deletions

View File

@@ -1,6 +1,6 @@
# HMM MCP 데모 사용자 preset (#699)
> 상태: Verified / Deployed (2026-07-22)
> 상태: Verified / Deployed (2026-07-23)
> 추적: Redmine #699 / Git 브랜치: `hmm-backoffice`
> 대상: `hmm.cloud-handson.com` / `/opt/hmm-poc4`
@@ -64,3 +64,41 @@ vpd_token_presets.json (사용자 ID·역할·팀·테스트 문맥·mcp_token_e
입사 2년차부터 차기 발생연차 50% 선사용 제한을 답변했다.
- dark color-scheme 계산값: expander 배경 `rgb(246, 248, 250)`, 보조 버튼 배경
`rgb(255, 255, 255)`, 답변 목록·expander·버튼 글자 `rgb(23, 43, 58)`
## 전 화면 브라우저 검증 기준 (2026-07-23)
다음 화면과 패널을 각각 실제 Chromium에서 열어 렌더링, 오류 alert, 핵심 조작, 색상 대비를 확인한다.
1. 로그인: 입력 필드, 로그인 유지, 실패 안내, 정상 로그인, 로그아웃
2. 아키텍처: 운영 구조와 질의 처리 흐름
3. 시나리오: 샘플 선택, 질문 입력, 저장된 답변, MCP 상세
4. 감사로그: HMM 접근 이벤트 유형·상태·기간 필터, 지표와 이벤트 표
5. 보안관리: 업무 프로세스와 `hmm-backoffice` 운영 포털 링크
6. 사이드바: 데모 사용자 5명, 대화 검색·세션, 대화 관리, MCP 고급 설정
페이지 오류, HTTP 4xx/5xx, 흰 배경의 흰 글자, 검은 배경의 검은 글자, 가로 overflow가 없어야 한다.
### HMM 감사로그 데이터 원천
- 포털 감사로그 탭은 KB PoC의 `POC_2` FGA 카탈로그를 조회하지 않는다.
- DB 접속값은 `POC4_AUDIT_DB_USER`, `POC4_AUDIT_DB_PASSWORD`, `POC4_AUDIT_DSN`으로
분리하고 Git에 값을 저장하지 않는다. ADB가 TLS 서버 인증을 허용하므로 운영 환경은 1521 TLS
접속 기술자를 사용해 Wallet PEM 암호 입력 대기를 제거한다.
- 상호 TLS가 필요한 다른 환경에서는 `POC4_AUDIT_WALLET_DIR`
`POC4_AUDIT_WALLET_PASSWORD`를 함께 지정한다. Wallet 암호 없이 단순 TNS alias만 지정하는
불완전한 설정은 시작 시 안전한 오류로 처리한다.
- 이벤트 원천은 `ADMIN.HMM_ACCESS_AUDIT`이다. 이벤트 유형, 성공/실패, 대상 key/object, 처리 행 수,
오류 코드와 메시지를 최근 순으로 표시한다.
- 비활성 Streamlit tab의 글자·배경색도 공통 presentation CSS에서 명시해 OS dark color-scheme과
무관하게 WCAG AA 수준의 대비를 유지한다.
### 2026-07-23 전수검사 결과
- 실제 Chromium에서 로그인·아키텍처·시나리오·감사로그·보안관리 5개 화면을 각각 열었다.
- 필수 문구 누락 0, 낮은 색상 대비 0, 가로 overflow 0이다.
- 브라우저 page error 0, console error 0, HTTP 오류 0이다.
- 데모 사용자 5명 선택·복원, 대화 검색·JSON 다운로드, MCP discovery cache 갱신을 확인했다.
- 감사로그 필터 2개, 새로고침, 상세 토글이 렌더링되고 `ADMIN.HMM_ACCESS_AUDIT` 0건 상태를
정상적인 empty state로 표시한다.
- 보안관리의 `https://hmm-backoffice.cloud-handson.com/` 링크는 HTTP 200이다.
- 상세 증적은 `docs/reports/2026-07-23-hmm-portal-full-browser-audit.md`에 기록한다.

View File

@@ -0,0 +1,49 @@
# HMM 포털 전 화면 브라우저 전수검사
- 실행일: 2026-07-23 (Asia/Seoul)
- 대상: `https://hmm.cloud-handson.com/`
- 브라우저: Chromium, 1440×1100, OS dark color-scheme 모의
- 추적: Redmine #699 / branch `hmm-backoffice`
## 결과
| 화면 | 핵심 검증 | 결과 |
|---|---|---|
| 로그인 | 입력 2개, 로그인 유지, 실패 피드백, 서명 로그인, 로그아웃 | PASS |
| 아키텍처 | 운영 아키텍처, 처리 흐름, AI/MCP, 데이터 보안 | PASS |
| 시나리오 | 질문 입력, 샘플, 전송, 결과 empty state | PASS |
| 감사로그 | HMM 이벤트·상태 필터, 기간·건수, 새로고침, 상세 토글 | PASS |
| 보안관리 | 업무 프로세스, 백오피스 링크 및 HTTP 200 | PASS |
| 사이드바 | 데모 사용자 5명, 전환·복원, 검색, 다운로드, MCP cache | PASS |
## 정량 결과
- 검사 화면: 5
- 필수 문구 누락: 0
- 낮은 색상 대비: 0
- 가로 overflow: 0
- 브라우저 page error: 0
- console error: 0
- HTTP 4xx/5xx: 0
시나리오의 “아직 저장된 대화가 없습니다.”와 감사로그의 “선택한 조건에 해당하는 HMM 접근 관리
이벤트가 없습니다.”는 오류가 아니라 현재 데이터 0건을 설명하는 정상 empty state다.
## 이번에 제거한 결함
1. 감사로그가 KB PoC의 `POC_2` FGA 메타데이터를 조회하던 경로를 제거했다.
2. 감사 원천을 `ADMIN.HMM_ACCESS_AUDIT`으로 교체하고 렌더러를 `src/agent_console/audit.py`
분리했다.
3. Python Thin driver가 암호화 `ewallet.pem`의 암호를 대화형으로 기다리던 연결을 ADB TLS 1521
서버 인증 풀로 교체했다.
4. Streamlit 최신 DOM의 `data-testid="stTab"`을 공통 CSS에서 직접 처리해 dark color-scheme에서
비활성 탭이 흰색으로 렌더링되던 문제를 제거했다.
## 운영 배포
- 애플리케이션: `/opt/hmm-poc4`
- 서비스: `poc4-streamlit.service` (active)
- 감사 DB 환경 파일: `/etc/hmm-poc4-audit.env` (root-only)
- systemd drop-in: `/etc/systemd/system/poc4-streamlit.service.d/20-audit.conf`
DB 사용자·암호·접속 기술자 원문은 이 보고서와 Git에 기록하지 않는다.

View File

@@ -62,6 +62,7 @@ from src.agent_console.presentation import (
render_console_header,
render_login_brand,
)
from src.agent_console.audit import render_hmm_audit_tab
from src.agent_console.profile import AppProfile, AppProfileError, load_app_profile
from src.poc4.scenarios import ScenarioConfigError, load_demo_scenarios
@@ -93,10 +94,16 @@ PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user"
PORTAL_LOGIN_FAILURE_KEY = "poc4_portal_login_failed"
PORTAL_REMEMBER_TOKEN_PARAM = "poc4_remember"
PORTAL_REMEMBER_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
AUDIT_SCHEMA = "POC_2"
AUDIT_DB_ENV_FILE = Path(
os.environ.get("POC4_AUDIT_DB_ENV_FILE", "/home/opc/kbmcp/.env")
os.environ.get("POC4_AUDIT_DB_ENV_FILE", str(ENV_FILE))
).expanduser()
DEFAULT_AUDIT_DB_DSN = (
"(description=(retry_count=3)(retry_delay=1)"
"(address=(protocol=tcps)(port=1521)(host=adb.ap-seoul-1.oraclecloud.com))"
"(connect_data=(service_name="
"yh0olybn5pqce4n_hmmaipoc_high.adb.oraclecloud.com))"
"(security=(ssl_server_dn_match=yes)))"
)
_OPAQUE_BEARER = re.compile(r"^[\x21-\x7e]{1,4096}$")
KB_THEME_CSS = """
<style>
@@ -2147,29 +2154,40 @@ def _audit_db_env_value(name: str, default: str = "") -> str:
@st.cache_resource(show_spinner=False)
def _audit_db_pool() -> Any:
password = _audit_db_env_value("ORACLE_DB_PASSWORD")
password = _audit_db_env_value("POC4_AUDIT_DB_PASSWORD")
if not password:
raise AuditLogError("감사로그 DB 접속 설정을 확인해 주세요.")
wallet_dir = Path(
_audit_db_env_value(
"ORACLE_WALLET_DIR",
"/home/opc/wallet/kbaipoc",
)
).expanduser().resolve()
if not wallet_dir.is_dir():
raise AuditLogError("감사로그 DB Wallet 경로를 확인해 주세요.")
try:
return oracledb.create_pool(
user=_audit_db_env_value("ORACLE_DB_USER", "ADMIN"),
password=password,
dsn=_audit_db_env_value("ORACLE_DSN", "kbaipoc_high"),
dsn = _audit_db_env_value("POC4_AUDIT_DSN", DEFAULT_AUDIT_DB_DSN)
wallet_password = _audit_db_env_value("POC4_AUDIT_WALLET_PASSWORD")
pool_options: dict[str, Any] = {}
if wallet_password:
wallet_dir = Path(
_audit_db_env_value(
"POC4_AUDIT_WALLET_DIR",
"/home/opc/apps/vpd-backoffice/wallet",
)
).expanduser().resolve()
if not wallet_dir.is_dir():
raise AuditLogError("감사로그 DB Wallet 경로를 확인해 주세요.")
pool_options.update(
config_dir=str(wallet_dir),
wallet_location=str(wallet_dir),
wallet_password=_audit_db_env_value("ORACLE_WALLET_PASSWORD") or None,
wallet_password=wallet_password,
)
elif not (dsn.lstrip().startswith("(") or dsn.lower().startswith("tcps://")):
raise AuditLogError(
"감사로그 DB DSN은 TLS 접속 기술자이거나 Wallet 암호와 함께 제공되어야 합니다."
)
try:
return oracledb.create_pool(
user=_audit_db_env_value("POC4_AUDIT_DB_USER", "ADMIN"),
password=password,
dsn=dsn,
min=1,
max=2,
increment=1,
getmode=oracledb.POOL_GETMODE_WAIT,
**pool_options,
)
except (oracledb.Error, OSError, ValueError):
raise AuditLogError("감사로그 DB에 연결하지 못했습니다.") from None
@@ -2200,111 +2218,59 @@ def _audit_rows(
@st.cache_data(ttl=60, show_spinner=False)
def _load_fga_inventory() -> dict[str, list[dict[str, Any]]]:
policies = _audit_rows(
def _load_hmm_audit_inventory() -> list[dict[str, Any]]:
return _audit_rows(
"""
SELECT policy.object_name,
policy.policy_name,
policy.policy_text,
LISTAGG(policy_columns.policy_column, ',') WITHIN GROUP (
ORDER BY policy_columns.policy_column
) AS policy_column,
policy.enabled,
policy.sel,
policy.ins,
policy.upd,
policy.del
FROM dba_audit_policies policy
LEFT JOIN dba_audit_policy_columns policy_columns
ON policy_columns.object_schema = policy.object_schema
AND policy_columns.object_name = policy.object_name
AND policy_columns.policy_name = policy.policy_name
WHERE policy.object_schema = :schema
GROUP BY policy.object_name,
policy.policy_name,
policy.policy_text,
policy.enabled,
policy.sel,
policy.ins,
policy.upd,
policy.del
ORDER BY policy.object_name, policy.policy_name
""",
{"schema": AUDIT_SCHEMA},
)
catalog = _audit_rows(
"""
SELECT object_name,
column_name,
policy_name,
policy_expression,
enabled_yn,
description,
updated_at
FROM POC_2.KB_SECURITY_POLICY_CATALOG
WHERE control_type = 'DBMS_FGA'
ORDER BY object_name, policy_name, column_name
SELECT event_type,
COUNT(*) AS event_count,
TO_CHAR(
MAX(created_at) AT TIME ZONE 'Asia/Seoul',
'YYYY-MM-DD HH24:MI:SS'
) AS latest_event_time
FROM ADMIN.HMM_ACCESS_AUDIT
GROUP BY event_type
ORDER BY event_type
"""
)
return {"policies": policies, "catalog": catalog}
@st.cache_data(ttl=30, show_spinner=False)
def _load_fga_audit_events(
def _load_hmm_audit_events(
days: int,
row_limit: int,
policy_name: str,
object_name: str,
event_type: str,
status: str,
) -> list[dict[str, Any]]:
return _audit_rows(
"""
SELECT *
FROM (
SELECT TO_CHAR(
audit_event.event_timestamp AT TIME ZONE 'Asia/Seoul',
SELECT audit_id,
TO_CHAR(
created_at AT TIME ZONE 'Asia/Seoul',
'YYYY-MM-DD HH24:MI:SS'
) AS event_time,
audit_event.dbusername,
audit_event.client_identifier,
audit_event.userhost,
audit_event.object_schema,
audit_event.object_name,
audit_event.action_name,
audit_event.fga_policy_name,
policy_columns.audit_column,
audit_event.return_code,
DBMS_LOB.SUBSTR(audit_event.sql_text, 1000, 1) AS sql_text
FROM unified_audit_trail audit_event
LEFT JOIN (
SELECT object_schema,
object_name,
policy_name,
LISTAGG(policy_column, ',') WITHIN GROUP (
ORDER BY policy_column
) AS audit_column
FROM dba_audit_policy_columns
WHERE object_schema = :schema
GROUP BY object_schema, object_name, policy_name
) policy_columns
ON policy_columns.object_schema = audit_event.object_schema
AND policy_columns.object_name = audit_event.object_name
AND policy_columns.policy_name = audit_event.fga_policy_name
WHERE audit_event.object_schema = :schema
AND audit_event.fga_policy_name IS NOT NULL
AND audit_event.event_timestamp >= (
SYSTIMESTAMP - NUMTODSINTERVAL(:days, 'DAY')
)
AND (:policy_name IS NULL OR audit_event.fga_policy_name = :policy_name)
AND (:object_name IS NULL OR audit_event.object_name = :object_name)
ORDER BY audit_event.event_timestamp DESC
event_type,
key_id,
object_id,
status,
row_count,
error_code,
message
FROM ADMIN.HMM_ACCESS_AUDIT
WHERE created_at >= (
SYSTIMESTAMP - NUMTODSINTERVAL(:days, 'DAY')
)
AND (:event_type IS NULL OR event_type = :event_type)
AND (:status IS NULL OR status = :status)
ORDER BY created_at DESC, audit_id DESC
)
WHERE ROWNUM <= :row_limit
""",
{
"schema": AUDIT_SCHEMA,
"days": int(days),
"policy_name": policy_name or None,
"object_name": object_name or None,
"event_type": event_type or None,
"status": status or None,
"row_limit": int(row_limit),
},
)
@@ -6116,280 +6082,6 @@ def _render_architecture_tab() -> None:
)
def _render_fga_audit_tab() -> None:
st.markdown(
'<div class="kb-section-title input" role="heading" aria-level="3">'
'감사로그 ( 오라클 <strong>FGA</strong> )'
'</div>',
unsafe_allow_html=True,
)
st.markdown(
'<div class="kb-audit-lead">'
'Oracle FGA 정책 상태와 민감 컬럼 접근 이력을 시간순으로 확인합니다. '
'조회 조건을 선택하면 정책·객체·사용자·SQL 원문을 함께 비교할 수 있습니다.'
'</div>',
unsafe_allow_html=True,
)
try:
inventory = _load_fga_inventory()
except AuditLogError as exc:
st.error(str(exc))
return
policies = inventory["policies"]
catalog = inventory["catalog"]
policy_names = sorted(
{
str(item.get("policy_name") or "").strip()
for item in (*policies, *catalog)
if str(item.get("policy_name") or "").strip()
}
)
object_names = sorted(
{
str(item.get("object_name") or "").strip()
for item in (*policies, *catalog)
if str(item.get("object_name") or "").strip()
}
)
st.markdown(
'<div class="kb-audit-heading">조회 조건</div>',
unsafe_allow_html=True,
)
with st.container(key="poc4_fga_filters"):
filter_policy, filter_object = st.columns(2)
with filter_policy:
selected_policy = st.selectbox(
"FGA 정책",
options=("", *policy_names),
format_func=lambda value: "전체 정책" if not value else value,
key="poc4_fga_policy_filter",
)
with filter_object:
selected_object = st.selectbox(
"감사 객체",
options=("", *object_names),
format_func=lambda value: "전체 객체" if not value else value,
key="poc4_fga_object_filter",
)
filter_days, filter_limit, refresh_column = st.columns([1.5, 1, 0.8])
with filter_days:
days = st.slider(
"조회 기간",
min_value=1,
max_value=90,
value=7,
format="%d",
key="poc4_fga_days",
)
with filter_limit:
row_limit = st.number_input(
"최대 건수",
min_value=10,
max_value=500,
value=100,
step=10,
key="poc4_fga_row_limit",
)
with refresh_column:
st.markdown(
'<div style="height: 28px"></div>',
unsafe_allow_html=True,
)
if st.button(
"새로고침",
icon=":material/refresh:",
width="stretch",
key="poc4_fga_refresh",
):
_load_fga_inventory.clear()
_load_fga_audit_events.clear()
st.rerun()
try:
events = _load_fga_audit_events(
int(days),
int(row_limit),
selected_policy,
selected_object,
)
except AuditLogError as exc:
st.error(str(exc))
return
success_count = sum(
1 for item in events if int(item.get("return_code") or 0) == 0
)
failure_count = len(events) - success_count
with st.container(key="poc4_fga_metrics"):
metric_policy, metric_event, metric_success, metric_failure = st.columns(4)
metric_policy.metric("등록 FGA 정책", len(policies))
metric_event.metric("조회 이벤트", len(events))
metric_success.metric("성공", success_count)
metric_failure.metric("실패", failure_count)
if not policies:
if catalog:
st.warning(
"FGA 정책 카탈로그는 존재하지만 현재 DB에 활성 정책이 등록되어 있지 않습니다."
)
else:
st.warning(
f"현재 {AUDIT_SCHEMA} 스키마에 등록된 DBMS_FGA 정책이 없습니다."
)
st.markdown(
'<div class="kb-audit-heading">정책 상태</div>'
f'<div class="kb-audit-caption">현재 활성 정책 {len(policies)}건 · '
'감사 대상 컬럼과 적용 조건을 확인합니다.</div>',
unsafe_allow_html=True,
)
with st.container(key="poc4_fga_policy_panel"):
with st.expander("FGA 정책 상태", expanded=True):
if policies:
st.dataframe(
[
{
"객체": str(item.get("object_name") or ""),
"정책": str(item.get("policy_name") or ""),
"감사 컬럼": str(
item.get("policy_column") or "전체"
),
"조건": str(item.get("policy_text") or "항상"),
"활성": str(item.get("enabled") or ""),
"SELECT": str(item.get("sel") or ""),
}
for item in policies
],
column_config={
"객체": st.column_config.TextColumn(width="medium"),
"정책": st.column_config.TextColumn(width="large"),
"감사 컬럼": st.column_config.TextColumn(width="large"),
"조건": st.column_config.TextColumn(width="large"),
"활성": st.column_config.TextColumn(width="small"),
"SELECT": st.column_config.TextColumn(width="small"),
},
hide_index=True,
width="stretch",
height=min(360, 72 + 36 * len(policies)),
)
elif catalog:
st.dataframe(
[
{
"객체": str(item.get("object_name") or ""),
"정책": str(item.get("policy_name") or ""),
"대상 컬럼": str(item.get("column_name") or "전체"),
"카탈로그 상태": str(item.get("enabled_yn") or ""),
"설명": str(item.get("description") or ""),
}
for item in catalog
],
hide_index=True,
width="stretch",
height=min(360, 72 + 36 * len(catalog)),
)
else:
st.caption("등록된 FGA 정책 정보가 없습니다.")
st.markdown(
'<div class="kb-audit-heading">감사 이벤트</div>'
'<div class="kb-audit-caption">최신 이벤트부터 표시합니다. '
'성공 여부와 감사 컬럼, 실행 사용자를 먼저 확인하세요.</div>',
unsafe_allow_html=True,
)
with st.container(key="poc4_fga_event_toolbar"):
show_sql = st.toggle(
"SQL 원문 표시",
value=True,
key="poc4_fga_show_sql",
)
if not events:
st.info("선택한 조건에 해당하는 FGA 감사 이벤트가 없습니다.")
return
display_rows: list[dict[str, Any]] = []
for event in events:
return_code = int(event.get("return_code") or 0)
actor = str(event.get("client_identifier") or "").strip()
if not actor:
actor = str(event.get("dbusername") or "")
display_row: dict[str, Any] = {
"발생시각(KST)": str(event.get("event_time") or ""),
"정책": str(event.get("fga_policy_name") or ""),
"감사 컬럼": str(event.get("audit_column") or "전체"),
"사용자": actor,
"DB 사용자": str(event.get("dbusername") or ""),
"접속 호스트": str(event.get("userhost") or ""),
"객체": (
f"{event.get('object_schema')}.{event.get('object_name')}"
),
"작업": str(event.get("action_name") or ""),
"결과": "성공" if return_code == 0 else f"ORA-{return_code:05d}",
}
if show_sql:
display_row["SQL 원문"] = " ".join(
str(event.get("sql_text") or "").split()
)
display_rows.append(display_row)
def initial_column_width(
column_name: str,
minimum: int,
maximum: int,
) -> int:
values = [column_name]
values.extend(str(row.get(column_name) or "") for row in display_rows)
text_units = max(
sum(2 if ord(character) > 127 else 1 for character in value)
for value in values
)
return max(minimum, min(maximum, 36 + text_units * 8))
event_column_config: dict[str, Any] = {
"발생시각(KST)": st.column_config.TextColumn(
width=initial_column_width("발생시각(KST)", 180, 220)
),
"정책": st.column_config.TextColumn(
width=initial_column_width("정책", 180, 320)
),
"감사 컬럼": st.column_config.TextColumn(
width=initial_column_width("감사 컬럼", 150, 300)
),
"사용자": st.column_config.TextColumn(
width=initial_column_width("사용자", 110, 180)
),
"DB 사용자": st.column_config.TextColumn(
width=initial_column_width("DB 사용자", 120, 180)
),
"접속 호스트": st.column_config.TextColumn(
width=initial_column_width("접속 호스트", 150, 240)
),
"객체": st.column_config.TextColumn(
width=initial_column_width("객체", 180, 300)
),
"작업": st.column_config.TextColumn(
width=initial_column_width("작업", 90, 140)
),
"결과": st.column_config.TextColumn(
width=initial_column_width("결과", 90, 140)
),
}
if show_sql:
event_column_config["SQL 원문"] = st.column_config.TextColumn(
width=initial_column_width("SQL 원문", 420, 720)
)
with st.container(key="poc4_fga_event_panel"):
st.dataframe(
display_rows,
column_config=event_column_config,
hide_index=True,
width="stretch",
height=min(640, 104 + 38 * len(display_rows)),
)
def _render_vpd_operations_tab() -> None:
st.markdown(
f"""
@@ -7487,7 +7179,12 @@ def main() -> None:
)
with audit_tab:
_render_fga_audit_tab()
render_hmm_audit_tab(
st,
_load_hmm_audit_inventory,
_load_hmm_audit_events,
AuditLogError,
)
with operations_tab:
_render_vpd_operations_tab()

View File

@@ -0,0 +1,2 @@
[Service]
EnvironmentFile=/etc/hmm-poc4-audit.env

View File

@@ -0,0 +1,190 @@
"""Reusable audit-tab renderer with data loaders supplied by the application."""
from __future__ import annotations
from typing import Any, Callable
AuditInventoryLoader = Callable[[], list[dict[str, Any]]]
AuditEventsLoader = Callable[[int, int, str, str], list[dict[str, Any]]]
def render_hmm_audit_tab(
st: Any,
inventory_loader: AuditInventoryLoader,
events_loader: AuditEventsLoader,
error_type: type[Exception],
) -> None:
"""Render HMM access audit data without owning DB connection details."""
st.markdown(
'<div class="kb-section-title input" role="heading" aria-level="3">'
'감사로그 ( <strong>HMM 접근 관리</strong> )'
'</div>',
unsafe_allow_html=True,
)
st.markdown(
'<div class="kb-audit-lead">'
'HMM 백오피스의 사용자·그룹·역할·토큰·접근 정책 변경 이력을 시간순으로 확인합니다. '
'이벤트 유형과 처리 상태로 필터링해 운영 변경의 성공·실패를 추적할 수 있습니다.'
'</div>',
unsafe_allow_html=True,
)
try:
inventory = inventory_loader()
except error_type as exc:
st.error(str(exc))
return
event_types = tuple(
str(item.get("event_type") or "").strip()
for item in inventory
if str(item.get("event_type") or "").strip()
)
st.markdown('<div class="kb-audit-heading">조회 조건</div>', unsafe_allow_html=True)
with st.container(key="poc4_hmm_audit_filters"):
event_column, status_column = st.columns(2)
with event_column:
selected_event_type = st.selectbox(
"이벤트 유형",
options=("", *event_types),
format_func=lambda value: "전체 이벤트" if not value else value,
key="poc4_hmm_audit_event_filter",
)
with status_column:
selected_status = st.selectbox(
"처리 상태",
options=("", "SUCCESS", "FAILURE", "DENIED"),
format_func=lambda value: "전체 상태" if not value else value,
key="poc4_hmm_audit_status_filter",
)
days_column, limit_column, refresh_column = st.columns([1.5, 1, 0.8])
with days_column:
days = st.slider(
"조회 기간",
min_value=1,
max_value=90,
value=7,
format="%d",
key="poc4_hmm_audit_days",
)
with limit_column:
row_limit = st.number_input(
"최대 건수",
min_value=10,
max_value=500,
value=100,
step=10,
key="poc4_hmm_audit_row_limit",
)
with refresh_column:
st.markdown('<div style="height: 28px"></div>', unsafe_allow_html=True)
if st.button(
"새로고침",
icon=":material/refresh:",
width="stretch",
key="poc4_hmm_audit_refresh",
):
inventory_loader.clear()
events_loader.clear()
st.rerun()
try:
events = events_loader(
int(days), int(row_limit), selected_event_type, selected_status
)
except error_type as exc:
st.error(str(exc))
return
success_count = sum(
1 for item in events if str(item.get("status") or "").upper() == "SUCCESS"
)
with st.container(key="poc4_hmm_audit_metrics"):
type_metric, event_metric, success_metric, failure_metric = st.columns(4)
type_metric.metric("이벤트 유형", len(inventory))
event_metric.metric("조회 이벤트", len(events))
success_metric.metric("성공", success_count)
failure_metric.metric("실패·거부", len(events) - success_count)
st.markdown(
'<div class="kb-audit-heading">이벤트 유형 현황</div>'
f'<div class="kb-audit-caption">현재 기록된 이벤트 유형 {len(inventory)}개 · '
'유형별 누적 건수와 최근 발생 시각을 확인합니다.</div>',
unsafe_allow_html=True,
)
with st.container(key="poc4_hmm_audit_inventory_panel"):
with st.expander("감사 이벤트 유형", expanded=True):
if inventory:
st.dataframe(
[
{
"이벤트 유형": str(item.get("event_type") or ""),
"누적 건수": int(item.get("event_count") or 0),
"최근 발생(KST)": str(item.get("latest_event_time") or ""),
}
for item in inventory
],
column_config={
"이벤트 유형": st.column_config.TextColumn(width="large"),
"누적 건수": st.column_config.NumberColumn(width="small"),
"최근 발생(KST)": st.column_config.TextColumn(width="medium"),
},
hide_index=True,
width="stretch",
height=min(360, 72 + 36 * len(inventory)),
)
else:
st.caption("아직 기록된 HMM 접근 관리 이벤트가 없습니다.")
st.markdown(
'<div class="kb-audit-heading">감사 이벤트</div>'
'<div class="kb-audit-caption">최신 이벤트부터 표시합니다. '
'처리 상태와 대상 식별자, 오류 메시지를 먼저 확인하세요.</div>',
unsafe_allow_html=True,
)
show_details = st.toggle(
"상세 메시지 표시",
value=True,
key="poc4_hmm_audit_show_details",
)
if not events:
st.info("선택한 조건에 해당하는 HMM 접근 관리 이벤트가 없습니다.")
return
display_rows: list[dict[str, Any]] = []
for event in events:
row: dict[str, Any] = {
"감사 ID": int(event.get("audit_id") or 0),
"발생시각(KST)": str(event.get("event_time") or ""),
"이벤트 유형": str(event.get("event_type") or ""),
"상태": str(event.get("status") or ""),
"토큰 Key ID": event.get("key_id"),
"대상 Object ID": event.get("object_id"),
"처리 행": event.get("row_count"),
"오류 코드": str(event.get("error_code") or ""),
}
if show_details:
row["메시지"] = str(event.get("message") or "")
display_rows.append(row)
column_config: dict[str, Any] = {
"감사 ID": st.column_config.NumberColumn(width="small"),
"발생시각(KST)": st.column_config.TextColumn(width="medium"),
"이벤트 유형": st.column_config.TextColumn(width="large"),
"상태": st.column_config.TextColumn(width="small"),
"토큰 Key ID": st.column_config.NumberColumn(width="small"),
"대상 Object ID": st.column_config.NumberColumn(width="small"),
"처리 행": st.column_config.NumberColumn(width="small"),
"오류 코드": st.column_config.TextColumn(width="medium"),
}
if show_details:
column_config["메시지"] = st.column_config.TextColumn(width="large")
with st.container(key="poc4_hmm_audit_event_panel"):
st.dataframe(
display_rows,
column_config=column_config,
hide_index=True,
width="stretch",
height=min(640, 104 + 38 * len(display_rows)),
)

View File

@@ -67,6 +67,26 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None:
[data-testid="stExpander"] summary * {{
color:var(--console-text) !important;
-webkit-text-fill-color:var(--console-text) !important; }}
[data-baseweb="tab-list"], [data-testid="stTabs"] [role="tablist"] {{
border-bottom:1px solid var(--console-border) !important; }}
[data-baseweb="tab-list"] [role="tab"], [data-testid="stTab"] {{
background:#fff !important; color:var(--console-text) !important;
-webkit-text-fill-color:var(--console-text) !important;
border-radius:4px 4px 0 0 !important; }}
[data-baseweb="tab-list"] [role="tab"] *, [data-testid="stTab"] * {{
color:var(--console-text) !important;
-webkit-text-fill-color:var(--console-text) !important; }}
[data-baseweb="tab-list"] [role="tab"]:hover, [data-testid="stTab"]:hover {{
background:#f6f8fa !important; }}
[data-baseweb="tab-list"] [role="tab"][aria-selected="true"],
[data-testid="stTab"][aria-selected="true"] {{
color:var(--console-primary) !important;
-webkit-text-fill-color:var(--console-primary) !important;
border-bottom:3px solid var(--console-primary) !important; }}
[data-baseweb="tab-list"] [role="tab"][aria-selected="true"] *,
[data-testid="stTab"][aria-selected="true"] * {{
color:var(--console-primary) !important;
-webkit-text-fill-color:var(--console-primary) !important; }}
.console-header {{ margin:0 0 28px; padding:0 0 22px; border-bottom:1px solid var(--console-border); }}
.console-wordmark {{ color:var(--console-primary); font-size:1.35rem; font-weight:800; letter-spacing:.08em; }}
.console-header h1 {{ margin:10px 0 8px; font-size:1.7rem; }}

View File

@@ -60,6 +60,25 @@ class DemoScenarioConfigTest(unittest.TestCase):
self.assertIn('[data-testid="stAppViewContainer"] li', source)
self.assertIn('[data-testid="stExpander"] summary', source)
self.assertIn('div[data-testid="stButton"] > button', source)
self.assertIn('[data-baseweb="tab-list"] [role="tab"]', source)
self.assertIn('[data-testid="stTab"]', source)
self.assertIn('[role="tab"][aria-selected="true"]', source)
def test_audit_tab_uses_hmm_access_audit_loaders(self) -> None:
root = Path(__file__).parents[1]
entrypoint = (root / "apps" / "poc4" / "mcp_discovery_ui.py").read_text(
encoding="utf-8"
)
renderer = (root / "src" / "agent_console" / "audit.py").read_text(
encoding="utf-8"
)
self.assertIn("FROM ADMIN.HMM_ACCESS_AUDIT", entrypoint)
self.assertIn("_load_hmm_audit_inventory", entrypoint)
self.assertIn("(protocol=tcps)(port=1521)", entrypoint)
self.assertIn("POC4_AUDIT_WALLET_PASSWORD", entrypoint)
self.assertIn("HMM 접근 관리", renderer)
self.assertNotIn('AUDIT_SCHEMA = "POC_2"', entrypoint)
def test_hmm_scenarios_are_enabled_and_unique(self) -> None:
path = Path(__file__).parents[1] / "config" / "hmm_demo_scenarios.json"