Compare commits
15 Commits
hmm-backof
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14ac162a85 | ||
|
|
8817b24043 | ||
|
|
750bfbab5b | ||
|
|
22c0b571e2 | ||
|
|
20c6a82338 | ||
|
|
b3f2422b63 | ||
|
|
1538aadb14 | ||
|
|
404244534a | ||
|
|
ea4fc1de8b | ||
|
|
0eb68664f1 | ||
|
|
4eec33b2ed | ||
|
|
b4ed8649f1 | ||
|
|
f7295277f1 | ||
|
|
075eb46ef8 | ||
|
|
2b66e27bfb |
@@ -73,7 +73,8 @@ export BACKOFFICE_PRODUCT_NAME="Data & AI Backoffice"
|
|||||||
export BACKOFFICE_PRODUCT_TITLE="Data & AI Backoffice"
|
export BACKOFFICE_PRODUCT_TITLE="Data & AI Backoffice"
|
||||||
export BACKOFFICE_PRODUCT_DATA_LABEL="업무 데이터"
|
export BACKOFFICE_PRODUCT_DATA_LABEL="업무 데이터"
|
||||||
|
|
||||||
# 단일 Select AI 도구 호환 설정. 여러 Agent Tool을 쓸 때는 BACKOFFICE_MCP_TOOLS가 우선합니다.
|
# 단일 Select AI 도구 호환 설정. 여러 Tool을 쓸 때는 BACKOFFICE_MCP_TOOLS가 우선합니다.
|
||||||
|
# AGENT_TOOL targetName은 서버 시작 시 USER_AI_AGENT_TOOLS의 ENABLED 상태를 검증합니다.
|
||||||
export BACKOFFICE_MCP_PUBLIC_URL="https://example.com/mcp"
|
export BACKOFFICE_MCP_PUBLIC_URL="https://example.com/mcp"
|
||||||
export BACKOFFICE_MCP_SERVER_NAME="data-ai-backoffice"
|
export BACKOFFICE_MCP_SERVER_NAME="data-ai-backoffice"
|
||||||
export BACKOFFICE_MCP_TOOL_NAME="oracle.select_ai.data_text2sql"
|
export BACKOFFICE_MCP_TOOL_NAME="oracle.select_ai.data_text2sql"
|
||||||
|
|||||||
@@ -130,6 +130,69 @@ begin
|
|||||||
)');
|
)');
|
||||||
create_if_missing('create sequence sg_permission_seq start with 1 increment by 1 nocache');
|
create_if_missing('create sequence sg_permission_seq start with 1 increment by 1 nocache');
|
||||||
create_if_missing('create sequence sg_permission_rule_seq start with 1 increment by 1 nocache');
|
create_if_missing('create sequence sg_permission_rule_seq start with 1 increment by 1 nocache');
|
||||||
|
create_if_missing('create table sg_vpd_policy_note (
|
||||||
|
object_owner varchar2(128) not null,
|
||||||
|
object_name varchar2(128) not null,
|
||||||
|
policy_name varchar2(128) not null,
|
||||||
|
description varchar2(2000),
|
||||||
|
updated_at timestamp default systimestamp not null,
|
||||||
|
constraint sg_vpd_policy_note_pk primary key (object_owner, object_name, policy_name)
|
||||||
|
)');
|
||||||
|
create_if_missing('create table sg_vpd_filter_note (
|
||||||
|
function_owner varchar2(128) not null,
|
||||||
|
function_name varchar2(128) not null,
|
||||||
|
description varchar2(2000),
|
||||||
|
updated_at timestamp default systimestamp not null,
|
||||||
|
constraint sg_vpd_filter_note_pk primary key (function_owner, function_name)
|
||||||
|
)');
|
||||||
|
create_if_missing('create table sg_masking_rule (
|
||||||
|
rule_id number primary key,
|
||||||
|
rule_code varchar2(100) not null unique,
|
||||||
|
rule_name varchar2(200) not null,
|
||||||
|
template_code varchar2(100) not null,
|
||||||
|
description varchar2(2000),
|
||||||
|
enabled_yn char(1) default ''Y'' not null,
|
||||||
|
created_at timestamp default systimestamp not null,
|
||||||
|
updated_at timestamp default systimestamp not null,
|
||||||
|
constraint sg_masking_rule_enabled_ck check (enabled_yn in (''Y'', ''N''))
|
||||||
|
)');
|
||||||
|
create_if_missing('create table sg_access_bearer_token (
|
||||||
|
key_id number primary key, user_id number not null, key_prefix varchar2(100) not null,
|
||||||
|
key_hash varchar2(256) not null unique, expires_at timestamp not null, revoked_at timestamp,
|
||||||
|
description varchar2(500), created_at timestamp default systimestamp not null,
|
||||||
|
constraint sg_access_bearer_token_user_fk foreign key (user_id) references sg_app_user(user_id)
|
||||||
|
)');
|
||||||
|
create_if_missing('create table sg_backoffice_setting (
|
||||||
|
setting_key varchar2(200) primary key, setting_value varchar2(4000),
|
||||||
|
updated_at timestamp default systimestamp not null
|
||||||
|
)');
|
||||||
|
create_if_missing('create table sg_vector_document_chunk (
|
||||||
|
chunk_id number primary key, document_id varchar2(200) not null, chunk_no number not null,
|
||||||
|
content clob not null, embedding vector(1536, float32), created_at timestamp default systimestamp not null
|
||||||
|
)');
|
||||||
|
create_if_missing('create table sg_vector_document_tag (
|
||||||
|
chunk_id number not null, tech_tag varchar2(200) not null,
|
||||||
|
constraint sg_vector_document_tag_pk primary key (chunk_id, tech_tag),
|
||||||
|
constraint sg_vector_document_tag_chunk_fk foreign key (chunk_id) references sg_vector_document_chunk(chunk_id)
|
||||||
|
)');
|
||||||
|
create_if_missing('create sequence sg_vector_chunk_seq start with 1 increment by 1 nocache');
|
||||||
|
create_if_missing('create table sg_column_masking_rule (
|
||||||
|
column_id number primary key,
|
||||||
|
rule_id number not null,
|
||||||
|
updated_at timestamp default systimestamp not null,
|
||||||
|
constraint sg_column_masking_rule_column_fk foreign key (column_id) references sg_protected_column(column_id),
|
||||||
|
constraint sg_column_masking_rule_rule_fk foreign key (rule_id) references sg_masking_rule(rule_id)
|
||||||
|
)');
|
||||||
|
create_if_missing('create table sg_user_masking_rule (
|
||||||
|
user_id number not null,
|
||||||
|
column_id number not null,
|
||||||
|
decision varchar2(30) not null,
|
||||||
|
active_yn char(1) default ''Y'' not null,
|
||||||
|
updated_at timestamp default systimestamp not null,
|
||||||
|
constraint sg_user_masking_rule_pk primary key (user_id, column_id),
|
||||||
|
constraint sg_user_masking_rule_user_fk foreign key (user_id) references sg_app_user(user_id),
|
||||||
|
constraint sg_user_masking_rule_column_fk foreign key (column_id) references sg_protected_column(column_id)
|
||||||
|
)');
|
||||||
end;
|
end;
|
||||||
/
|
/
|
||||||
|
|
||||||
@@ -168,3 +231,99 @@ merge into sg_group_role t using (select 2001 group_id, 3002 role_id from dual)
|
|||||||
on (t.group_id=s.group_id and t.role_id=s.role_id) when not matched then insert (group_id, role_id) values (s.group_id, s.role_id);
|
on (t.group_id=s.group_id and t.role_id=s.role_id) when not matched then insert (group_id, role_id) values (s.group_id, s.role_id);
|
||||||
|
|
||||||
commit;
|
commit;
|
||||||
|
|
||||||
|
-- Compatibility layer for remaining backoffice modules. The data is stored
|
||||||
|
-- only in SG_* tables; these views prevent older controller paths from
|
||||||
|
-- querying non-existent CB_* physical tables during the Smilegate transition.
|
||||||
|
create or replace view cb_app_user as
|
||||||
|
select user_id, user_name, employee_no, dept_code, can_read_contents, active from sg_app_user;
|
||||||
|
create or replace view cb_app_group as
|
||||||
|
select group_id, group_code, group_name, description, active_yn from sg_app_group;
|
||||||
|
create or replace view cb_app_role as
|
||||||
|
select role_id, role_name, max_sensitivity_level from sg_app_role;
|
||||||
|
create or replace view cb_user_role as select user_id, role_id from sg_user_role;
|
||||||
|
create or replace view cb_user_group as select group_id, user_id from sg_user_group;
|
||||||
|
create or replace view cb_group_role as select group_id, role_id from sg_group_role;
|
||||||
|
create or replace view cb_protected_object as
|
||||||
|
select object_id, owner, object_name, ords_path, enabled_yn, description from sg_protected_object;
|
||||||
|
create or replace view cb_protected_column as
|
||||||
|
select column_id, object_id, column_name, sensitive_yn, visible_role_id, sensitivity_level, redaction_method from sg_protected_column;
|
||||||
|
create or replace view cb_permission as
|
||||||
|
select perm_id, role_id, target_name, action_name, permission_effect from sg_permission;
|
||||||
|
create or replace view cb_permission_rule as
|
||||||
|
select rule_id, perm_id, rule_column, rule_type, rule_value from sg_permission_rule;
|
||||||
|
create or replace view cb_permission_column as
|
||||||
|
select permission_id, column_name from sg_permission_column;
|
||||||
|
create or replace view cb_vpd_policy_note as
|
||||||
|
select object_owner, object_name, policy_name, description, updated_at from sg_vpd_policy_note;
|
||||||
|
create or replace view cb_vpd_filter_note as
|
||||||
|
select function_owner, function_name, description, updated_at from sg_vpd_filter_note;
|
||||||
|
create or replace view cb_masking_rule as
|
||||||
|
select rule_id, rule_code, rule_name, template_code, description, enabled_yn from sg_masking_rule;
|
||||||
|
create or replace view cb_column_masking_rule as
|
||||||
|
select column_id, rule_id, updated_at from sg_column_masking_rule;
|
||||||
|
create or replace view cb_user_masking_rule as
|
||||||
|
select user_id, column_id, decision, active_yn, updated_at from sg_user_masking_rule;
|
||||||
|
create or replace view cb_ords_probe_audit as
|
||||||
|
select audit_id, event_type, key_id, object_id, status, row_count, error_code, message, created_at from sg_audit_event;
|
||||||
|
create or replace view cb_backoffice_setting as
|
||||||
|
select setting_key, setting_value, updated_at from sg_backoffice_setting;
|
||||||
|
|
||||||
|
-- PoC administrator access: both demo operators can manage and query every
|
||||||
|
-- Smilegate game-data object registered in SGMP_POC.
|
||||||
|
merge into sg_app_role t
|
||||||
|
using (select 3099 role_id, 'DATA_AI_POC_ADMIN' role_name,
|
||||||
|
'Full access to all Smilegate PoC game-data objects' description,
|
||||||
|
'RESTRICTED' max_sensitivity_level from dual) s
|
||||||
|
on (t.role_id = s.role_id)
|
||||||
|
when matched then update set t.role_name=s.role_name, t.description=s.description,
|
||||||
|
t.max_sensitivity_level=s.max_sensitivity_level, t.updated_at=systimestamp
|
||||||
|
when not matched then insert (role_id, role_name, description, max_sensitivity_level)
|
||||||
|
values (s.role_id, s.role_name, s.description, s.max_sensitivity_level);
|
||||||
|
|
||||||
|
merge into sg_user_role t
|
||||||
|
using (select 1001 user_id, 3099 role_id from dual union all select 1002, 3099 from dual) s
|
||||||
|
on (t.user_id=s.user_id and t.role_id=s.role_id)
|
||||||
|
when not matched then insert (user_id, role_id) values (s.user_id, s.role_id);
|
||||||
|
|
||||||
|
declare
|
||||||
|
l_object_id number;
|
||||||
|
l_permission_id number;
|
||||||
|
begin
|
||||||
|
for source_object in (
|
||||||
|
select table_name
|
||||||
|
from all_tables
|
||||||
|
where owner = 'SGMP_POC'
|
||||||
|
and table_name not in ('SEMANTIC_METADATA_CHANGE_LOG', 'SGMP_TERM_CONTEXT_CACHE',
|
||||||
|
'SGMP_TERM_DICTIONARY', 'SGMP_TERM_SEARCH_LOG', 'SGMP_TERM_SYNONYM')
|
||||||
|
order by table_name
|
||||||
|
) loop
|
||||||
|
begin
|
||||||
|
select object_id into l_object_id
|
||||||
|
from sg_protected_object
|
||||||
|
where owner = 'SGMP_POC' and object_name = source_object.table_name;
|
||||||
|
exception
|
||||||
|
when no_data_found then
|
||||||
|
select nvl(max(object_id), 0) + 1 into l_object_id from sg_protected_object;
|
||||||
|
insert into sg_protected_object (object_id, owner, object_name, ords_path, enabled_yn, description)
|
||||||
|
values (l_object_id, 'SGMP_POC', source_object.table_name,
|
||||||
|
'/sgmp-poc/' || lower(source_object.table_name), 'Y',
|
||||||
|
'Smilegate PoC game-data object');
|
||||||
|
end;
|
||||||
|
|
||||||
|
begin
|
||||||
|
select perm_id into l_permission_id
|
||||||
|
from sg_permission
|
||||||
|
where role_id = 3099 and target_name = source_object.table_name;
|
||||||
|
exception
|
||||||
|
when no_data_found then
|
||||||
|
l_permission_id := sg_permission_seq.nextval;
|
||||||
|
insert into sg_permission (perm_id, role_id, target_name, action_name, permission_effect)
|
||||||
|
values (l_permission_id, 3099, source_object.table_name, 'SELECT', 'ALLOW');
|
||||||
|
insert into sg_permission_rule (rule_id, perm_id, rule_column, rule_type, rule_value)
|
||||||
|
values (sg_permission_rule_seq.nextval, l_permission_id, null, 'ALL', null);
|
||||||
|
end;
|
||||||
|
end loop;
|
||||||
|
commit;
|
||||||
|
end;
|
||||||
|
/
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ BACKOFFICE_PRODUCT_DATA_LABEL='HMM HR 데이터'
|
|||||||
|
|
||||||
BACKOFFICE_MCP_PUBLIC_URL='https://hmm-backoffice.cloud-handson.com/mcp'
|
BACKOFFICE_MCP_PUBLIC_URL='https://hmm-backoffice.cloud-handson.com/mcp'
|
||||||
BACKOFFICE_MCP_SERVER_NAME='hmm-hr-backoffice'
|
BACKOFFICE_MCP_SERVER_NAME='hmm-hr-backoffice'
|
||||||
BACKOFFICE_MCP_TOOLS='[{"name":"resolve_hr_term","label":"HMM HR 용어 표준화","description":"휴가·근태 표현을 HMM 표준 용어와 코드로 변환합니다. 모호한 표현은 데이터 조회 전에 이 도구를 사용합니다.","argumentName":"term","argumentDescription":"확인할 휴가·근태 용어, 동의어 또는 코드입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_TERM_RESOLVER","targetParameterName":"P_TERM"},{"name":"search_hr_data","label":"HMM HR 데이터 조회","description":"조직, 직원, 휴가 잔여·신청, 근태 데이터를 읽기 전용 Select AI로 조회합니다.","argumentName":"query","argumentDescription":"조직, 직원, 휴가 또는 근태에 대한 완전한 자연어 질문입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_NORMALIZED_DATA_SEARCH","targetParameterName":"P_QUERY"},{"name":"search_hr_policy","label":"HMM HR 규정 검색","description":"HR 규정 PDF의 문서 메타데이터, Abstract, 관련 청크를 계층형 벡터 검색으로 조회합니다.","argumentName":"query","argumentDescription":"HR 규정에 대한 완전한 자연어 질문입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_POLICY_SEARCH","targetParameterName":"P_QUERY"}]'
|
# AGENT_TOOL targetName must exist with STATUS=ENABLED in USER_AI_AGENT_TOOLS.
|
||||||
|
# The application fails startup before advertising an invalid configured contract.
|
||||||
|
BACKOFFICE_MCP_TOOLS='[{"name":"resolve_hr_term","label":"HMM HR 용어 표준화","description":"휴가·근태 표현을 HMM 표준 용어와 코드로 변환합니다. 모호한 표현은 데이터 조회 전에 이 도구를 사용합니다.","argumentName":"term","argumentDescription":"확인할 휴가·근태 용어, 동의어 또는 코드입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_TERM_RESOLVER","targetParameterName":"P_TERM"},{"name":"search_hr_data","label":"HMM HR 데이터 조회","description":"조직, 직원, 휴가 잔여·신청, 근태 데이터를 읽기 전용 Select AI로 조회합니다.","argumentName":"query","argumentDescription":"조직, 직원, 휴가 또는 근태에 대한 완전한 자연어 질문입니다.","executionType":"SELECT_AI"},{"name":"search_hr_policy","label":"HMM HR 규정 검색","description":"HR 규정 PDF의 문서 메타데이터, Abstract, 관련 청크를 계층형 벡터 검색으로 조회합니다.","argumentName":"query","argumentDescription":"HR 규정에 대한 완전한 자연어 질문입니다.","executionType":"AGENT_TOOL","targetName":"HMM_HR_POLICY_SEARCH","targetParameterName":"P_QUERY"}]'
|
||||||
|
|
||||||
BACKOFFICE_MASKING_POLICIES='[{"objectName":"HMM_HR_EMPLOYEES","policyName":"HMM_EMPLOYEE_PII_REDACT"},{"objectName":"HMM_LEAVE_BALANCES","policyName":"HMM_LEAVE_BALANCE_REDACT"},{"objectName":"HMM_LEAVE_REQUESTS","policyName":"HMM_LEAVE_REQUEST_REDACT"},{"objectName":"HMM_ATTENDANCE_DAILY","policyName":"HMM_ATTENDANCE_REDACT"}]'
|
BACKOFFICE_MASKING_POLICIES='[{"objectName":"HMM_HR_EMPLOYEES","policyName":"HMM_EMPLOYEE_PII_REDACT"},{"objectName":"HMM_LEAVE_BALANCES","policyName":"HMM_LEAVE_BALANCE_REDACT"},{"objectName":"HMM_LEAVE_REQUESTS","policyName":"HMM_LEAVE_REQUEST_REDACT"},{"objectName":"HMM_ATTENDANCE_DAILY","policyName":"HMM_ATTENDANCE_REDACT"}]'
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ docs/
|
|||||||
adr/ ← Architecture Decision Records: 가로지르는 결정 기록
|
adr/ ← Architecture Decision Records: 가로지르는 결정 기록
|
||||||
_TEMPLATE.md
|
_TEMPLATE.md
|
||||||
NNNN-<title>.md
|
NNNN-<title>.md
|
||||||
|
architecture/ ← 현행 시스템 아키텍처 Draw.io 원본과 검토용 SVG/PNG
|
||||||
reference/ ← 레퍼런스: 구현된 모듈/함수/설정 사양 (구현 "후" 동기화)
|
reference/ ← 레퍼런스: 구현된 모듈/함수/설정 사양 (구현 "후" 동기화)
|
||||||
guides/ ← How-to / 사용 가이드 / 튜토리얼 (사용자·운영자 대상)
|
guides/ ← How-to / 사용 가이드 / 튜토리얼 (사용자·운영자 대상)
|
||||||
pipeline/ ← 개발 프로세스 문서 (큐 프로토콜·런북)
|
pipeline/ ← 개발 프로세스 문서 (큐 프로토콜·런북)
|
||||||
@@ -61,3 +62,11 @@ docs/
|
|||||||
`Draft`(작성) → `Approved`(QA/Reviewer 통과 후) → `Superseded`(대체 시 상단 표기, 삭제 금지).
|
`Draft`(작성) → `Approved`(QA/Reviewer 통과 후) → `Superseded`(대체 시 상단 표기, 삭제 금지).
|
||||||
구현이 설계서와 달라지면 **코드가 아니라 설계서를 먼저 고치고** 다시 구현한다.
|
구현이 설계서와 달라지면 **코드가 아니라 설계서를 먼저 고치고** 다시 구현한다.
|
||||||
```
|
```
|
||||||
|
# 문서 안내
|
||||||
|
|
||||||
|
## 공통 운영 문서
|
||||||
|
|
||||||
|
- [MCP·VPD·Data Redaction·DDS 공통 운영 가이드](runbooks/mcp-vpd-redaction-dss-operations.md): 고객사와 무관한 구성, 적용, 검증, 롤백 기준
|
||||||
|
- [SQLcl VPD 배포·검증·롤백 런북](runbooks/460-sqlcl-vpd-deploy-runbook.md): DB 적용과 장애 점검 절차
|
||||||
|
|
||||||
|
고객사별 데이터 모델, MCP 도구, 질의 예제는 `main`이 아니라 해당 고객사 브랜치에서 관리한다.
|
||||||
|
|||||||
76
docs/architecture/README.md
Normal file
76
docs/architecture/README.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# HMM AI 데이터 접근 아키텍처
|
||||||
|
|
||||||
|
OCI Draw.io Style Guide의 공식 서비스 도형과 Oracle 색상 규칙으로 정리한 HMM 데모
|
||||||
|
아키텍처다. Draw.io 원본은 두 페이지로 구성한다.
|
||||||
|
|
||||||
|
| 페이지 | 설명 | 미리보기 |
|
||||||
|
|---|---|---|
|
||||||
|
| `01 · 전체 구성` | HMM 사용자, Compute, Backoffice MCP, ADB, GenAI, Object Storage, AWS RDS federation | [SVG](hmm-ai-data-access-architecture-overview.svg) · [PNG](hmm-ai-data-access-architecture-overview.png) |
|
||||||
|
| `02 · MCP 요청과 VPD` | Tool 광고 시작 검증, 사용자 Bearer 인증, Agent Tool과 Select AI 분기, `CB_ORDS` VPD 실행 | [SVG](hmm-ai-data-access-architecture-security-flow.svg) · [PNG](hmm-ai-data-access-architecture-security-flow.png) |
|
||||||
|
|
||||||
|
편집 원본: [hmm-ai-data-access-architecture.drawio](hmm-ai-data-access-architecture.drawio)
|
||||||
|
|
||||||
|
## 현행 기준
|
||||||
|
|
||||||
|
- 사용자별 MCP: `https://hmm-backoffice.cloud-handson.com/mcp`
|
||||||
|
- 공개 Tool 계약: `BACKOFFICE_MCP_TOOLS`
|
||||||
|
- ADB Agent Tool: `resolve_hr_term`, `search_hr_policy`
|
||||||
|
- Java Select AI Tool: `search_hr_data`
|
||||||
|
- Select AI SQL 생성: ADMIN 세션
|
||||||
|
- 보호 SQL 실행: `CB_ORDS` 비면제 읽기 전용 세션
|
||||||
|
- VPD 문맥: `HMM_ACCESS_CTX`
|
||||||
|
- 휴가 정책: `HMM_LEAVE_SCOPE_POLICY`
|
||||||
|
- 정책 문서: Object Storage PDF → Abstract/Tag/Chunk/Embed 4 Vector
|
||||||
|
- 선사 실적: AWS RDS PostgreSQL → `HMM_RDS_PG_LINK` → ADB federation View
|
||||||
|
- 공용 호환 게이트웨이 `hmm-mcp.cloud-handson.com`은 사용자별 VPD MCP 주소가 아니다.
|
||||||
|
|
||||||
|
## 흐름을 읽는 방법
|
||||||
|
|
||||||
|
- 파란 실선: 사용자 요청 또는 읽기 전용 데이터 실행
|
||||||
|
- Oracle red 실선: 인증, 보안 Context, VPD 적용
|
||||||
|
- 주황 점선: 모델 호출 또는 문서 적재
|
||||||
|
- 회색 실선: 외부 PostgreSQL federation
|
||||||
|
- 녹색 실선: 정상 결과와 세션 정리
|
||||||
|
|
||||||
|
`AGENT_TOOL`과 `SELECT_AI`의 DB 세션은 구분한다.
|
||||||
|
|
||||||
|
- `AGENT_TOOL`: 기본 datasource의 같은 connection에서
|
||||||
|
`SET_USER_BY_BEARER → DBMS_CLOUD_AI_AGENT.RUN_TOOL → CLEAR_USER`
|
||||||
|
- `search_hr_data`: ADMIN이 SQL만 생성하고, `CB_ORDS`의 같은 connection에서
|
||||||
|
`SET_VPD_CONTEXT → SELECT → ROLLBACK → CLEAR_VPD_CONTEXT`
|
||||||
|
|
||||||
|
## 재생성
|
||||||
|
|
||||||
|
로컬 OCI 라이브러리 경로를 명시한다. 비밀번호, Token, OCID는 입력하지 않는다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node tools/architecture/generate-hmm-oci-architecture.mjs \
|
||||||
|
"/Users/joungminko/Downloads/OCI Style Guide for Drawio/OCI Library.xml" \
|
||||||
|
docs/architecture
|
||||||
|
```
|
||||||
|
|
||||||
|
SVG와 PNG는 diagrams.net의 embed exporter를 Playwright로 호출해 실제 Draw.io 렌더링과
|
||||||
|
동일하게 만든다. 인터넷 연결과 Playwright Chromium이 필요하다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node tools/architecture/export-drawio.mjs \
|
||||||
|
docs/architecture/hmm-ai-data-access-architecture.drawio \
|
||||||
|
docs/architecture
|
||||||
|
```
|
||||||
|
|
||||||
|
구조, 필수 문구, OCI stencil, 비밀값 패턴, 렌더링 크기를 검사한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node tools/architecture/validate-hmm-architecture.mjs docs/architecture
|
||||||
|
```
|
||||||
|
|
||||||
|
## 스타일 기준
|
||||||
|
|
||||||
|
- 기준 파일: `OCI Library.xml`, `Read-ME.drawio`
|
||||||
|
- 글꼴: Oracle Sans
|
||||||
|
- 본문: `#312D2A`
|
||||||
|
- OCI 경계: `#F5F4F2` / `#9E9892`
|
||||||
|
- OCI 강조: `#AE562C`
|
||||||
|
- 보안 강조: `#C74634`
|
||||||
|
- 외부 Cloud: 흰 배경과 회색 점선
|
||||||
|
- Style Guide의 안내용 pink와 Courier New는 사용하지 않는다.
|
||||||
BIN
docs/architecture/hmm-ai-data-access-architecture-overview.png
Normal file
BIN
docs/architecture/hmm-ai-data-access-architecture-overview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 336 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 101 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 300 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 91 KiB |
5
docs/architecture/hmm-ai-data-access-architecture.drawio
Normal file
5
docs/architecture/hmm-ai-data-access-architecture.drawio
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,64 @@
|
|||||||
|
# #743 MCP 광고 Agent Tool 시작 검증
|
||||||
|
|
||||||
|
## 문제
|
||||||
|
|
||||||
|
백오피스 MCP의 `tools/list`는 `BACKOFFICE_MCP_TOOLS`에 선언된 공개 계약을 광고한다.
|
||||||
|
`AGENT_TOOL`은 `targetName`을 `DBMS_CLOUD_AI_AGENT.RUN_TOOL`에 전달하지만, 현재는 서버
|
||||||
|
시작 시 해당 Tool이 ADB에 실제로 등록되어 있는지 확인하지 않는다. 따라서 Discovery는
|
||||||
|
성공하고 첫 `tools/call`에서만 실패할 수 있다.
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
- 공개 Tool 이름과 입력 스키마는 배포 설정에서 안정적으로 관리한다.
|
||||||
|
- `AGENT_TOOL`의 내부 `targetName`은 현재 JDBC 실행 사용자의
|
||||||
|
`USER_AI_AGENT_TOOLS`에서 존재하고 `ENABLED` 상태여야 한다.
|
||||||
|
- 누락·비활성·메타데이터 조회 실패는 서버 시작을 중단한다.
|
||||||
|
- Java 자체 구현인 `SELECT_AI`는 ADB Agent Tool 검증 대상에서 제외한다.
|
||||||
|
|
||||||
|
## 처리 흐름
|
||||||
|
|
||||||
|
```text
|
||||||
|
Spring 설정 로드
|
||||||
|
→ EnvironmentMcpToolCatalog가 BACKOFFICE_MCP_TOOLS 검증
|
||||||
|
→ SmartInitializingSingleton이 AGENT_TOOL targetName 수집
|
||||||
|
→ USER_AI_AGENT_TOOLS 조회
|
||||||
|
→ 모두 ENABLED
|
||||||
|
├─ 예: MCP endpoint 기동 완료, tools/list 광고
|
||||||
|
└─ 아니오: 기동 실패, 외부에 불완전한 Tool 계약을 광고하지 않음
|
||||||
|
```
|
||||||
|
|
||||||
|
Discovery 요청 때마다 DB를 조회하지 않는다. ADB Agent Tool은 운영 설정이므로 시작 시 한 번
|
||||||
|
검증하고, 설정이나 DB Tool을 바꾼 뒤에는 애플리케이션을 재기동해 계약을 다시 확정한다.
|
||||||
|
|
||||||
|
## 실패 정책
|
||||||
|
|
||||||
|
누락된 Tool을 `tools/list`에서 자동 제외하지 않는다. 호출 가능한 Tool 집합이 환경에 따라
|
||||||
|
조용히 축소되면 Agent instruction과 실제 도구 목록이 어긋나기 때문이다. 설정에 선언된
|
||||||
|
`AGENT_TOOL`이 하나라도 누락되거나 `ENABLED`가 아니면 fail-closed로 기동을 실패시킨다.
|
||||||
|
|
||||||
|
오류에는 설정의 내부 Tool 이름과 상태만 포함한다. Bearer Token, Tool 입력, DB 접속정보는
|
||||||
|
로그에 기록하지 않는다.
|
||||||
|
|
||||||
|
## 구현 경계
|
||||||
|
|
||||||
|
- `EnvironmentMcpToolCatalog`: 공개 이름, 인자, 실행 유형, 내부 target 형식 검증
|
||||||
|
- `McpAgentToolStartupValidator`: DB 등록·상태 검증
|
||||||
|
- `McpSseService`: 검증 완료된 catalog를 `tools/list`로 광고하고 `tools/call`로 실행
|
||||||
|
- `JdbcHmmAiAgentToolRunner`: 검증된 `targetName`을 동일 JDBC 세션에서 실행
|
||||||
|
|
||||||
|
`USER_AI_AGENT_TOOLS`는 `RUN_TOOL`을 실행하는 기본 datasource 사용자 기준 View다. 다른
|
||||||
|
스키마의 Tool을 임의로 검색하거나 `ALL_*` 권한을 요구하지 않는다.
|
||||||
|
|
||||||
|
## 검증 기준
|
||||||
|
|
||||||
|
1. `AGENT_TOOL`이 모두 `ENABLED`면 검증이 통과한다.
|
||||||
|
2. Tool이 누락되면 누락된 이름을 포함해 실패한다.
|
||||||
|
3. Tool이 `DISABLED`면 이름과 상태를 포함해 실패한다.
|
||||||
|
4. 같은 ADB Tool을 여러 공개 Tool이 참조해도 한 번만 검증한다.
|
||||||
|
5. `SELECT_AI`만 구성되면 `USER_AI_AGENT_TOOLS`를 조회하지 않는다.
|
||||||
|
6. 기존 Maven 전체 테스트와 MCP discovery/call 테스트가 통과한다.
|
||||||
|
|
||||||
|
## 롤백
|
||||||
|
|
||||||
|
검증 컴포넌트와 테스트를 제거하면 기존 설정 기반 광고 방식으로 돌아간다. DB Tool,
|
||||||
|
프로필, VPD 정책과 운영 데이터는 이 변경에서 수정하지 않는다.
|
||||||
128
docs/design/745-hmm-oci-architecture/README.md
Normal file
128
docs/design/745-hmm-oci-architecture/README.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# #745 OCI 스타일 HMM AI 데이터 접근 아키텍처
|
||||||
|
|
||||||
|
> 상태: Approved
|
||||||
|
> Redmine: #745
|
||||||
|
> 구현 산출물: `docs/architecture/hmm-ai-data-access-architecture.*`
|
||||||
|
> 생성 도구: `tools/architecture/generate-hmm-oci-architecture.mjs`
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
HMM 데모의 애플리케이션, MCP, ADB 보안 실행 경계, AI 모델, 문서 지식과 외부
|
||||||
|
PostgreSQL federation을 한 장에서 설명할 수 있는 아키텍처 그림을 만든다. 세부 요청 흐름은
|
||||||
|
두 번째 페이지에 분리해 사용자 Bearer Token이 실제 VPD 행 필터로 연결되는 지점을 명확히 한다.
|
||||||
|
|
||||||
|
기준 스타일은 `/Users/joungminko/Downloads/OCI Style Guide for Drawio`의
|
||||||
|
`OCI Library.xml`과 `Read-ME.drawio`다. OCI 서비스는 공식 라이브러리 도형을 사용하고,
|
||||||
|
외부 서비스는 `3rd Party Cloud` 경계와 중립 색상으로 구분한다.
|
||||||
|
|
||||||
|
## 산출물
|
||||||
|
|
||||||
|
| 파일 | 용도 |
|
||||||
|
|---|---|
|
||||||
|
| `docs/architecture/hmm-ai-data-access-architecture.drawio` | 편집 가능한 2페이지 원본 |
|
||||||
|
| `docs/architecture/hmm-ai-data-access-architecture-overview.svg` | 전체 구성 검토·문서 삽입용 |
|
||||||
|
| `docs/architecture/hmm-ai-data-access-architecture-security-flow.svg` | 요청·보안 흐름 검토용 |
|
||||||
|
| 같은 이름의 `.png` | 일반 문서·메신저 공유용 |
|
||||||
|
| `docs/architecture/README.md` | 범례, 현행 기준, 편집·재생성 방법 |
|
||||||
|
|
||||||
|
## 페이지 1: 전체 구성
|
||||||
|
|
||||||
|
다음 경계를 왼쪽에서 오른쪽으로 배치한다.
|
||||||
|
|
||||||
|
1. HMM 사용자: 팀장, 팀원, 관리자
|
||||||
|
2. 인터넷 공개 HTTPS: `hmm.cloud-handson.com`, `hmm-backoffice.cloud-handson.com`
|
||||||
|
3. OCI 서울 리전
|
||||||
|
- Compute VM + Nginx
|
||||||
|
- AI Web Agent Console
|
||||||
|
- VPD Backoffice + 사용자 Bearer MCP `/mcp`
|
||||||
|
- OCI Object Storage의 HR PDF
|
||||||
|
- Autonomous Database HMMAIPOC
|
||||||
|
- OCI Generative AI
|
||||||
|
4. 3rd Party Cloud: AWS RDS for PostgreSQL
|
||||||
|
|
||||||
|
ADB 내부에는 아래 실행 요소를 표시한다.
|
||||||
|
|
||||||
|
- MCP Tool catalog와 시작 검증: `BACKOFFICE_MCP_TOOLS` → `USER_AI_AGENT_TOOLS.ENABLED`
|
||||||
|
- ADB Agent Tool: `resolve_hr_term`, `search_hr_policy`
|
||||||
|
- Java `SELECT_AI`: `search_hr_data`
|
||||||
|
- 비면제 런타임 사용자 `CB_ORDS`
|
||||||
|
- `HMM_ACCESS_CTX`, `HMM_LEAVE_SCOPE_POLICY`, HR 정형 테이블
|
||||||
|
- 문서 메타데이터, Abstract, Tag, Chunk, Embed 4 Vector
|
||||||
|
- `HMM_RDS_PG_LINK`와 `HMM_RDS_*_V`, 선사 배정 View
|
||||||
|
|
||||||
|
모델 연결은 용도를 구분한다.
|
||||||
|
|
||||||
|
- GPT-5.4 Mini: 질문 계획, Select AI SQL 생성, 답변 합성
|
||||||
|
- Cohere Embed 4: HR 용어와 정책 문서 임베딩
|
||||||
|
|
||||||
|
## 페이지 2: MCP 요청과 VPD 적용 흐름
|
||||||
|
|
||||||
|
다음 순서를 번호로 표시한다.
|
||||||
|
|
||||||
|
1. Agent가 인증된 `initialize`/`tools/list`를 호출한다.
|
||||||
|
2. 백오피스가 사용자 Bearer Token의 해시, 만료, 회수와 재직 상태를 확인한다.
|
||||||
|
3. `tools/call`에서 Tool 유형을 분기한다.
|
||||||
|
4. `AGENT_TOOL`은 `DBMS_CLOUD_AI_AGENT.RUN_TOOL`을 호출한다.
|
||||||
|
5. `search_hr_data`는 ADMIN 세션에서 `DBMS_CLOUD_AI.GENERATE(..., 'showsql')`로
|
||||||
|
승인 객체만 사용하는 읽기 전용 SQL을 만든다.
|
||||||
|
6. 같은 `CB_ORDS` JDBC 세션에서 `CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT`를 호출한다.
|
||||||
|
7. `HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER`가 `HMM_ACCESS_CTX`를 설정한다.
|
||||||
|
8. `CB_ORDS`가 SQL을 실행하면 `HMM_LEAVE_SCOPE_POLICY`가 SELF/MANAGED_TEAM/ALL
|
||||||
|
predicate를 자동 적용한다.
|
||||||
|
9. 트랜잭션을 rollback하고 `CLEAR_VPD_CONTEXT`로 세션 문맥을 제거한다.
|
||||||
|
10. 필터링된 결과와 근거만 Agent에 반환한다.
|
||||||
|
|
||||||
|
서버 기동 흐름은 요청 흐름과 분리한다. `AGENT_TOOL targetName`이
|
||||||
|
`USER_AI_AGENT_TOOLS`에 없거나 `ENABLED`가 아니면 서버는 준비 상태가 되지 않으며
|
||||||
|
불완전한 `tools/list`를 광고하지 않는다.
|
||||||
|
|
||||||
|
## 시각 규칙
|
||||||
|
|
||||||
|
- 기본 글꼴: `Oracle Sans`, 대체 글꼴 `Arial`, `sans-serif`
|
||||||
|
- 본문 색: `#312D2A`
|
||||||
|
- OCI 경계 배경/선: `#F5F4F2` / `#9E9892`
|
||||||
|
- OCI 강조선: `#AE562C`
|
||||||
|
- Oracle red 강조: `#C74634`
|
||||||
|
- 외부 Cloud 경계: 흰색 배경, `#6B7280` 점선
|
||||||
|
- 정상 데이터 흐름: 실선, 보안·컨텍스트 적용: Oracle red 실선, 관리/적재: 점선
|
||||||
|
- 선 교차를 최소화하고 연결선에는 동작 이름을 직접 표기한다.
|
||||||
|
- 스타일 가이드의 안내 전용 pink와 Courier New는 사용하지 않는다.
|
||||||
|
|
||||||
|
## 생성기 계약
|
||||||
|
|
||||||
|
`generate-hmm-oci-architecture.mjs`는 OCI Library 경로와 출력 디렉터리를 인자로 받는다.
|
||||||
|
라이브러리에서 아래 공식 도형을 제목으로 찾아 원본 Draw.io에 포함한다.
|
||||||
|
|
||||||
|
- `Identity and Security - User`
|
||||||
|
- `Compute - Virtual Machine VM`
|
||||||
|
- `Database - Autonomous DB`
|
||||||
|
- `Analytics and AI - Artificial Intelligence`
|
||||||
|
- `Storage - Object Storage`
|
||||||
|
- `Identity and Security - Vault`
|
||||||
|
|
||||||
|
필수 도형이 없으면 불완전한 그림을 만들지 않고 실패한다. 생성 결과는 비밀번호, Token,
|
||||||
|
OCID를 포함하지 않는다.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
1. `.drawio`가 XML로 파싱되고 페이지가 정확히 2개다.
|
||||||
|
2. 두 페이지 모두 필수 구성요소와 흐름 라벨을 포함한다.
|
||||||
|
3. 공식 OCI 라이브러리 stencil이 원본에 포함된다.
|
||||||
|
4. SVG를 PNG로 렌더링해 잘림, 겹침, 읽기 어려운 글자, 불필요한 선 교차를 확인한다.
|
||||||
|
5. `git diff --check`와 비밀값 패턴 검사를 통과한다.
|
||||||
|
|
||||||
|
## 롤백
|
||||||
|
|
||||||
|
`docs/architecture/hmm-ai-data-access-architecture*`와 생성기를 제거하면 된다. 애플리케이션,
|
||||||
|
DB, OCI 리소스와 운영 설정은 이 작업에서 변경하지 않는다.
|
||||||
|
|
||||||
|
## 검증 결과
|
||||||
|
|
||||||
|
- Draw.io XML 파싱: PASS
|
||||||
|
- 페이지 수와 이름: `2` / `01 · 전체 구성`, `02 · MCP 요청과 VPD`
|
||||||
|
- OCI 공식 stencil 포함: PASS
|
||||||
|
- 필수 구성요소·보안 흐름 검사: PASS
|
||||||
|
- 비밀값 패턴 검사: PASS
|
||||||
|
- 동일 입력 재생성 byte 비교: PASS
|
||||||
|
- diagrams.net 실제 렌더링: `1840×1120`, `1840×1090`
|
||||||
|
- PNG 육안 검사: 흰 배경, 제목 대비, 구성요소 잘림 없음, 주요 연결선과 설명 식별 가능
|
||||||
@@ -33,6 +33,12 @@ HMM 포털 로그인과 MCP 호출은 서로 다른 인증 수단을 사용한
|
|||||||
| `resolve_hr_term` | `term` | 휴가·근태 표현을 표준 용어와 코드로 변환 |
|
| `resolve_hr_term` | `term` | 휴가·근태 표현을 표준 용어와 코드로 변환 |
|
||||||
| `search_hr_policy` | `query` | HR 규정 PDF 지식 검색 |
|
| `search_hr_policy` | `query` | HR 규정 PDF 지식 검색 |
|
||||||
|
|
||||||
|
`tools/list`는 `BACKOFFICE_MCP_TOOLS`의 공개 이름·설명·입력 스키마를 광고한다.
|
||||||
|
`executionType=AGENT_TOOL`인 항목은 애플리케이션 시작 시 기본 datasource 사용자의
|
||||||
|
`USER_AI_AGENT_TOOLS`에서 `targetName`이 실제로 존재하고 `STATUS=ENABLED`인지 검증한다.
|
||||||
|
하나라도 누락되거나 비활성이면 서버는 기동을 실패하며 불완전한 Tool 목록을 광고하지 않는다.
|
||||||
|
`search_hr_data`처럼 `executionType=SELECT_AI`인 Java 자체 구현 Tool은 이 검증 대상이 아니다.
|
||||||
|
|
||||||
## 포털의 현재 공용 토큰 조합
|
## 포털의 현재 공용 토큰 조합
|
||||||
|
|
||||||
`config/vpd_token_presets.json`의 현재 조합은 다음과 같다.
|
`config/vpd_token_presets.json`의 현재 조합은 다음과 같다.
|
||||||
|
|||||||
96
docs/runbooks/mcp-vpd-redaction-dss-operations.md
Normal file
96
docs/runbooks/mcp-vpd-redaction-dss-operations.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# MCP·VPD·Data Redaction·DDS 공통 운영 가이드
|
||||||
|
|
||||||
|
## 1. 적용 범위와 책임
|
||||||
|
|
||||||
|
이 문서는 고객사와 데이터 도메인에 무관하게 사용하는 공통 운영 절차다. 고객별 테이블,
|
||||||
|
지표, 질문 예제, Select AI 프로파일은 각 고객사 브랜치 문서에서 관리한다.
|
||||||
|
|
||||||
|
| 구성요소 | 책임 | 운영자가 확인할 것 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| MCP | 허용 도구를 공개하고 요청·응답 계약을 제공 | `tools/list`, 입력 스키마, 인증 |
|
||||||
|
| Select AI | 읽기 전용 SQL 생성 및 실행 | 허용 객체, SQL 검증, timeout |
|
||||||
|
| VPD | 행 단위 접근 제어 | session context, policy, predicate |
|
||||||
|
| Data Redaction (ASO) | 민감 컬럼 값의 NULL/마스킹 | 대상 컬럼, policy, 예외 사용자 |
|
||||||
|
| DDS | 선언형 행·컬럼·작업 권한 | END USER, DATA ROLE, DATA GRANT |
|
||||||
|
| FGA | 실행 증적 감사 | 요청 식별자, SQL, RLS 정보 |
|
||||||
|
|
||||||
|
VPD와 Data Redaction은 서로 대체하지 않는다. VPD는 **어떤 행을 볼 수 있는지**,
|
||||||
|
Data Redaction은 허용된 행에서 **컬럼 값을 어떻게 표시할지**를 담당한다. DDS는
|
||||||
|
`DATA GRANT`로 행·컬럼 권한을 선언적으로 적용하는 별도 경로다.
|
||||||
|
|
||||||
|
## 2. 공통 실행 흐름
|
||||||
|
|
||||||
|
```text
|
||||||
|
사용자/Agent
|
||||||
|
→ MCP initialize · tools/list · tools/call
|
||||||
|
→ Bearer 또는 서비스 인증 검증
|
||||||
|
→ 허용된 도구와 입력 스키마 확인
|
||||||
|
→ DB session context 또는 DDS END USER context 설정
|
||||||
|
→ Select AI SHOWSQL 생성
|
||||||
|
→ SELECT/WITH 전용 검증과 read-only 실행
|
||||||
|
→ VPD 행 필터 + Data Redaction 또는 DDS 권한 적용
|
||||||
|
→ 결과·생성 SQL·감사 식별자 반환
|
||||||
|
```
|
||||||
|
|
||||||
|
MCP 또는 애플리케이션은 사용자 입력을 VPD predicate나 SQL 조각으로 조합하지 않는다.
|
||||||
|
권한 판단은 DB 정책·권한 테이블·DDS grant에서 수행한다.
|
||||||
|
|
||||||
|
## 3. 최초 구성 순서
|
||||||
|
|
||||||
|
1. `database/adb/`의 기본 스키마, 권한 테이블, 보호 View를 적용한다.
|
||||||
|
2. VPD context package·policy function을 compile하고 `DBA_POLICIES`에서 적용 대상을 확인한다.
|
||||||
|
3. 민감 컬럼에는 Data Redaction policy를 적용하고 권한별 조회 결과를 확인한다.
|
||||||
|
4. DDS를 사용할 경우 END USER, DATA ROLE, DATA GRANT를 별도 구성한다. 단순 Bearer 값 조회만으로 DDS END USER가 되지 않는다.
|
||||||
|
5. MCP endpoint와 공개 도구 목록을 설정한다. 광고한 모든 도구는 실제로 호출 가능해야 한다.
|
||||||
|
6. SELECT/WITH 이외 문장 차단, read-only transaction, query timeout, 반환 행 제한을 설정한다.
|
||||||
|
7. 사용자별 허용/거부, 마스킹, 감사 증적을 회귀 검증한다.
|
||||||
|
|
||||||
|
## 4. MCP 운영 절차
|
||||||
|
|
||||||
|
### 기동 전
|
||||||
|
|
||||||
|
- 환경변수·DB 연결·wallet·MCP endpoint를 점검한다.
|
||||||
|
- 도구 이름, 설명, 입력 스키마가 실제 구현과 일치하는지 확인한다.
|
||||||
|
- 실제 token, password, wallet, 대화 이력 DB는 Git에 넣지 않는다.
|
||||||
|
|
||||||
|
### 요청 처리
|
||||||
|
|
||||||
|
1. `initialize` 후 협상된 프로토콜 버전을 사용한다.
|
||||||
|
2. `tools/list` 결과 중 승인된 도구만 호출한다.
|
||||||
|
3. `tools/call` 입력을 서버에서 검증한다.
|
||||||
|
4. 생성 SQL과 실행 결과, 오류 사유를 분리해 반환한다.
|
||||||
|
5. 실패 시 임의 SQL 재시도 대신 도구 상세·SHOWPROMPT·DB audit을 확인한다.
|
||||||
|
|
||||||
|
## 5. VPD와 Data Redaction 점검
|
||||||
|
|
||||||
|
| 증상 | 우선 확인 |
|
||||||
|
| --- | --- |
|
||||||
|
| 권한 있는데 0행 | session context, VPD predicate, 권한 매핑 |
|
||||||
|
| `ORA-00942`/`ORA-01031` | DB object grant와 보호 View 노출 여부 |
|
||||||
|
| 컬럼이 NULL/마스킹됨 | Data Redaction policy와 예외 조건 |
|
||||||
|
| 다른 사용자 결과가 같음 | Bearer→업무 사용자 매핑, context clear |
|
||||||
|
| 요청 추적 불가 | `CLIENT_IDENTIFIER`, FGA/Unified Audit Trail |
|
||||||
|
|
||||||
|
VPD 정책 변경 전에는 대상 View·TABLE, 기존 policy, 함수 상태를 백업하고, 변경 후
|
||||||
|
허용 사용자·거부 사용자·마스킹 예외 사용자를 모두 조회한다. 롤백은 policy/function 및
|
||||||
|
권한 매핑을 직전 검증 상태로 되돌린 뒤 같은 회귀 시나리오로 확인한다.
|
||||||
|
|
||||||
|
## 6. DDS 사용 기준
|
||||||
|
|
||||||
|
- 사용자별 `END USER` context를 실제 DB 호출에 전달할 수 있으면 DDS를 고려한다.
|
||||||
|
- 서비스 계정의 client-credentials token은 서비스 인증용이며 업무 사용자 권한을 뜻하지 않는다.
|
||||||
|
- 권한이 자주 바뀌면 사용자별 grant 복제보다 공통 role과 런타임 권한 함수를 검토한다.
|
||||||
|
- VPD와 DDS를 같은 보호 객체에 함께 적용할 때는 정책 순서와 예상 결과를 별도 검증한다.
|
||||||
|
|
||||||
|
## 7. 검증과 증적
|
||||||
|
|
||||||
|
최소 검증 세트는 다음과 같다.
|
||||||
|
|
||||||
|
1. 인증 성공/실패와 `tools/list` 계약
|
||||||
|
2. 허용 사용자와 거부 사용자의 행 수 차이
|
||||||
|
3. 민감 컬럼의 Redaction 결과
|
||||||
|
4. 읽기 전용 SQL 차단 규칙과 timeout
|
||||||
|
5. FGA 또는 Unified Audit Trail의 SQL·RLS 정보·요청 식별자
|
||||||
|
|
||||||
|
상세 SQLcl 절차는 [VPD 배포·검증·롤백 런북](460-sqlcl-vpd-deploy-runbook.md)을,
|
||||||
|
설계 근거는 [ORDS·VPD·DDS 보안 설계](../06-agent-ords-vpd-dds-security-brief.md)를 따른다.
|
||||||
118
tools/architecture/export-drawio.mjs
Normal file
118
tools/architecture/export-drawio.mjs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
|
||||||
|
const sourcePath = path.resolve(
|
||||||
|
process.argv[2] || "docs/architecture/hmm-ai-data-access-architecture.drawio",
|
||||||
|
);
|
||||||
|
const outputDir = path.resolve(process.argv[3] || path.dirname(sourcePath));
|
||||||
|
const source = fs.readFileSync(sourcePath, "utf8");
|
||||||
|
const diagrams = [...source.matchAll(
|
||||||
|
/<diagram\b[^>]*name="([^"]+)"[^>]*>[\s\S]*?<\/diagram>/g,
|
||||||
|
)];
|
||||||
|
|
||||||
|
if (diagrams.length !== 2) {
|
||||||
|
throw new Error(`Expected 2 Draw.io pages, found ${diagrams.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputs = [
|
||||||
|
"hmm-ai-data-access-architecture-overview",
|
||||||
|
"hmm-ai-data-access-architecture-security-flow",
|
||||||
|
];
|
||||||
|
const embedUrl = "https://embed.diagrams.net/?embed=1&ui=min&spin=1&proto=json";
|
||||||
|
|
||||||
|
function singlePageMxfile(diagram) {
|
||||||
|
return [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<mxfile host="app.diagrams.net" compressed="false">',
|
||||||
|
diagram,
|
||||||
|
"</mxfile>",
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeDataUrl(data, mimeType) {
|
||||||
|
const base64Prefix = `data:${mimeType};base64,`;
|
||||||
|
if (data.startsWith(base64Prefix)) {
|
||||||
|
return Buffer.from(data.slice(base64Prefix.length), "base64");
|
||||||
|
}
|
||||||
|
const utf8Prefix = `data:${mimeType},`;
|
||||||
|
if (data.startsWith(utf8Prefix)) {
|
||||||
|
return Buffer.from(decodeURIComponent(data.slice(utf8Prefix.length)), "utf8");
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected diagrams.net export prefix: ${data.slice(0, 80)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportPage(browser, xml, format) {
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
|
||||||
|
const html = `<!doctype html>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<script>
|
||||||
|
window.inputXml = ${JSON.stringify(xml)};
|
||||||
|
window.exportResult = null;
|
||||||
|
window.exportError = null;
|
||||||
|
window.addEventListener("message", (event) => {
|
||||||
|
let message = event.data;
|
||||||
|
try {
|
||||||
|
if (typeof message === "string") message = JSON.parse(message);
|
||||||
|
} catch (_error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!message || !message.event) return;
|
||||||
|
if (message.event === "init") {
|
||||||
|
event.source.postMessage(JSON.stringify({
|
||||||
|
action: "load",
|
||||||
|
xml: window.inputXml,
|
||||||
|
autosave: 0
|
||||||
|
}), "*");
|
||||||
|
} else if (message.event === "load") {
|
||||||
|
event.source.postMessage(JSON.stringify({
|
||||||
|
action: "export",
|
||||||
|
format: ${JSON.stringify(format)},
|
||||||
|
xml: window.inputXml,
|
||||||
|
border: 20,
|
||||||
|
scale: 1
|
||||||
|
}), "*");
|
||||||
|
} else if (message.event === "export") {
|
||||||
|
window.exportResult = message.data;
|
||||||
|
} else if (message.event === "error") {
|
||||||
|
window.exportError = JSON.stringify(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<iframe title="diagrams.net exporter" style="width:100vw;height:100vh;border:0"
|
||||||
|
src="${embedUrl}"></iframe>`;
|
||||||
|
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.exportResult || window.exportError,
|
||||||
|
null,
|
||||||
|
{ timeout: 120000 },
|
||||||
|
);
|
||||||
|
const result = await page.evaluate(() => ({
|
||||||
|
data: window.exportResult,
|
||||||
|
error: window.exportError,
|
||||||
|
}));
|
||||||
|
await page.close();
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
try {
|
||||||
|
for (let index = 0; index < diagrams.length; index += 1) {
|
||||||
|
const xml = singlePageMxfile(diagrams[index][0]);
|
||||||
|
for (const format of ["svg", "png"]) {
|
||||||
|
const data = await exportPage(browser, xml, format);
|
||||||
|
const mimeType = format === "svg" ? "image/svg+xml" : "image/png";
|
||||||
|
const output = path.join(outputDir, `${outputs[index]}.${format}`);
|
||||||
|
fs.writeFileSync(output, decodeDataUrl(data, mimeType));
|
||||||
|
console.log(output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
801
tools/architecture/generate-hmm-oci-architecture.mjs
Normal file
801
tools/architecture/generate-hmm-oci-architecture.mjs
Normal file
@@ -0,0 +1,801 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import zlib from "node:zlib";
|
||||||
|
|
||||||
|
const DEFAULT_OUTPUT_DIR = "docs/architecture";
|
||||||
|
const libraryInput = process.argv[2] || process.env.OCI_DRAWIO_LIBRARY;
|
||||||
|
if (!libraryInput) {
|
||||||
|
throw new Error(
|
||||||
|
"OCI Draw.io library path is required as the first argument or OCI_DRAWIO_LIBRARY",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const libraryPath = path.resolve(libraryInput);
|
||||||
|
const outputDir = path.resolve(process.argv[3] || DEFAULT_OUTPUT_DIR);
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
ink: "#312D2A",
|
||||||
|
muted: "#5B5652",
|
||||||
|
oracleRed: "#C74634",
|
||||||
|
oracleOrange: "#AE562C",
|
||||||
|
regionFill: "#F5F4F2",
|
||||||
|
regionStroke: "#9E9892",
|
||||||
|
panel: "#FFFFFF",
|
||||||
|
panelSoft: "#FCFBFA",
|
||||||
|
thirdParty: "#6B7280",
|
||||||
|
success: "#2F6B4F",
|
||||||
|
blue: "#2F6F8F",
|
||||||
|
};
|
||||||
|
|
||||||
|
const REQUIRED_ICONS = [
|
||||||
|
"Identity and Security - User",
|
||||||
|
"Compute - Virtual Machine VM",
|
||||||
|
"Database - Autonomous DB",
|
||||||
|
"Analytics and AI - Artificial Intelligence",
|
||||||
|
"Storage - Object Storage",
|
||||||
|
"Identity and Security - Vault",
|
||||||
|
];
|
||||||
|
|
||||||
|
function escapeXml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLibrary(file) {
|
||||||
|
const raw = fs.readFileSync(file, "utf8").trim();
|
||||||
|
const prefix = "<mxlibrary>";
|
||||||
|
const suffix = "</mxlibrary>";
|
||||||
|
if (!raw.startsWith(prefix) || !raw.endsWith(suffix)) {
|
||||||
|
throw new Error(`Invalid Draw.io library: ${file}`);
|
||||||
|
}
|
||||||
|
const entries = JSON.parse(raw.slice(prefix.length, -suffix.length));
|
||||||
|
const byTitle = new Map(entries.map((entry) => [entry.title, entry]));
|
||||||
|
const missing = REQUIRED_ICONS.filter((title) => !byTitle.has(title));
|
||||||
|
if (missing.length) {
|
||||||
|
throw new Error(`Required OCI library shapes not found: ${missing.join(", ")}`);
|
||||||
|
}
|
||||||
|
return byTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeLibraryEntry(entry) {
|
||||||
|
const encoded = Buffer.from(entry.xml, "base64");
|
||||||
|
return decodeURIComponent(zlib.inflateRawSync(encoded).toString("utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function style(parts) {
|
||||||
|
return Object.entries(parts)
|
||||||
|
.filter(([, value]) => value !== undefined && value !== null)
|
||||||
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
|
.join(";") + ";";
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPage(name, id, width, height) {
|
||||||
|
let sequence = 1;
|
||||||
|
const cells = [
|
||||||
|
`<mxCell id="${id}_background" value="" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=none;locked=1;" vertex="1" connectable="0" parent="1">`
|
||||||
|
+ `<mxGeometry x="0" y="0" width="${width}" height="${height}" as="geometry"/>`
|
||||||
|
+ `</mxCell>`,
|
||||||
|
];
|
||||||
|
const nextId = (prefix = "c") => `${id}_${prefix}_${sequence++}`;
|
||||||
|
|
||||||
|
function vertex({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
value = "",
|
||||||
|
cellStyle,
|
||||||
|
parent = "1",
|
||||||
|
cellId = nextId("v"),
|
||||||
|
connectable,
|
||||||
|
}) {
|
||||||
|
const connectableAttr = connectable === false ? ' connectable="0"' : "";
|
||||||
|
cells.push(
|
||||||
|
`<mxCell id="${cellId}" value="${escapeXml(value)}" style="${escapeXml(cellStyle)}" vertex="1" parent="${parent}"${connectableAttr}>`
|
||||||
|
+ `<mxGeometry x="${x}" y="${y}" width="${w}" height="${h}" as="geometry"/>`
|
||||||
|
+ `</mxCell>`,
|
||||||
|
);
|
||||||
|
return cellId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function edge({
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
value = "",
|
||||||
|
cellStyle,
|
||||||
|
points = [],
|
||||||
|
cellId = nextId("e"),
|
||||||
|
}) {
|
||||||
|
const pointXml = points.length
|
||||||
|
? `<Array as="points">${points.map(([x, y]) => `<mxPoint x="${x}" y="${y}"/>`).join("")}</Array>`
|
||||||
|
: "";
|
||||||
|
cells.push(
|
||||||
|
`<mxCell id="${cellId}" value="${escapeXml(value)}" style="${escapeXml(cellStyle)}" edge="1" parent="1" source="${source}" target="${target}">`
|
||||||
|
+ `<mxGeometry relative="1" as="geometry">${pointXml}</mxGeometry>`
|
||||||
|
+ `</mxCell>`,
|
||||||
|
);
|
||||||
|
return cellId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function officialIcon(library, title, x, y, parent = "1") {
|
||||||
|
const entry = library.get(title);
|
||||||
|
const decoded = decodeLibraryEntry(entry);
|
||||||
|
const sourceCells = [
|
||||||
|
...decoded.matchAll(/<mxCell\b[^>]*\/>|<mxCell\b[^>]*>[\s\S]*?<\/mxCell>/g),
|
||||||
|
].map((match) => match[0]);
|
||||||
|
const parsed = sourceCells
|
||||||
|
.map((xml) => ({
|
||||||
|
xml,
|
||||||
|
id: xml.match(/\bid="([^"]+)"/)?.[1],
|
||||||
|
parent: xml.match(/\bparent="([^"]+)"/)?.[1],
|
||||||
|
value: xml.match(/\bvalue="([^"]*)"/)?.[1] ?? "",
|
||||||
|
}))
|
||||||
|
.filter((cell) => cell.id && !["0", "1"].includes(cell.id));
|
||||||
|
const included = parsed.filter((cell) => !cell.value.trim());
|
||||||
|
const wrapperId = nextId("oci");
|
||||||
|
cells.push(
|
||||||
|
`<mxCell id="${wrapperId}" value="" style="group" vertex="1" connectable="0" parent="${parent}">`
|
||||||
|
+ `<mxGeometry x="${x}" y="${y}" width="${entry.w}" height="90" as="geometry"/>`
|
||||||
|
+ `</mxCell>`,
|
||||||
|
);
|
||||||
|
const idMap = new Map(included.map((cell) => [cell.id, nextId("ociPart")]));
|
||||||
|
for (const cell of included) {
|
||||||
|
let xml = cell.xml;
|
||||||
|
xml = xml.replace(/\bid="([^"]+)"/, `id="${idMap.get(cell.id)}"`);
|
||||||
|
xml = xml.replace(/\bparent="([^"]+)"/, (_match, oldParent) => {
|
||||||
|
if (oldParent === "1") {
|
||||||
|
return `parent="${wrapperId}"`;
|
||||||
|
}
|
||||||
|
return `parent="${idMap.get(oldParent) || wrapperId}"`;
|
||||||
|
});
|
||||||
|
xml = xml.replace(/\b(source|target)="([^"]+)"/g, (_match, attr, oldId) =>
|
||||||
|
`${attr}="${idMap.get(oldId) || oldId}"`);
|
||||||
|
cells.push(xml);
|
||||||
|
}
|
||||||
|
return wrapperId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function result() {
|
||||||
|
const model = [
|
||||||
|
`<mxGraphModel dx="1600" dy="1000" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="${width}" pageHeight="${height}" background="#FFFFFF" math="0" shadow="0">`,
|
||||||
|
"<root>",
|
||||||
|
'<mxCell id="0"/>',
|
||||||
|
'<mxCell id="1" parent="0"/>',
|
||||||
|
...cells,
|
||||||
|
"</root>",
|
||||||
|
"</mxGraphModel>",
|
||||||
|
].join("");
|
||||||
|
return `<diagram id="${id}" name="${escapeXml(name)}">${model}</diagram>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { vertex, edge, officialIcon, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelStyle = (options = {}) => style({
|
||||||
|
rounded: 0,
|
||||||
|
whiteSpace: "wrap",
|
||||||
|
html: 1,
|
||||||
|
strokeColor: "none",
|
||||||
|
fillColor: "none",
|
||||||
|
align: options.align || "left",
|
||||||
|
verticalAlign: options.verticalAlign || "middle",
|
||||||
|
fontFamily: "Oracle Sans",
|
||||||
|
fontColor: options.fontColor || COLORS.ink,
|
||||||
|
fontSize: options.fontSize || 12,
|
||||||
|
fontStyle: options.bold ? 1 : 0,
|
||||||
|
spacingLeft: options.spacingLeft || 0,
|
||||||
|
spacingRight: options.spacingRight || 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const panelStyle = (options = {}) => style({
|
||||||
|
rounded: 1,
|
||||||
|
arcSize: options.arcSize || 8,
|
||||||
|
whiteSpace: "wrap",
|
||||||
|
html: 1,
|
||||||
|
fillColor: options.fillColor || COLORS.panel,
|
||||||
|
strokeColor: options.strokeColor || COLORS.regionStroke,
|
||||||
|
strokeWidth: options.strokeWidth || 1,
|
||||||
|
dashed: options.dashed ? 1 : 0,
|
||||||
|
fontFamily: "Oracle Sans",
|
||||||
|
fontColor: options.fontColor || COLORS.ink,
|
||||||
|
fontSize: options.fontSize || 12,
|
||||||
|
align: options.align || "center",
|
||||||
|
verticalAlign: options.verticalAlign || "middle",
|
||||||
|
spacing: options.spacing || 6,
|
||||||
|
spacingLeft: options.spacingLeft,
|
||||||
|
spacingRight: options.spacingRight,
|
||||||
|
});
|
||||||
|
|
||||||
|
const edgeStyle = (options = {}) => style({
|
||||||
|
edgeStyle: "orthogonalEdgeStyle",
|
||||||
|
rounded: 1,
|
||||||
|
orthogonalLoop: 1,
|
||||||
|
jettySize: "auto",
|
||||||
|
html: 1,
|
||||||
|
strokeColor: options.strokeColor || COLORS.ink,
|
||||||
|
strokeWidth: options.strokeWidth || 2,
|
||||||
|
dashed: options.dashed ? 1 : 0,
|
||||||
|
endArrow: options.endArrow || "block",
|
||||||
|
endFill: options.endFill === false ? 0 : 1,
|
||||||
|
fontFamily: "Oracle Sans",
|
||||||
|
fontColor: options.fontColor || COLORS.ink,
|
||||||
|
fontSize: options.fontSize || 11,
|
||||||
|
labelBackgroundColor: "#FFFFFF",
|
||||||
|
exitX: options.exitX,
|
||||||
|
exitY: options.exitY,
|
||||||
|
entryX: options.entryX,
|
||||||
|
entryY: options.entryY,
|
||||||
|
});
|
||||||
|
|
||||||
|
function title(page, heading, subtitle, width) {
|
||||||
|
page.vertex({
|
||||||
|
x: 36,
|
||||||
|
y: 22,
|
||||||
|
w: width - 72,
|
||||||
|
h: 38,
|
||||||
|
value: heading,
|
||||||
|
cellStyle: labelStyle({ fontSize: 25, bold: true }),
|
||||||
|
});
|
||||||
|
page.vertex({
|
||||||
|
x: 38,
|
||||||
|
y: 60,
|
||||||
|
w: width - 76,
|
||||||
|
h: 28,
|
||||||
|
value: subtitle,
|
||||||
|
cellStyle: labelStyle({ fontSize: 12, fontColor: COLORS.muted }),
|
||||||
|
});
|
||||||
|
page.vertex({
|
||||||
|
x: 36,
|
||||||
|
y: 92,
|
||||||
|
w: width - 72,
|
||||||
|
h: 3,
|
||||||
|
cellStyle: panelStyle({
|
||||||
|
fillColor: COLORS.oracleRed,
|
||||||
|
strokeColor: COLORS.oracleRed,
|
||||||
|
arcSize: 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundary(page, x, y, w, h, heading, options = {}) {
|
||||||
|
const box = page.vertex({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
value: "",
|
||||||
|
cellStyle: panelStyle({
|
||||||
|
fillColor: options.fillColor || COLORS.regionFill,
|
||||||
|
strokeColor: options.strokeColor || COLORS.regionStroke,
|
||||||
|
strokeWidth: options.strokeWidth || 1,
|
||||||
|
dashed: options.dashed,
|
||||||
|
arcSize: options.arcSize || 7,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
page.vertex({
|
||||||
|
x: x + 14,
|
||||||
|
y: y + 8,
|
||||||
|
w: w - 28,
|
||||||
|
h: 26,
|
||||||
|
value: `<b>${heading}</b>`,
|
||||||
|
cellStyle: labelStyle({
|
||||||
|
fontSize: options.fontSize || 13,
|
||||||
|
fontColor: options.headingColor || COLORS.ink,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOverviewPage(library) {
|
||||||
|
const page = createPage("01 · 전체 구성", "hmm-overview", 1800, 1080);
|
||||||
|
title(
|
||||||
|
page,
|
||||||
|
"HMM AI 데이터 접근 아키텍처",
|
||||||
|
"사용자 Bearer MCP · Select AI · VPD · HR 정책 벡터 검색 · AWS RDS PostgreSQL Federation",
|
||||||
|
1800,
|
||||||
|
);
|
||||||
|
|
||||||
|
boundary(page, 30, 120, 190, 790, "HMM 사용자", {
|
||||||
|
fillColor: COLORS.panelSoft,
|
||||||
|
});
|
||||||
|
page.officialIcon(library, "Identity and Security - User", 80, 165);
|
||||||
|
const users = page.vertex({
|
||||||
|
x: 54,
|
||||||
|
y: 270,
|
||||||
|
w: 142,
|
||||||
|
h: 150,
|
||||||
|
value: "<b>팀장 · E1001</b><br>팀원 · E1002~E1007<br>HR 관리자<br><br><font color=\"#5B5652\">같은 질문이라도 역할과 보고 관계에 따라 다른 행을 조회</font>",
|
||||||
|
cellStyle: panelStyle({ fillColor: "#FFFFFF", strokeColor: "#D4CFCA" }),
|
||||||
|
});
|
||||||
|
const externalAgent = page.vertex({
|
||||||
|
x: 54,
|
||||||
|
y: 465,
|
||||||
|
w: 142,
|
||||||
|
h: 115,
|
||||||
|
value: "<b>외부 Agent</b><br>AI Database Private<br>Agent Factory<br><br><font color=\"#5B5652\">MCP client</font>",
|
||||||
|
cellStyle: panelStyle({ fillColor: "#FFFFFF", strokeColor: "#D4CFCA" }),
|
||||||
|
});
|
||||||
|
page.vertex({
|
||||||
|
x: 54,
|
||||||
|
y: 625,
|
||||||
|
w: 142,
|
||||||
|
h: 210,
|
||||||
|
value: "<b>공개 HTTPS</b><br><br>hmm.cloud-handson.com<br><br>hmm-backoffice.cloud-handson.com/mcp<br><br><font color=\"#AE562C\">사용자 Bearer Token</font><br><font color=\"#5B5652\">TLS · HttpOnly portal cookie</font>",
|
||||||
|
cellStyle: panelStyle({ fillColor: "#FFFFFF", strokeColor: COLORS.oracleOrange }),
|
||||||
|
});
|
||||||
|
|
||||||
|
boundary(page, 250, 120, 1260, 900, "Oracle Cloud Infrastructure", {
|
||||||
|
fillColor: "#FBFAF9",
|
||||||
|
});
|
||||||
|
boundary(page, 280, 165, 930, 815, "OCI Region · ap-seoul-1", {
|
||||||
|
fillColor: COLORS.regionFill,
|
||||||
|
});
|
||||||
|
boundary(page, 315, 210, 480, 320, "VCN · Public application subnet", {
|
||||||
|
fillColor: "#FFFFFF",
|
||||||
|
strokeColor: COLORS.oracleOrange,
|
||||||
|
dashed: true,
|
||||||
|
headingColor: COLORS.oracleOrange,
|
||||||
|
});
|
||||||
|
|
||||||
|
page.officialIcon(library, "Compute - Virtual Machine VM", 340, 265);
|
||||||
|
const compute = page.vertex({
|
||||||
|
x: 445,
|
||||||
|
y: 260,
|
||||||
|
w: 320,
|
||||||
|
h: 235,
|
||||||
|
value: "",
|
||||||
|
cellStyle: panelStyle({ fillColor: "#FCFBFA", strokeColor: "#D4CFCA" }),
|
||||||
|
});
|
||||||
|
page.vertex({
|
||||||
|
x: 462,
|
||||||
|
y: 274,
|
||||||
|
w: 286,
|
||||||
|
h: 34,
|
||||||
|
value: "<b>Compute VM · 132.226.232.69</b>",
|
||||||
|
cellStyle: labelStyle({ fontSize: 13, bold: true }),
|
||||||
|
});
|
||||||
|
const nginx = page.vertex({
|
||||||
|
x: 470,
|
||||||
|
y: 320,
|
||||||
|
w: 270,
|
||||||
|
h: 42,
|
||||||
|
value: "<b>Nginx</b> · TLS / reverse proxy",
|
||||||
|
cellStyle: panelStyle({ fillColor: "#FFFFFF", strokeColor: "#D4CFCA" }),
|
||||||
|
});
|
||||||
|
const consoleApp = page.vertex({
|
||||||
|
x: 470,
|
||||||
|
y: 376,
|
||||||
|
w: 270,
|
||||||
|
h: 46,
|
||||||
|
value: "<b>AI Web Agent Console</b><br>Streamlit · 질문/근거/감사 UI",
|
||||||
|
cellStyle: panelStyle({ fillColor: "#FFFFFF", strokeColor: COLORS.blue }),
|
||||||
|
});
|
||||||
|
const backoffice = page.vertex({
|
||||||
|
x: 470,
|
||||||
|
y: 436,
|
||||||
|
w: 270,
|
||||||
|
h: 46,
|
||||||
|
value: "<b>VPD Backoffice</b><br>Spring Boot · `/mcp` · 권한 관리",
|
||||||
|
cellStyle: panelStyle({ fillColor: "#FFFFFF", strokeColor: COLORS.oracleRed }),
|
||||||
|
});
|
||||||
|
|
||||||
|
page.officialIcon(library, "Identity and Security - Vault", 330, 635);
|
||||||
|
const vault = page.vertex({
|
||||||
|
x: 420,
|
||||||
|
y: 642,
|
||||||
|
w: 160,
|
||||||
|
h: 76,
|
||||||
|
value: "<b>Vault / Secrets</b><br>DB·OCI 자격증명<br><font color=\"#5B5652\">Git 외부 관리</font>",
|
||||||
|
cellStyle: panelStyle({ fillColor: "#FFFFFF", strokeColor: "#D4CFCA" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
page.officialIcon(library, "Storage - Object Storage", 330, 800);
|
||||||
|
const objectStorage = page.vertex({
|
||||||
|
x: 445,
|
||||||
|
y: 804,
|
||||||
|
w: 180,
|
||||||
|
h: 82,
|
||||||
|
value: "<b>Object Storage</b><br>HR 규정 PDF 원본<br>파일명·생성일 메타데이터",
|
||||||
|
cellStyle: panelStyle({ fillColor: "#FFFFFF", strokeColor: "#D4CFCA" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
page.officialIcon(library, "Database - Autonomous DB", 805, 225);
|
||||||
|
const adb = page.vertex({
|
||||||
|
x: 905,
|
||||||
|
y: 220,
|
||||||
|
w: 270,
|
||||||
|
h: 690,
|
||||||
|
value: "",
|
||||||
|
cellStyle: panelStyle({ fillColor: "#FFFFFF", strokeColor: COLORS.oracleRed, strokeWidth: 2 }),
|
||||||
|
});
|
||||||
|
page.vertex({
|
||||||
|
x: 920,
|
||||||
|
y: 238,
|
||||||
|
w: 238,
|
||||||
|
h: 40,
|
||||||
|
value: "<b>Autonomous Database · HMMAIPOC</b>",
|
||||||
|
cellStyle: labelStyle({ fontSize: 14, bold: true }),
|
||||||
|
});
|
||||||
|
const startupGate = page.vertex({
|
||||||
|
x: 925,
|
||||||
|
y: 294,
|
||||||
|
w: 230,
|
||||||
|
h: 72,
|
||||||
|
value: "<b>MCP Tool 계약</b><br>BACKOFFICE_MCP_TOOLS<br><font color=\"#AE562C\">시작 시 USER_AI_AGENT_TOOLS.ENABLED 검증</font>",
|
||||||
|
cellStyle: panelStyle({ fillColor: "#FFF7F2", strokeColor: COLORS.oracleOrange }),
|
||||||
|
});
|
||||||
|
const agentTools = page.vertex({
|
||||||
|
x: 925,
|
||||||
|
y: 382,
|
||||||
|
w: 230,
|
||||||
|
h: 78,
|
||||||
|
value: "<b>ADB Agent Tools</b><br>resolve_hr_term<br>search_hr_policy<br><font color=\"#5B5652\">DBMS_CLOUD_AI_AGENT.RUN_TOOL</font>",
|
||||||
|
cellStyle: panelStyle({ fillColor: "#FCFBFA", strokeColor: "#D4CFCA" }),
|
||||||
|
});
|
||||||
|
const selectAi = page.vertex({
|
||||||
|
x: 925,
|
||||||
|
y: 475,
|
||||||
|
w: 230,
|
||||||
|
h: 80,
|
||||||
|
value: "<b>search_hr_data · SELECT_AI</b><br>ADMIN: SHOWSQL 생성/검증<br>CB_ORDS: 읽기 전용 SQL 실행",
|
||||||
|
cellStyle: panelStyle({ fillColor: "#F4F8FA", strokeColor: COLORS.blue }),
|
||||||
|
});
|
||||||
|
const vpd = page.vertex({
|
||||||
|
x: 925,
|
||||||
|
y: 570,
|
||||||
|
w: 230,
|
||||||
|
h: 92,
|
||||||
|
value: "<b>Bearer → Secure Context → VPD</b><br>HMM_ACCESS_CTX<br>HMM_LEAVE_SCOPE_POLICY<br><font color=\"#C74634\">SELF · MANAGED_TEAM · ALL</font>",
|
||||||
|
cellStyle: panelStyle({ fillColor: "#FFF7F2", strokeColor: COLORS.oracleRed }),
|
||||||
|
});
|
||||||
|
const hrData = page.vertex({
|
||||||
|
x: 925,
|
||||||
|
y: 678,
|
||||||
|
w: 230,
|
||||||
|
h: 72,
|
||||||
|
value: "<b>HMM HR 정형 데이터</b><br>직원·팀·휴가·근태<br>역할·그룹·권한·Token hash",
|
||||||
|
cellStyle: panelStyle({ fillColor: "#FCFBFA", strokeColor: "#D4CFCA" }),
|
||||||
|
});
|
||||||
|
const vectorData = page.vertex({
|
||||||
|
x: 925,
|
||||||
|
y: 765,
|
||||||
|
w: 230,
|
||||||
|
h: 62,
|
||||||
|
value: "<b>정책 지식</b><br>Abstract · Tag · Chunk · Embed 4 Vector",
|
||||||
|
cellStyle: panelStyle({ fillColor: "#FCFBFA", strokeColor: "#D4CFCA" }),
|
||||||
|
});
|
||||||
|
const federation = page.vertex({
|
||||||
|
x: 925,
|
||||||
|
y: 842,
|
||||||
|
w: 230,
|
||||||
|
h: 52,
|
||||||
|
value: "<b>Federation View</b><br>HMM_RDS_*_V · 선사 배정 View",
|
||||||
|
cellStyle: panelStyle({ fillColor: "#FCFBFA", strokeColor: "#D4CFCA" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
boundary(page, 1235, 165, 240, 300, "OCI Region · us-chicago-1", {
|
||||||
|
fillColor: COLORS.regionFill,
|
||||||
|
});
|
||||||
|
page.officialIcon(library, "Analytics and AI - Artificial Intelligence", 1260, 220);
|
||||||
|
const genai = page.vertex({
|
||||||
|
x: 1260,
|
||||||
|
y: 320,
|
||||||
|
w: 190,
|
||||||
|
h: 110,
|
||||||
|
value: "<b>OCI Generative AI</b><br><br>GPT-5.4 Mini<br><font color=\"#5B5652\">질문 계획 · SQL 생성 · 답변</font><br><br>Cohere Embed 4<br><font color=\"#5B5652\">용어·문서 Vector</font>",
|
||||||
|
cellStyle: panelStyle({ fillColor: "#FFFFFF", strokeColor: COLORS.oracleOrange }),
|
||||||
|
});
|
||||||
|
|
||||||
|
boundary(page, 1535, 120, 235, 900, "3rd Party Cloud · AWS", {
|
||||||
|
fillColor: "#FFFFFF",
|
||||||
|
strokeColor: COLORS.thirdParty,
|
||||||
|
dashed: true,
|
||||||
|
headingColor: COLORS.thirdParty,
|
||||||
|
});
|
||||||
|
const rds = page.vertex({
|
||||||
|
x: 1570,
|
||||||
|
y: 530,
|
||||||
|
w: 165,
|
||||||
|
h: 180,
|
||||||
|
value: "<b>AWS RDS PostgreSQL</b><br><br>hmm_demo.carriers<br>carrier_monthly_performance<br><br><font color=\"#5B5652\">가상 선사 8개<br>월간 실적 144행</font>",
|
||||||
|
cellStyle: style({
|
||||||
|
shape: "cylinder3",
|
||||||
|
boundedLbl: 1,
|
||||||
|
backgroundOutline: 1,
|
||||||
|
size: 15,
|
||||||
|
whiteSpace: "wrap",
|
||||||
|
html: 1,
|
||||||
|
fillColor: "#F9FAFB",
|
||||||
|
strokeColor: COLORS.thirdParty,
|
||||||
|
fontFamily: "Oracle Sans",
|
||||||
|
fontColor: COLORS.ink,
|
||||||
|
fontSize: 12,
|
||||||
|
spacingTop: 12,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
page.edge({
|
||||||
|
source: users,
|
||||||
|
target: nginx,
|
||||||
|
value: "HTTPS · 질문/관리",
|
||||||
|
cellStyle: edgeStyle({ strokeColor: COLORS.blue, exitX: 1, exitY: 0.35, entryX: 0, entryY: 0.5 }),
|
||||||
|
});
|
||||||
|
page.edge({
|
||||||
|
source: externalAgent,
|
||||||
|
target: backoffice,
|
||||||
|
value: "MCP initialize · tools/list · tools/call",
|
||||||
|
cellStyle: edgeStyle({ strokeColor: COLORS.oracleRed, exitX: 1, exitY: 0.5, entryX: 0, entryY: 0.5 }),
|
||||||
|
});
|
||||||
|
page.edge({
|
||||||
|
source: nginx,
|
||||||
|
target: consoleApp,
|
||||||
|
value: "",
|
||||||
|
cellStyle: edgeStyle({ strokeColor: COLORS.blue, exitX: 0.5, exitY: 1, entryX: 0.5, entryY: 0 }),
|
||||||
|
});
|
||||||
|
page.edge({
|
||||||
|
source: consoleApp,
|
||||||
|
target: backoffice,
|
||||||
|
value: "MCP",
|
||||||
|
cellStyle: edgeStyle({ strokeColor: COLORS.oracleRed, exitX: 1, exitY: 0.5, entryX: 1, entryY: 0.5 }),
|
||||||
|
points: [[780, 399], [780, 459]],
|
||||||
|
});
|
||||||
|
page.edge({
|
||||||
|
source: backoffice,
|
||||||
|
target: startupGate,
|
||||||
|
value: "JDBC · Tool 호출",
|
||||||
|
cellStyle: edgeStyle({ strokeColor: COLORS.oracleRed, exitX: 1, exitY: 0.5, entryX: 0, entryY: 0.5 }),
|
||||||
|
points: [[805, 459], [885, 459], [885, 330]],
|
||||||
|
});
|
||||||
|
page.edge({
|
||||||
|
source: startupGate,
|
||||||
|
target: agentTools,
|
||||||
|
value: "ENABLED",
|
||||||
|
cellStyle: edgeStyle({ strokeColor: COLORS.success, exitX: 0.5, exitY: 1, entryX: 0.5, entryY: 0 }),
|
||||||
|
});
|
||||||
|
page.edge({
|
||||||
|
source: startupGate,
|
||||||
|
target: selectAi,
|
||||||
|
value: "Java Tool",
|
||||||
|
cellStyle: edgeStyle({ strokeColor: COLORS.blue, exitX: 1, exitY: 0.5, entryX: 1, entryY: 0.5 }),
|
||||||
|
points: [[1170, 330], [1170, 515]],
|
||||||
|
});
|
||||||
|
page.edge({
|
||||||
|
source: selectAi,
|
||||||
|
target: vpd,
|
||||||
|
value: "동일 CB_ORDS session",
|
||||||
|
cellStyle: edgeStyle({ strokeColor: COLORS.oracleRed, exitX: 0.5, exitY: 1, entryX: 0.5, entryY: 0 }),
|
||||||
|
});
|
||||||
|
page.edge({
|
||||||
|
source: vpd,
|
||||||
|
target: hrData,
|
||||||
|
value: "predicate 자동 적용",
|
||||||
|
cellStyle: edgeStyle({ strokeColor: COLORS.oracleRed, exitX: 0.5, exitY: 1, entryX: 0.5, entryY: 0 }),
|
||||||
|
});
|
||||||
|
page.edge({
|
||||||
|
source: objectStorage,
|
||||||
|
target: vectorData,
|
||||||
|
value: "PDF ingest · 청킹",
|
||||||
|
cellStyle: edgeStyle({ dashed: true, strokeColor: COLORS.oracleOrange, exitX: 1, exitY: 0.5, entryX: 0, entryY: 0.5 }),
|
||||||
|
});
|
||||||
|
page.edge({
|
||||||
|
source: vault,
|
||||||
|
target: compute,
|
||||||
|
value: "runtime secret",
|
||||||
|
cellStyle: edgeStyle({ dashed: true, strokeColor: COLORS.regionStroke, exitX: 0.5, exitY: 0, entryX: 0, entryY: 1 }),
|
||||||
|
});
|
||||||
|
page.edge({
|
||||||
|
source: adb,
|
||||||
|
target: genai,
|
||||||
|
value: "모델 호출",
|
||||||
|
cellStyle: edgeStyle({ dashed: true, strokeColor: COLORS.oracleOrange, exitX: 1, exitY: 0.28, entryX: 0, entryY: 0.55 }),
|
||||||
|
});
|
||||||
|
page.edge({
|
||||||
|
source: federation,
|
||||||
|
target: rds,
|
||||||
|
value: "HMM_RDS_PG_LINK · TLS 5432",
|
||||||
|
cellStyle: edgeStyle({ strokeColor: COLORS.thirdParty, exitX: 1, exitY: 0.5, entryX: 0, entryY: 0.5 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
page.vertex({
|
||||||
|
x: 1545,
|
||||||
|
y: 830,
|
||||||
|
w: 215,
|
||||||
|
h: 105,
|
||||||
|
value: "<b>읽기 책임 분리</b><br>RDS: 선사 실적 원장<br>ADB: 직원·배정·권한·VPD<br>MCP: 허용된 View만 자연어 조회",
|
||||||
|
cellStyle: panelStyle({ fillColor: "#F9FAFB", strokeColor: COLORS.thirdParty }),
|
||||||
|
});
|
||||||
|
page.vertex({
|
||||||
|
x: 280,
|
||||||
|
y: 985,
|
||||||
|
w: 1195,
|
||||||
|
h: 24,
|
||||||
|
value: "※ hmm-mcp.cloud-handson.com은 공용 호환 게이트웨이입니다. 사용자별 VPD MCP의 기준 주소는 hmm-backoffice.cloud-handson.com/mcp입니다.",
|
||||||
|
cellStyle: labelStyle({ fontSize: 11, fontColor: COLORS.muted }),
|
||||||
|
});
|
||||||
|
return page.result();
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberedBox(page, number, x, y, w, h, heading, body, options = {}) {
|
||||||
|
const box = page.vertex({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
value: `<b>${heading}</b><br>${body}`,
|
||||||
|
cellStyle: panelStyle({
|
||||||
|
fillColor: options.fillColor || "#FFFFFF",
|
||||||
|
strokeColor: options.strokeColor || "#D4CFCA",
|
||||||
|
align: "left",
|
||||||
|
verticalAlign: "middle",
|
||||||
|
spacing: 10,
|
||||||
|
spacingLeft: 22,
|
||||||
|
spacingRight: 8,
|
||||||
|
fontSize: options.fontSize || 11,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
page.vertex({
|
||||||
|
x: x - 12,
|
||||||
|
y: y + 10,
|
||||||
|
w: 30,
|
||||||
|
h: 30,
|
||||||
|
value: `<b><font color=\"#FFFFFF\">${number}</font></b>`,
|
||||||
|
cellStyle: style({
|
||||||
|
ellipse: 1,
|
||||||
|
shape: "ellipse",
|
||||||
|
whiteSpace: "wrap",
|
||||||
|
html: 1,
|
||||||
|
fillColor: options.badgeColor || COLORS.oracleRed,
|
||||||
|
strokeColor: options.badgeColor || COLORS.oracleRed,
|
||||||
|
fontFamily: "Oracle Sans",
|
||||||
|
fontSize: 13,
|
||||||
|
align: "center",
|
||||||
|
verticalAlign: "middle",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSecurityFlowPage(library) {
|
||||||
|
const page = createPage("02 · MCP 요청과 VPD", "hmm-security-flow", 1800, 1050);
|
||||||
|
title(
|
||||||
|
page,
|
||||||
|
"MCP Tool Discovery와 사용자별 VPD 실행 흐름",
|
||||||
|
"광고 계약 검증은 시작 시 1회 · 사용자 인증과 Context 설정·SQL 실행·정리는 요청마다 같은 세션에서 수행",
|
||||||
|
1800,
|
||||||
|
);
|
||||||
|
|
||||||
|
boundary(page, 35, 120, 1730, 185, "서버 시작 · Tool 광고 전 Fail-Closed Gate", {
|
||||||
|
fillColor: "#FFF7F2",
|
||||||
|
strokeColor: COLORS.oracleOrange,
|
||||||
|
headingColor: COLORS.oracleOrange,
|
||||||
|
});
|
||||||
|
const config = numberedBox(page, "A", 95, 185, 300, 78, "BACKOFFICE_MCP_TOOLS", "공개 이름 · 설명 · 입력 schema<br>AGENT_TOOL targetName", {
|
||||||
|
strokeColor: COLORS.oracleOrange,
|
||||||
|
badgeColor: COLORS.oracleOrange,
|
||||||
|
});
|
||||||
|
const validator = numberedBox(page, "B", 510, 185, 330, 78, "McpAgentToolStartupValidator", "AGENT_TOOL targetName 중복 제거<br>SELECT_AI는 DB Tool 검증에서 제외", {
|
||||||
|
strokeColor: COLORS.oracleOrange,
|
||||||
|
badgeColor: COLORS.oracleOrange,
|
||||||
|
});
|
||||||
|
const metadata = numberedBox(page, "C", 955, 185, 330, 78, "USER_AI_AGENT_TOOLS", "Tool 존재 + STATUS=ENABLED 확인", {
|
||||||
|
strokeColor: COLORS.oracleOrange,
|
||||||
|
badgeColor: COLORS.oracleOrange,
|
||||||
|
});
|
||||||
|
const ready = numberedBox(page, "D", 1400, 185, 285, 78, "Endpoint Ready", "모두 정상일 때만 tools/list 광고<br><font color=\"#C74634\">누락·비활성·조회 오류는 기동 실패</font>", {
|
||||||
|
strokeColor: COLORS.success,
|
||||||
|
badgeColor: COLORS.success,
|
||||||
|
});
|
||||||
|
page.edge({ source: config, target: validator, value: "구성 로드", cellStyle: edgeStyle({ strokeColor: COLORS.oracleOrange }) });
|
||||||
|
page.edge({ source: validator, target: metadata, value: "시작 시 1회 조회", cellStyle: edgeStyle({ strokeColor: COLORS.oracleOrange }) });
|
||||||
|
page.edge({ source: metadata, target: ready, value: "모두 ENABLED", cellStyle: edgeStyle({ strokeColor: COLORS.success }) });
|
||||||
|
|
||||||
|
boundary(page, 35, 330, 225, 665, "MCP Client", { fillColor: "#F9FAFB" });
|
||||||
|
page.officialIcon(library, "Identity and Security - User", 95, 385);
|
||||||
|
const client = numberedBox(page, 1, 70, 500, 160, 100, "Agent 요청", "initialize<br>tools/list<br>tools/call", {
|
||||||
|
strokeColor: COLORS.blue,
|
||||||
|
badgeColor: COLORS.blue,
|
||||||
|
});
|
||||||
|
const response = numberedBox(page, 10, 70, 800, 160, 115, "필터링 결과", "허용 행 + 근거만 반환<br>Token·내부 SQL은 미노출", {
|
||||||
|
strokeColor: COLORS.success,
|
||||||
|
badgeColor: COLORS.success,
|
||||||
|
});
|
||||||
|
|
||||||
|
boundary(page, 285, 330, 650, 665, "VPD Backoffice · Spring Boot", {
|
||||||
|
fillColor: "#FCFBFA",
|
||||||
|
});
|
||||||
|
const auth = numberedBox(page, 2, 330, 400, 250, 110, "Bearer 인증", "Token SHA-256 hash<br>만료·회수·재직 상태 검증<br><font color=\"#C74634\">실패: HTTP 401</font>", {
|
||||||
|
strokeColor: COLORS.oracleRed,
|
||||||
|
});
|
||||||
|
const router = numberedBox(page, 3, 650, 400, 235, 110, "MCP Tool Router", "catalog에서 이름·인자 검증<br>AGENT_TOOL / SELECT_AI 분기", {
|
||||||
|
strokeColor: COLORS.oracleRed,
|
||||||
|
});
|
||||||
|
const agentTool = numberedBox(page, 4, 350, 590, 250, 130, "ADB AGENT_TOOL", "resolve_hr_term · search_hr_policy<br>동일 기본 JDBC session<br>SET_USER_BY_BEARER<br>→ RUN_TOOL → CLEAR_USER", {
|
||||||
|
strokeColor: COLORS.oracleOrange,
|
||||||
|
badgeColor: COLORS.oracleOrange,
|
||||||
|
fontSize: 10,
|
||||||
|
});
|
||||||
|
const showSql = numberedBox(page, 5, 650, 590, 235, 125, "search_hr_data", "ADMIN 생성 세션<br>DBMS_CLOUD_AI.GENERATE<br>action=showsql<br>SELECT/WITH + 승인 객체 검증", {
|
||||||
|
strokeColor: COLORS.blue,
|
||||||
|
badgeColor: COLORS.blue,
|
||||||
|
});
|
||||||
|
page.edge({ source: client, target: auth, value: "Authorization: Bearer …", cellStyle: edgeStyle({ strokeColor: COLORS.oracleRed }) });
|
||||||
|
page.edge({ source: auth, target: router, value: "인증된 employee", cellStyle: edgeStyle({ strokeColor: COLORS.oracleRed }) });
|
||||||
|
page.edge({ source: router, target: agentTool, value: "AGENT_TOOL", cellStyle: edgeStyle({ strokeColor: COLORS.oracleOrange, exitX: 0.25, exitY: 1, entryX: 0.5, entryY: 0 }) });
|
||||||
|
page.edge({ source: router, target: showSql, value: "SELECT_AI", cellStyle: edgeStyle({ strokeColor: COLORS.blue, exitX: 0.75, exitY: 1, entryX: 0.5, entryY: 0 }) });
|
||||||
|
|
||||||
|
boundary(page, 960, 330, 805, 665, "Autonomous Database · 보안 실행 경계", {
|
||||||
|
fillColor: COLORS.regionFill,
|
||||||
|
strokeColor: COLORS.oracleRed,
|
||||||
|
});
|
||||||
|
page.officialIcon(library, "Database - Autonomous DB", 995, 370);
|
||||||
|
const setContext = numberedBox(page, 6, 1100, 390, 285, 110, "CB_ORDS 동일 JDBC 세션", "CB_ORDS_HANDLER_PKG<br>SET_VPD_CONTEXT<br>Runtime principal·context 일치 검증", {
|
||||||
|
strokeColor: COLORS.oracleRed,
|
||||||
|
});
|
||||||
|
const context = numberedBox(page, 7, 1445, 390, 270, 110, "Secure Application Context", "HMM_ACCESS_CTX_PKG<br>SET_USER_BY_BEARER<br>EMPLOYEE_ID · CODE · TEAM_ID", {
|
||||||
|
strokeColor: COLORS.oracleRed,
|
||||||
|
});
|
||||||
|
const execute = numberedBox(page, 8, 1100, 585, 285, 125, "읽기 전용 SQL 실행", "CB_ORDS · SET TRANSACTION READ ONLY<br>EXEMPT ACCESS POLICY 없음<br>승인된 HR Table/View만 조회", {
|
||||||
|
strokeColor: COLORS.blue,
|
||||||
|
badgeColor: COLORS.blue,
|
||||||
|
});
|
||||||
|
const policy = numberedBox(page, "8a", 1445, 585, 270, 125, "HMM_LEAVE_SCOPE_POLICY", "SYS_CONTEXT 기반 predicate 자동 적용<br><b>VIEWER → SELF</b><br><b>MANAGER → MANAGED_TEAM</b><br><b>ADMIN role → ALL</b>", {
|
||||||
|
strokeColor: COLORS.oracleRed,
|
||||||
|
});
|
||||||
|
const cleanup = numberedBox(page, 9, 1100, 805, 615, 95, "항상 정리", "결과 읽기 → ROLLBACK → CB_ORDS_HANDLER_PKG · CLEAR_VPD_CONTEXT<br><font color=\"#5B5652\">성공·실패와 무관하게 session context와 client identifier 제거</font>", {
|
||||||
|
strokeColor: COLORS.success,
|
||||||
|
badgeColor: COLORS.success,
|
||||||
|
});
|
||||||
|
page.edge({ source: showSql, target: setContext, value: "검증된 SQL + 원문 Bearer", cellStyle: edgeStyle({ strokeColor: COLORS.oracleRed }) });
|
||||||
|
page.edge({
|
||||||
|
source: agentTool,
|
||||||
|
target: response,
|
||||||
|
value: "Agent Tool 결과 · CLEAR_USER",
|
||||||
|
cellStyle: edgeStyle({ dashed: true, strokeColor: COLORS.success, exitX: 1, exitY: 0.5, entryX: 1, entryY: 0.5 }),
|
||||||
|
points: [[635, 655], [635, 760], [280, 760], [280, 855]],
|
||||||
|
});
|
||||||
|
page.edge({ source: setContext, target: context, value: "SET_USER_BY_BEARER", cellStyle: edgeStyle({ strokeColor: COLORS.oracleRed }) });
|
||||||
|
page.edge({ source: setContext, target: execute, value: "context 확인 후 실행", cellStyle: edgeStyle({ strokeColor: COLORS.blue, exitX: 0.5, exitY: 1, entryX: 0.5, entryY: 0 }) });
|
||||||
|
page.edge({ source: context, target: policy, value: "SYS_CONTEXT", cellStyle: edgeStyle({ strokeColor: COLORS.oracleRed, exitX: 0.5, exitY: 1, entryX: 0.5, entryY: 0 }) });
|
||||||
|
page.edge({ source: execute, target: policy, value: "SELECT → predicate 주입", cellStyle: edgeStyle({ strokeColor: COLORS.oracleRed }) });
|
||||||
|
page.edge({ source: policy, target: cleanup, value: "허용 행", cellStyle: edgeStyle({ strokeColor: COLORS.success, exitX: 0.5, exitY: 1, entryX: 0.8, entryY: 0 }) });
|
||||||
|
page.edge({ source: execute, target: cleanup, value: "result set", cellStyle: edgeStyle({ strokeColor: COLORS.success, exitX: 0.5, exitY: 1, entryX: 0.2, entryY: 0 }) });
|
||||||
|
page.edge({
|
||||||
|
source: cleanup,
|
||||||
|
target: response,
|
||||||
|
value: "정제된 Tool 결과",
|
||||||
|
cellStyle: edgeStyle({ strokeColor: COLORS.success, exitX: 0, exitY: 0.5, entryX: 1, entryY: 0.5 }),
|
||||||
|
points: [[950, 945], [270, 945]],
|
||||||
|
});
|
||||||
|
|
||||||
|
page.vertex({
|
||||||
|
x: 330,
|
||||||
|
y: 790,
|
||||||
|
w: 555,
|
||||||
|
h: 110,
|
||||||
|
value: "<b>보안 불변조건</b><br>• Token 원문은 DB·로그·응답·Git에 저장하지 않음<br>• SQL 생성 ADMIN과 실행 CB_ORDS를 분리<br>• 컨텍스트 설정과 보호 객체 SELECT는 반드시 같은 connection<br>• 권한 또는 메타데이터가 불완전하면 결과를 축소하는 대신 요청/기동 실패",
|
||||||
|
cellStyle: panelStyle({ fillColor: "#FFF7F2", strokeColor: COLORS.oracleRed, align: "left", verticalAlign: "middle", spacing: 10 }),
|
||||||
|
});
|
||||||
|
return page.result();
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeDrawio(library) {
|
||||||
|
const diagrams = [addOverviewPage(library), addSecurityFlowPage(library)];
|
||||||
|
const mxfile = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<mxfile host="app.diagrams.net" modified="2026-08-04T00:00:00.000Z" agent="cloud-handson" version="24.2" type="device" compressed="false">',
|
||||||
|
...diagrams,
|
||||||
|
"</mxfile>",
|
||||||
|
].join("\n");
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
const output = path.join(outputDir, "hmm-ai-data-access-architecture.drawio");
|
||||||
|
fs.writeFileSync(output, mxfile, "utf8");
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
const library = readLibrary(libraryPath);
|
||||||
|
const output = writeDrawio(library);
|
||||||
|
console.log(output);
|
||||||
82
tools/architecture/validate-hmm-architecture.mjs
Normal file
82
tools/architecture/validate-hmm-architecture.mjs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const architectureDir = path.resolve(process.argv[2] || "docs/architecture");
|
||||||
|
const drawioPath = path.join(
|
||||||
|
architectureDir,
|
||||||
|
"hmm-ai-data-access-architecture.drawio",
|
||||||
|
);
|
||||||
|
const drawio = fs.readFileSync(drawioPath, "utf8");
|
||||||
|
const diagrams = [...drawio.matchAll(
|
||||||
|
/<diagram\b[^>]*name="([^"]+)"[^>]*>([\s\S]*?)<\/diagram>/g,
|
||||||
|
)];
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(diagrams.length === 2, `Expected 2 pages, found ${diagrams.length}`);
|
||||||
|
assert(
|
||||||
|
diagrams.map((match) => match[1]).join("|") ===
|
||||||
|
"01 · 전체 구성|02 · MCP 요청과 VPD",
|
||||||
|
"Unexpected Draw.io page names",
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [name, page] of diagrams.map((match) => [match[1], match[2]])) {
|
||||||
|
const ids = [...page.matchAll(/<mxCell\b[^>]*\bid="([^"]+)"/g)]
|
||||||
|
.map((match) => match[1]);
|
||||||
|
assert(ids.length === new Set(ids).size, `Duplicate mxCell id in ${name}`);
|
||||||
|
assert(page.includes("shape=stencil("), `OCI stencil missing in ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredText = [
|
||||||
|
"Autonomous Database",
|
||||||
|
"OCI Generative AI",
|
||||||
|
"Object Storage",
|
||||||
|
"AWS RDS PostgreSQL",
|
||||||
|
"BACKOFFICE_MCP_TOOLS",
|
||||||
|
"USER_AI_AGENT_TOOLS.ENABLED",
|
||||||
|
"CB_ORDS",
|
||||||
|
"HMM_ACCESS_CTX",
|
||||||
|
"HMM_LEAVE_SCOPE_POLICY",
|
||||||
|
"HMM_RDS_PG_LINK",
|
||||||
|
"SET_USER_BY_BEARER",
|
||||||
|
"CLEAR_VPD_CONTEXT",
|
||||||
|
];
|
||||||
|
for (const text of requiredText) {
|
||||||
|
assert(drawio.includes(text), `Required architecture text missing: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const forbidden = [
|
||||||
|
/vpd_live_[A-Za-z0-9_-]{8,}/,
|
||||||
|
/Bearer\s+[A-Za-z0-9_-]{20,}/,
|
||||||
|
/BEGIN (?:RSA |OPENSSH )?PRIVATE KEY/,
|
||||||
|
/Dhfkzmf#/,
|
||||||
|
];
|
||||||
|
for (const pattern of forbidden) {
|
||||||
|
assert(!pattern.test(drawio), `Sensitive value pattern found: ${pattern}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderNames = [
|
||||||
|
"hmm-ai-data-access-architecture-overview",
|
||||||
|
"hmm-ai-data-access-architecture-security-flow",
|
||||||
|
];
|
||||||
|
for (const name of renderNames) {
|
||||||
|
const svg = fs.readFileSync(path.join(architectureDir, `${name}.svg`), "utf8");
|
||||||
|
assert(svg.includes("<svg"), `Invalid SVG render: ${name}`);
|
||||||
|
const png = fs.readFileSync(path.join(architectureDir, `${name}.png`));
|
||||||
|
assert(
|
||||||
|
png.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])),
|
||||||
|
`Invalid PNG render: ${name}`,
|
||||||
|
);
|
||||||
|
const width = png.readUInt32BE(16);
|
||||||
|
const height = png.readUInt32BE(20);
|
||||||
|
assert(width >= 1600 && height >= 900, `Render is too small: ${name} ${width}x${height}`);
|
||||||
|
console.log(`${name}: ${width}x${height}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("HMM OCI architecture validation: PASS");
|
||||||
@@ -37,7 +37,7 @@ public class SecurityConfig {
|
|||||||
.rememberMeCookieName("VPD_REMEMBER_ME")
|
.rememberMeCookieName("VPD_REMEMBER_ME")
|
||||||
.tokenValiditySeconds(security.rememberMeValiditySeconds())
|
.tokenValiditySeconds(security.rememberMeValiditySeconds())
|
||||||
.useSecureCookie(true)
|
.useSecureCookie(true)
|
||||||
.alwaysRemember(false));
|
.alwaysRemember(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
return http
|
return http
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
import java.util.TreeSet;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.SmartInitializingSingleton;
|
||||||
|
import org.springframework.dao.DataAccessException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/** Fails startup when an advertised ADB Agent Tool cannot actually be called. */
|
||||||
|
@Component
|
||||||
|
public class McpAgentToolStartupValidator implements SmartInitializingSingleton {
|
||||||
|
|
||||||
|
static final String TOOL_METADATA_SQL = """
|
||||||
|
SELECT TOOL_NAME, STATUS
|
||||||
|
FROM USER_AI_AGENT_TOOLS
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final Logger log =
|
||||||
|
LoggerFactory.getLogger(McpAgentToolStartupValidator.class);
|
||||||
|
|
||||||
|
private final McpToolCatalog toolCatalog;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public McpAgentToolStartupValidator(
|
||||||
|
McpToolCatalog toolCatalog,
|
||||||
|
JdbcTemplate jdbcTemplate
|
||||||
|
) {
|
||||||
|
this.toolCatalog = toolCatalog;
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterSingletonsInstantiated() {
|
||||||
|
validateAdvertisedAgentTools();
|
||||||
|
}
|
||||||
|
|
||||||
|
void validateAdvertisedAgentTools() {
|
||||||
|
Set<String> requiredTargets = new TreeSet<>();
|
||||||
|
toolCatalog.tools().stream()
|
||||||
|
.filter(McpToolDefinition::agentTool)
|
||||||
|
.map(McpToolDefinition::targetName)
|
||||||
|
.map(value -> value.toUpperCase(Locale.ROOT))
|
||||||
|
.forEach(requiredTargets::add);
|
||||||
|
|
||||||
|
if (requiredTargets.isEmpty()) {
|
||||||
|
log.info("MCP startup validation skipped: no ADB Agent Tool is advertised");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> registered = loadRegisteredTools();
|
||||||
|
List<String> missing = new ArrayList<>();
|
||||||
|
List<String> notEnabled = new ArrayList<>();
|
||||||
|
for (String requiredTarget : requiredTargets) {
|
||||||
|
if (!registered.containsKey(requiredTarget)) {
|
||||||
|
missing.add(requiredTarget);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String status = registered.get(requiredTarget);
|
||||||
|
if (!"ENABLED".equals(status)) {
|
||||||
|
notEnabled.add(requiredTarget + "=" + status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!missing.isEmpty() || !notEnabled.isEmpty()) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Advertised MCP Agent Tool validation failed: missing=" + missing
|
||||||
|
+ ", notEnabled=" + notEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Validated {} advertised ADB Agent Tool(s): {}",
|
||||||
|
requiredTargets.size(), requiredTargets);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> loadRegisteredTools() {
|
||||||
|
try {
|
||||||
|
Map<String, String> registered = new TreeMap<>();
|
||||||
|
for (Map<String, Object> row : jdbcTemplate.queryForList(TOOL_METADATA_SQL)) {
|
||||||
|
String toolName = normalized(row, "TOOL_NAME");
|
||||||
|
if (!toolName.isBlank()) {
|
||||||
|
String status = normalized(row, "STATUS");
|
||||||
|
registered.put(toolName, status.isBlank() ? "<NULL>" : status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return registered;
|
||||||
|
} catch (DataAccessException exception) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Unable to validate advertised MCP Agent Tools in USER_AI_AGENT_TOOLS",
|
||||||
|
exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalized(Map<String, Object> row, String column) {
|
||||||
|
Object value = row.get(column);
|
||||||
|
if (value == null) {
|
||||||
|
value = row.entrySet().stream()
|
||||||
|
.filter(entry -> column.equalsIgnoreCase(entry.getKey()))
|
||||||
|
.map(Map.Entry::getValue)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
return value == null ? "" : value.toString().trim().toUpperCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.service;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.dao.DataAccessResourceFailureException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
class McpAgentToolStartupValidatorTest {
|
||||||
|
|
||||||
|
private final McpToolCatalog toolCatalog = mock(McpToolCatalog.class);
|
||||||
|
private final JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
|
||||||
|
private final McpAgentToolStartupValidator validator =
|
||||||
|
new McpAgentToolStartupValidator(toolCatalog, jdbcTemplate);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsEnabledAgentToolsAndChecksDuplicateTargetOnce() {
|
||||||
|
when(toolCatalog.tools()).thenReturn(List.of(
|
||||||
|
agentTool("resolve_hr_term", "HMM_HR_TERM_RESOLVER"),
|
||||||
|
agentTool("resolve_hr_alias", "HMM_HR_TERM_RESOLVER"),
|
||||||
|
agentTool("search_hr_policy", "HMM_HR_POLICY_SEARCH")
|
||||||
|
));
|
||||||
|
when(jdbcTemplate.queryForList(McpAgentToolStartupValidator.TOOL_METADATA_SQL))
|
||||||
|
.thenReturn(List.of(
|
||||||
|
Map.of("TOOL_NAME", "HMM_HR_TERM_RESOLVER", "STATUS", "ENABLED"),
|
||||||
|
Map.of("TOOL_NAME", "HMM_HR_POLICY_SEARCH", "STATUS", "enabled")
|
||||||
|
));
|
||||||
|
|
||||||
|
validator.validateAdvertisedAgentTools();
|
||||||
|
|
||||||
|
verify(jdbcTemplate).queryForList(McpAgentToolStartupValidator.TOOL_METADATA_SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMissingAgentTool() {
|
||||||
|
when(toolCatalog.tools()).thenReturn(List.of(
|
||||||
|
agentTool("search_hr_policy", "HMM_HR_POLICY_SEARCH")
|
||||||
|
));
|
||||||
|
when(jdbcTemplate.queryForList(McpAgentToolStartupValidator.TOOL_METADATA_SQL))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
assertThatThrownBy(validator::validateAdvertisedAgentTools)
|
||||||
|
.isInstanceOf(IllegalStateException.class)
|
||||||
|
.hasMessageContaining("missing=[HMM_HR_POLICY_SEARCH]");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsDisabledAgentTool() {
|
||||||
|
when(toolCatalog.tools()).thenReturn(List.of(
|
||||||
|
agentTool("resolve_hr_term", "HMM_HR_TERM_RESOLVER")
|
||||||
|
));
|
||||||
|
when(jdbcTemplate.queryForList(McpAgentToolStartupValidator.TOOL_METADATA_SQL))
|
||||||
|
.thenReturn(List.of(
|
||||||
|
Map.of("tool_name", "hmm_hr_term_resolver", "status", "DISABLED")
|
||||||
|
));
|
||||||
|
|
||||||
|
assertThatThrownBy(validator::validateAdvertisedAgentTools)
|
||||||
|
.isInstanceOf(IllegalStateException.class)
|
||||||
|
.hasMessageContaining("notEnabled=[HMM_HR_TERM_RESOLVER=DISABLED]");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void skipsDatabaseLookupForApplicationNativeTools() {
|
||||||
|
when(toolCatalog.tools()).thenReturn(List.of(selectAiTool("search_hr_data")));
|
||||||
|
|
||||||
|
validator.validateAdvertisedAgentTools();
|
||||||
|
|
||||||
|
verify(jdbcTemplate, never())
|
||||||
|
.queryForList(McpAgentToolStartupValidator.TOOL_METADATA_SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMetadataLookupFailure() {
|
||||||
|
when(toolCatalog.tools()).thenReturn(List.of(
|
||||||
|
agentTool("search_hr_policy", "HMM_HR_POLICY_SEARCH")
|
||||||
|
));
|
||||||
|
when(jdbcTemplate.queryForList(McpAgentToolStartupValidator.TOOL_METADATA_SQL))
|
||||||
|
.thenThrow(new DataAccessResourceFailureException("metadata unavailable"));
|
||||||
|
|
||||||
|
assertThatThrownBy(validator::validateAdvertisedAgentTools)
|
||||||
|
.isInstanceOf(IllegalStateException.class)
|
||||||
|
.hasMessageContaining("USER_AI_AGENT_TOOLS")
|
||||||
|
.hasCauseInstanceOf(DataAccessResourceFailureException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private McpToolDefinition agentTool(String publicName, String targetName) {
|
||||||
|
return new McpToolDefinition(
|
||||||
|
publicName,
|
||||||
|
publicName,
|
||||||
|
"description",
|
||||||
|
"query",
|
||||||
|
"query description",
|
||||||
|
"AGENT_TOOL",
|
||||||
|
targetName,
|
||||||
|
"P_QUERY"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private McpToolDefinition selectAiTool(String publicName) {
|
||||||
|
return new McpToolDefinition(
|
||||||
|
publicName,
|
||||||
|
publicName,
|
||||||
|
"description",
|
||||||
|
"query",
|
||||||
|
"query description",
|
||||||
|
"SELECT_AI",
|
||||||
|
"",
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user