refs #699: align HMM portal audit experience
This commit is contained in:
@@ -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`에 기록한다.
|
||||
|
||||
49
docs/reports/2026-07-23-hmm-portal-full-browser-audit.md
Normal file
49
docs/reports/2026-07-23-hmm-portal-full-browser-audit.md
Normal 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에 기록하지 않는다.
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[Service]
|
||||
EnvironmentFile=/etc/hmm-poc4-audit.env
|
||||
190
poc4_active_source_20260714/src/agent_console/audit.py
Normal file
190
poc4_active_source_20260714/src/agent_console/audit.py
Normal 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)),
|
||||
)
|
||||
@@ -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; }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user