From 2e44ed0b97c8a9dd6a955e831d43570370c88e38 Mon Sep 17 00:00:00 2001 From: devmrko Date: Fri, 31 Jul 2026 13:57:04 +0900 Subject: [PATCH] refs #740: enforce HMM MCP VPD runtime boundary --- .env.example | 11 +- docs/design/740-hmm-mcp-vpd-runtime/README.md | 123 ++++++++++++++ docs/runbooks/hmm-mcp-token-configuration.md | 35 +++- sql/adb/73_hmm_mcp_vpd_runtime.sql | 159 ++++++++++++++++++ .../config/BackofficeProperties.java | 12 +- .../service/SelectAiService.java | 97 +++++++++-- src/main/resources/application.yml | 11 +- .../service/McpSseServiceTest.java | 46 +++++ 8 files changed, 468 insertions(+), 26 deletions(-) create mode 100644 docs/design/740-hmm-mcp-vpd-runtime/README.md create mode 100644 sql/adb/73_hmm_mcp_vpd_runtime.sql diff --git a/.env.example b/.env.example index 03ac372..f36b100 100644 --- a/.env.example +++ b/.env.example @@ -52,11 +52,16 @@ export BACKOFFICE_ORDS_DB_URL="${BACKOFFICE_DB_URL}" export BACKOFFICE_ORDS_DB_USERNAME="CB_ORDS" export BACKOFFICE_ORDS_DB_PASSWORD="" -# Select AI 프로파일 소유 스키마의 별도 연결. MCP 도구가 AGENT_TOOL만 사용하면 비워 둘 수 있습니다. +# Select AI 프로파일 소유 스키마 연결은 SHOWSQL 생성에만 사용합니다. export BACKOFFICE_SELECT_AI_DB_URL="${BACKOFFICE_DB_URL}" -export BACKOFFICE_SELECT_AI_DB_USERNAME="" -export BACKOFFICE_SELECT_AI_DB_PASSWORD="" +export BACKOFFICE_SELECT_AI_DB_USERNAME="${BACKOFFICE_DB_USERNAME}" +export BACKOFFICE_SELECT_AI_DB_PASSWORD="${BACKOFFICE_DB_PASSWORD}" export BACKOFFICE_SELECT_AI_PROFILE="" +# 생성 SQL은 반드시 EXEMPT ACCESS POLICY가 없는 별도 계정으로 실행합니다. +# 런타임 비밀번호는 Git에 저장하지 말고 배포 서버 secret 환경 파일에만 넣으세요. +export BACKOFFICE_SELECT_AI_RUNTIME_DB_URL="${BACKOFFICE_DB_URL}" +export BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME="CB_ORDS" +export BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD="" # --- (2c) 재사용 가능한 백오피스 카탈로그와 표시 설정 --- # 승인 객체는 key/tableName/objectType/businessName/description JSON 배열입니다. diff --git a/docs/design/740-hmm-mcp-vpd-runtime/README.md b/docs/design/740-hmm-mcp-vpd-runtime/README.md new file mode 100644 index 0000000..c26c705 --- /dev/null +++ b/docs/design/740-hmm-mcp-vpd-runtime/README.md @@ -0,0 +1,123 @@ +# #740 HMM MCP VPD 실행 경계 복구 + +## 배경 + +HMM MCP의 `search_hr_data`는 현재 `ADMIN` JDBC 세션에서 +`DBMS_CLOUD_AI_AGENT.RUN_TOOL`을 실행한다. 토큰으로 +`HMM_ACCESS_CTX`를 설정해도 `ADMIN`에는 `EXEMPT ACCESS POLICY`가 있으므로 +`ADMIN.HMM_LEAVE_BALANCES`와 `ADMIN.HMM_LEAVE_REQUESTS`의 VPD 정책이 +실제 조회에 적용되지 않는다. + +컨텍스트 값이 올바른 것과 VPD가 적용되는 것은 별개의 조건이다. 보호 테이블을 +읽는 최종 SQL은 반드시 `EXEMPT ACCESS POLICY`가 없는 계정의 동일 DB 세션에서 +컨텍스트 설정과 함께 실행해야 한다. + +## 목표 + +- `ADMIN`은 Select AI `SHOWSQL` 생성만 담당한다. +- 비면제 런타임 스키마 `CB_ORDS`가 생성 SQL을 읽기 전용으로 실행한다. +- 실행 직전 같은 `CB_ORDS` 세션에서 + `CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT`를 호출한다. +- 실행 종료 시 성공·실패와 관계없이 `CLEAR_VPD_CONTEXT`를 호출하고 롤백한다. +- 런타임 계정이 `ADMIN`이거나 `EXEMPT ACCESS POLICY`를 가진 경우 fail-closed 한다. +- MCP `search_hr_data`만 새 경계로 전환하며 용어·정책 검색 도구는 기존 경로를 유지한다. + +## 구조 + +```text +MCP tools/call + └─ bearer token 해시 인증 + ├─ ADMIN / DBMS_CLOUD_AI.GENERATE(..., 'showsql') + │ └─ 허용된 HMM HR 객체만 포함한 SELECT/WITH 생성 + └─ CB_ORDS JDBC session + ├─ CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT('Bearer ...') + │ └─ ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(token) + ├─ runtime principal/컨텍스트 일치 검증 + ├─ SET TRANSACTION READ ONLY + ├─ 생성 SQL 실행 → HMM_LEAVE_SCOPE_POLICY 적용 + ├─ ROLLBACK + └─ CB_ORDS_HANDLER_PKG.CLEAR_VPD_CONTEXT +``` + +## DB 설계 + +### 런타임 스키마 + +`CB_ORDS`에는 다음 최소 권한만 부여한다. + +- `CREATE SESSION` +- `ADMIN.CB_ORDS_HANDLER_PKG` 실행 +- Select AI 프로필의 승인 객체 6개에 대한 `SELECT` + - `HMM_ORG_TEAMS` + - `HMM_HR_EMPLOYEES` + - `HMM_LEAVE_BALANCES` + - `HMM_LEAVE_REQUESTS` + - `HMM_ATTENDANCE_DAILY` + - `HMM_HR_TERMS` + +`EXEMPT ACCESS POLICY`, `SELECT ANY TABLE`, 객체 생성 `ANY` 권한은 부여하지 않는다. +생성 SQL의 스키마 한정 여부에 영향을 받지 않도록 승인 객체에 한해 +`CB_ORDS` private synonym을 만든다. + +### 컨텍스트 패키지 + +`ADMIN.CB_ORDS_HANDLER_PKG`는 HTTP Authorization 값에서 Bearer 토큰을 +추출한 후 `ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER`를 호출한다. +토큰 원문은 테이블이나 로그에 저장하지 않는다. + +`CB_ORDS`에는 동일 이름의 private synonym만 제공하여 런타임 SQL에서는 +`CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT`로 호출한다. 컨텍스트 패키지 자체의 +직접 실행 권한은 런타임 계정에 노출하지 않는다. + +`CLEAR_VPD_CONTEXT`는 HMM 컨텍스트와 client identifier를 모두 정리한다. +유효하지 않은 인증 헤더는 컨텍스트를 먼저 지운 뒤 오류로 종료한다. + +## 애플리케이션 설계 + +`backoffice.select-ai` 설정을 두 연결로 분리한다. + +- 생성 연결: 기존 `db-url`, `db-username`, `db-password`, `profile` +- 실행 연결: `runtime-db-url`, `runtime-db-username`, `runtime-db-password` + +생성 연결은 기존 `BACKOFFICE_DB_*`를 기본값으로 사용할 수 있다. 실행 연결은 +명시적으로 설정해야 하며 생성 연결로 자동 폴백하지 않는다. + +실행 전 다음을 검증한다. + +1. `USER`가 `ADMIN`이 아니다. +2. `SESSION_PRIVS`에 `EXEMPT ACCESS POLICY`가 없다. +3. 패키지 호출 후 `HMM_ACCESS_CTX.EMPLOYEE_CODE`가 토큰 인증 결과와 일치한다. + +하나라도 실패하면 SQL을 실행하지 않는다. + +## 운영 설정 + +`search_hr_data`의 `executionType`을 `AGENT_TOOL`에서 `SELECT_AI`로 변경한다. +다른 두 도구는 그대로 유지한다. + +필수 환경값: + +```properties +BACKOFFICE_SELECT_AI_PROFILE=HMM_HR_DATA_GPT54_PROFILE +BACKOFFICE_SELECT_AI_RUNTIME_DB_URL= +BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME=CB_ORDS +BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD= +``` + +비밀번호는 Git·Redmine·로그에 기록하지 않고 서버 환경 파일에서만 관리한다. + +## 검증 기준 + +1. `CB_ORDS`에 `EXEMPT ACCESS POLICY`가 없음을 확인한다. +2. E1006 토큰으로 `HMM_LEAVE_BALANCES` 조회 결과의 직원은 E1006 한 명뿐이다. +3. E1006 토큰으로 `HMM_LEAVE_REQUESTS` 조회 결과에 E1002가 없다. +4. E1006이 E1002 휴가를 직접 요청하면 0건을 반환한다. +5. 관리자 E1001은 권한 규칙에 따라 자기 자신과 직속 팀원 범위를 조회한다. +6. 무효 토큰, 컨텍스트 불일치, 런타임 권한 오설정은 fail-closed 한다. + +## 롤백 + +- 애플리케이션 환경의 `search_hr_data`를 이전 `AGENT_TOOL` 정의로 되돌리고 + 이전 JAR를 재기동한다. +- `CB_ORDS` 스키마는 즉시 삭제하지 않고 계정을 잠가 조사 가능 상태로 보존한다. +- VPD 정책 자체와 기존 `HMM_ACCESS_CTX_PKG`는 변경하지 않는다. diff --git a/docs/runbooks/hmm-mcp-token-configuration.md b/docs/runbooks/hmm-mcp-token-configuration.md index 034da2f..83bffc1 100644 --- a/docs/runbooks/hmm-mcp-token-configuration.md +++ b/docs/runbooks/hmm-mcp-token-configuration.md @@ -125,16 +125,35 @@ MCP의 표준 `Authorization` header는 하나이므로 “공통 gateway token 1. 백오피스에서 E1001, E1002 등 직원별 opaque token을 각각 발급한다. 2. MCP 서버는 그 직원 token 자체를 Bearer Token으로 검증한다. -3. 검증된 같은 token으로 DB 연결에서 - `ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(:token)`을 호출한다. -4. 같은 DB 세션에서 `HMM_LEAVE_BALANCES`, `HMM_LEAVE_REQUESTS`를 조회한다. -5. `finally`에서 context를 초기화하고 connection pool에 반환한다. -6. 서버 관리용 공통 토큰과 직원별 토큰을 동시에 요구해야 한다면 OAuth/API Gateway에서 +3. `ADMIN` 연결은 Select AI `SHOWSQL` 생성까지만 수행한다. +4. `EXEMPT ACCESS POLICY`가 없는 `CB_ORDS` 연결에서 + `CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT('Bearer ' || :token)`을 호출한다. +5. 같은 `CB_ORDS` DB 세션에서 생성된 읽기 전용 SQL로 + `HMM_LEAVE_BALANCES`, `HMM_LEAVE_REQUESTS`를 조회한다. +6. 실행 전 런타임 사용자가 `ADMIN`이 아니고 `EXEMPT ACCESS POLICY`가 없으며, + `HMM_ACCESS_CTX.EMPLOYEE_CODE`가 토큰 인증 결과와 일치하는지 확인한다. +7. 하나라도 다르면 조회하지 않고 fail-closed 한다. +8. `finally`에서 `CB_ORDS_HANDLER_PKG.CLEAR_VPD_CONTEXT`와 rollback을 수행한다. +9. 서버 관리용 공통 토큰과 직원별 토큰을 동시에 요구해야 한다면 OAuth/API Gateway에서 application identity와 user subject를 하나의 검증 가능한 access token으로 합친다. -이 흐름은 2026-07-23 백오피스 MCP에 적용됐다. 모든 MCP method가 토큰 해시·만료·회수·재직 -상태를 확인하며, Tool 호출은 같은 JDBC connection에서 `HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER` -후 실행하고 `finally`에서 context를 지운다. 무토큰·무효·회수 토큰은 HTTP 401이다. +이 흐름은 2026-07-31 백오피스 MCP `search_hr_data`에 적용됐다. 모든 MCP method가 토큰 +해시·만료·회수·재직 상태를 확인한다. `search_hr_data`는 +`HMM_HR_DATA_GPT54_PROFILE`로 SQL만 생성하고, 실제 SQL은 위 `CB_ORDS` 세션에서 실행한다. +무토큰·무효·회수 토큰은 HTTP 401이다. + +운영 환경에는 다음 값이 필요하다. + +```dotenv +BACKOFFICE_SELECT_AI_PROFILE=HMM_HR_DATA_GPT54_PROFILE +BACKOFFICE_SELECT_AI_RUNTIME_DB_URL= +BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME=CB_ORDS +BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD=<운영 secret> +``` + +DB 구성 스크립트는 `sql/adb/73_hmm_mcp_vpd_runtime.sql`이다. 이 스크립트는 승인된 HMM +HR 객체에 대한 개별 `SELECT`와 handler package 실행 권한만 부여하며 +`EXEMPT ACCESS POLICY`는 부여하지 않는다. 포털의 `HMM_MCP_BEARER_TOKEN` 공용 preset은 별도 호환 gateway를 사용하는 기존 UI 라우팅이다. Agent Factory의 사용자별 권한 검증에는 반드시 백오피스 MCP 주소와 직원 토큰을 사용한다. diff --git a/sql/adb/73_hmm_mcp_vpd_runtime.sql b/sql/adb/73_hmm_mcp_vpd_runtime.sql new file mode 100644 index 0000000..1d8248a --- /dev/null +++ b/sql/adb/73_hmm_mcp_vpd_runtime.sql @@ -0,0 +1,159 @@ +-- #740 HMM MCP VPD execution boundary +-- Run as ADMIN with SQLcl. The password is prompted without echo and is used +-- only when CB_ORDS does not already exist. +set define on verify off echo off feedback on serveroutput on +whenever sqlerror exit sql.sqlcode rollback + +accept hmm_cb_ords_password char prompt 'CB_ORDS initial password: ' hide + +declare + v_user_count number; + v_password varchar2(4000) := q'~&hmm_cb_ords_password~'; +begin + select count(*) + into v_user_count + from dba_users + where username = 'CB_ORDS'; + + if v_user_count = 0 then + if length(v_password) < 16 + or instr(v_password, '"') > 0 + or instr(v_password, chr(10)) > 0 + or instr(v_password, chr(13)) > 0 + or not regexp_like(v_password, '[A-Z]') + or not regexp_like(v_password, '[a-z]') + or not regexp_like(v_password, '[0-9]') + or not regexp_like(v_password, '[^A-Za-z0-9]') then + raise_application_error( + -20001, + 'CB_ORDS password must be 16+ chars with upper/lower/digit/special and no quote/newline.'); + end if; + execute immediate + 'create user CB_ORDS identified by "' || v_password || '" ' + || 'default tablespace DATA temporary tablespace TEMP quota 0 on DATA'; + dbms_output.put_line('Created CB_ORDS.'); + else + dbms_output.put_line('CB_ORDS already exists; password was not changed.'); + end if; +end; +/ + +alter user CB_ORDS account unlock; +grant create session to CB_ORDS; + +create or replace package CB_ORDS_HANDLER_PKG +authid definer +as + procedure SET_VPD_CONTEXT(p_authorization in varchar2); + procedure CLEAR_VPD_CONTEXT; +end CB_ORDS_HANDLER_PKG; +/ + +create or replace package body CB_ORDS_HANDLER_PKG +as + c_bearer_prefix constant varchar2(7) := 'Bearer '; + + procedure CLEAR_VPD_CONTEXT + is + begin + HMM_ACCESS_CTX_PKG.CLEAR_USER; + dbms_session.clear_identifier; + end CLEAR_VPD_CONTEXT; + + procedure SET_VPD_CONTEXT(p_authorization in varchar2) + is + v_authorization varchar2(2048) := trim(p_authorization); + v_token varchar2(512); + begin + CLEAR_VPD_CONTEXT; + if v_authorization is null + or length(v_authorization) > 2048 + or upper(substr(v_authorization, 1, length(c_bearer_prefix))) + <> upper(c_bearer_prefix) then + raise_application_error(-20002, 'A valid Bearer authorization header is required.'); + end if; + + v_token := trim(substr(v_authorization, length(c_bearer_prefix) + 1)); + if v_token is null or length(v_token) > 512 then + raise_application_error(-20003, 'A valid Bearer token is required.'); + end if; + + HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(v_token); + dbms_session.set_identifier( + sys_context('HMM_ACCESS_CTX', 'EMPLOYEE_CODE')); + exception + when others then + CLEAR_VPD_CONTEXT; + raise; + end SET_VPD_CONTEXT; +end CB_ORDS_HANDLER_PKG; +/ + +grant execute on CB_ORDS_HANDLER_PKG to CB_ORDS; + +grant select on HMM_ORG_TEAMS to CB_ORDS; +grant select on HMM_HR_EMPLOYEES to CB_ORDS; +grant select on HMM_LEAVE_BALANCES to CB_ORDS; +grant select on HMM_LEAVE_REQUESTS to CB_ORDS; +grant select on HMM_ATTENDANCE_DAILY to CB_ORDS; +grant select on HMM_HR_TERMS to CB_ORDS; + +create or replace synonym CB_ORDS.CB_ORDS_HANDLER_PKG + for ADMIN.CB_ORDS_HANDLER_PKG; +create or replace synonym CB_ORDS.HMM_ORG_TEAMS + for ADMIN.HMM_ORG_TEAMS; +create or replace synonym CB_ORDS.HMM_HR_EMPLOYEES + for ADMIN.HMM_HR_EMPLOYEES; +create or replace synonym CB_ORDS.HMM_LEAVE_BALANCES + for ADMIN.HMM_LEAVE_BALANCES; +create or replace synonym CB_ORDS.HMM_LEAVE_REQUESTS + for ADMIN.HMM_LEAVE_REQUESTS; +create or replace synonym CB_ORDS.HMM_ATTENDANCE_DAILY + for ADMIN.HMM_ATTENDANCE_DAILY; +create or replace synonym CB_ORDS.HMM_HR_TERMS + for ADMIN.HMM_HR_TERMS; + +declare + v_exempt_count number; + v_policy_count number; +begin + select count(*) + into v_exempt_count + from dba_sys_privs + where grantee = 'CB_ORDS' + and privilege = 'EXEMPT ACCESS POLICY'; + if v_exempt_count <> 0 then + raise_application_error(-20004, 'CB_ORDS must not have EXEMPT ACCESS POLICY.'); + end if; + + select count(*) + into v_policy_count + from dba_policies + where object_owner = 'ADMIN' + and object_name in ('HMM_LEAVE_BALANCES', 'HMM_LEAVE_REQUESTS') + and policy_name = 'HMM_LEAVE_SCOPE_POLICY' + and enable = 'YES' + and sel = 'YES'; + if v_policy_count <> 2 then + raise_application_error(-20005, 'Both HMM leave VPD policies must be enabled.'); + end if; +end; +/ + +select username, account_status + from dba_users + where username = 'CB_ORDS'; + +select privilege + from dba_sys_privs + where grantee = 'CB_ORDS' + order by privilege; + +select object_name, object_type, status + from dba_objects + where owner = 'ADMIN' + and object_name = 'CB_ORDS_HANDLER_PKG' + order by object_type; + +undefine hmm_cb_ords_password +prompt HMM MCP VPD runtime boundary is ready. diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java index c980efc..e5edcd2 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/BackofficeProperties.java @@ -79,7 +79,10 @@ public record BackofficeProperties( String dbUrl, String dbUsername, String dbPassword, - String profile + String profile, + String runtimeDbUrl, + String runtimeDbUsername, + String runtimeDbPassword ) { public boolean configured() { @@ -88,5 +91,12 @@ public record BackofficeProperties( && dbPassword != null && !dbPassword.isBlank() && profile != null && !profile.isBlank(); } + + /** The generated SQL must never fall back to the privileged profile-owner connection. */ + public boolean runtimeConfigured() { + return runtimeDbUrl != null && !runtimeDbUrl.isBlank() + && runtimeDbUsername != null && !runtimeDbUsername.isBlank() + && runtimeDbPassword != null && !runtimeDbPassword.isBlank(); + } } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java index e7ae37a..ef5c239 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/SelectAiService.java @@ -14,6 +14,7 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.sql.Statement; +import java.util.Locale; import java.util.regex.Pattern; import org.springframework.stereotype.Service; @@ -45,7 +46,7 @@ public class SelectAiService { } public JsonNode generateAndExecute(String bearerToken, String prompt) { - bearerAuthenticator.authenticate(bearerToken); + HmmMcpPrincipal principal = bearerAuthenticator.authenticate(bearerToken); String normalizedPrompt = requiredPrompt(prompt); BackofficeProperties.SelectAi selectAi = properties == null ? null : properties.selectAi(); @@ -54,11 +55,18 @@ public class SelectAiService { + "BACKOFFICE_SELECT_AI_DB_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, " + "BACKOFFICE_SELECT_AI_DB_PASSWORD, BACKOFFICE_SELECT_AI_PROFILE을 확인하세요."); } + if (!selectAi.runtimeConfigured()) { + throw new AppException("VPD 런타임 연결 설정이 필요합니다. " + + "BACKOFFICE_SELECT_AI_RUNTIME_DB_URL, " + + "BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME, " + + "BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD를 확인하세요."); + } - String generatedSql = generate(selectAi, normalizedPrompt); + String generatedSql = generate( + selectAi, vpdAwarePrompt(normalizedPrompt, principal)); String normalizedSql = validateReadOnlySql(generatedSql); QueryExecution execution = executeReadOnly( - selectAi, normalizedSql, bearerToken.trim()); + selectAi, normalizedSql, bearerToken.trim(), principal); ObjectNode response = objectMapper.createObjectNode(); response.put("status", "SHOWSQL_AND_EXECUTED"); response.put("profile", selectAi.profile()); @@ -66,6 +74,8 @@ public class SelectAiService { response.put("execution", "READ_ONLY_EXECUTED"); response.put("rowCount", execution.items().size()); response.put("truncated", execution.truncated()); + response.put("vpdEnforced", true); + response.put("scopeEmployeeCode", principal.employeeCode()); response.set("items", execution.items()); response.put("nextStep", execution.truncated() ? "최초 " + MAX_RESULT_ROWS + "건만 반환했습니다." @@ -84,6 +94,18 @@ public class SelectAiService { return normalized; } + private String vpdAwarePrompt(String prompt, HmmMcpPrincipal principal) { + return """ + Oracle SQL 생성 규칙: + - 프로필에 승인된 HMM HR 객체만 사용하세요. + - 단일 읽기 전용 SELECT 또는 WITH 문을 생성하세요. + - 행 접근 권한은 실행 세션의 Oracle VPD가 강제하므로 권한을 추정하거나 우회하지 마세요. + - 현재 인증 사용자 사번은 %s입니다. '나', '내', '우리 팀'은 이 사용자를 기준으로 해석하세요. + + 사용자 질문: %s + """.formatted(principal.employeeCode(), prompt); + } + private String generate( BackofficeProperties.SelectAi selectAi, String prompt @@ -110,20 +132,25 @@ public class SelectAiService { private QueryExecution executeReadOnly( BackofficeProperties.SelectAi selectAi, String generatedSql, - String bearerToken + String bearerToken, + HmmMcpPrincipal principal ) { ArrayNode items = objectMapper.createArrayNode(); boolean truncated = false; try (Connection connection = DriverManager.getConnection( - selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword())) { + selectAi.runtimeDbUrl(), + selectAi.runtimeDbUsername(), + selectAi.runtimeDbPassword())) { + verifyNonExemptRuntime(connection); boolean contextSet = false; try { try (CallableStatement statement = connection.prepareCall( - "BEGIN ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(?); END;")) { - statement.setString(1, bearerToken); + "BEGIN CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT(?); END;")) { + statement.setString(1, "Bearer " + bearerToken); statement.execute(); contextSet = true; } + verifyVpdContext(connection, principal); connection.setAutoCommit(false); connection.setReadOnly(true); try (Statement transaction = connection.createStatement()) { @@ -148,7 +175,7 @@ public class SelectAiService { if (column == null || column.isBlank()) { column = metadata.getColumnName(columnIndex); } - putResultValue(row, column, resultSet.getObject(columnIndex)); + putResultValue(row, column, resultSet, columnIndex); } } } @@ -159,7 +186,7 @@ public class SelectAiService { } finally { if (contextSet) { try (CallableStatement statement = connection.prepareCall( - "BEGIN ADMIN.HMM_ACCESS_CTX_PKG.CLEAR_USER; END;")) { + "BEGIN CB_ORDS_HANDLER_PKG.CLEAR_VPD_CONTEXT; END;")) { statement.execute(); } } @@ -171,7 +198,53 @@ public class SelectAiService { return new QueryExecution(items, truncated); } - private void putResultValue(ObjectNode row, String column, Object value) { + private void verifyNonExemptRuntime(Connection connection) throws Exception { + String runtimeUser; + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT USER FROM dual")) { + if (!resultSet.next()) { + throw new AppException("VPD 런타임 DB 사용자를 확인할 수 없습니다."); + } + runtimeUser = resultSet.getString(1); + } + if (runtimeUser == null || "ADMIN".equals(runtimeUser.toUpperCase(Locale.ROOT))) { + throw new AppException("VPD 런타임 연결은 ADMIN을 사용할 수 없습니다."); + } + try (PreparedStatement statement = connection.prepareStatement( + "SELECT COUNT(*) FROM SESSION_PRIVS WHERE PRIVILEGE = ?")) { + statement.setString(1, "EXEMPT ACCESS POLICY"); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next() || resultSet.getInt(1) != 0) { + throw new AppException( + "VPD 런타임 계정에 EXEMPT ACCESS POLICY가 있어 실행을 차단했습니다."); + } + } + } + } + + private void verifyVpdContext( + Connection connection, + HmmMcpPrincipal principal + ) throws Exception { + String sql = "SELECT SYS_CONTEXT('HMM_ACCESS_CTX', 'EMPLOYEE_CODE') FROM dual"; + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(sql)) { + String employeeCode = + resultSet.next() ? resultSet.getString(1) : null; + if (employeeCode == null + || !employeeCode.equalsIgnoreCase(principal.employeeCode())) { + throw new AppException("Bearer Token 사용자와 VPD 세션 컨텍스트가 일치하지 않습니다."); + } + } + } + + private void putResultValue( + ObjectNode row, + String column, + ResultSet resultSet, + int columnIndex + ) throws Exception { + Object value = resultSet.getObject(columnIndex); if (value == null) { row.putNull(column); } else if (value instanceof BigDecimal number) { @@ -191,7 +264,9 @@ public class SelectAiService { } else if (value instanceof Boolean bool) { row.put(column, bool); } else { - row.put(column, String.valueOf(value)); + // Oracle-specific temporal types such as TIMESTAMPTZ otherwise render as + // oracle.sql.TIMESTAMPTZ@, which is not usable MCP evidence. + row.put(column, resultSet.getString(columnIndex)); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 324b37b..16f5d20 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -69,10 +69,15 @@ backoffice: oci-region: ${BACKOFFICE_AI_OCI_REGION:${POC3_LLM_GPT55_OCI_REGION:}} oci-compartment-id: ${BACKOFFICE_AI_OCI_COMPARTMENT_ID:${OCI_GENAI_COMPARTMENT_ID:}} select-ai: - db-url: ${BACKOFFICE_SELECT_AI_DB_URL:} - db-username: ${BACKOFFICE_SELECT_AI_DB_USERNAME:} - db-password: ${BACKOFFICE_SELECT_AI_DB_PASSWORD:} + # Profile-owner connection: SHOWSQL generation only. + db-url: ${BACKOFFICE_SELECT_AI_DB_URL:${BACKOFFICE_DB_URL:}} + db-username: ${BACKOFFICE_SELECT_AI_DB_USERNAME:${BACKOFFICE_DB_USERNAME:}} + db-password: ${BACKOFFICE_SELECT_AI_DB_PASSWORD:${BACKOFFICE_DB_PASSWORD:}} profile: ${BACKOFFICE_SELECT_AI_PROFILE:} + # Non-EXEMPT execution boundary: no fallback to the profile owner is allowed. + runtime-db-url: ${BACKOFFICE_SELECT_AI_RUNTIME_DB_URL:} + runtime-db-username: ${BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME:} + runtime-db-password: ${BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD:} catalog: owner: ${BACKOFFICE_CATALOG_OWNER:} objects: ${BACKOFFICE_CATALOG_OBJECTS:} diff --git a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java index 121914b..d08dc3c 100644 --- a/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java +++ b/src/test/java/com/cloudhandson/vpdbackoffice/service/McpSseServiceTest.java @@ -3,6 +3,8 @@ package com.cloudhandson.vpdbackoffice.service; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import com.cloudhandson.vpdbackoffice.config.BackofficeProperties; import com.cloudhandson.vpdbackoffice.config.McpProperties; @@ -106,6 +108,50 @@ class McpSseServiceTest { assertThat(response.path("result").path("isError").asBoolean()).isFalse(); } + @Test + void routesSelectAiToolThroughTheVpdAwareExecutor() { + String selectAiTools = """ + [ + { + "name":"search_hr_data", + "label":"HMM HR 데이터 조회", + "description":"VPD가 적용된 HMM HR 데이터를 조회합니다.", + "argumentName":"query", + "argumentDescription":"완전한 자연어 질문입니다.", + "executionType":"SELECT_AI" + } + ] + """; + McpProperties selectAiProperties = + new McpProperties("", "", "", "", "", "", selectAiTools); + SelectAiService selectAiService = mock(SelectAiService.class); + when(selectAiService.generateAndExecute( + "valid-token", "내 휴가 신청 내역을 보여줘")) + .thenReturn(objectMapper.createObjectNode().put("vpdEnforced", true)); + McpSseService selectAiMcp = new McpSseService( + agentToolRunner, + selectAiService, + bearerAuthenticator, + new EnvironmentMcpToolCatalog(selectAiProperties, objectMapper), + selectAiProperties, + new BackofficeProperties(null, null, null, null, null), + objectMapper); + ObjectNode request = request(5, "tools/call"); + request.putObject("params") + .put("name", "search_hr_data") + .putObject("arguments") + .put("query", "내 휴가 신청 내역을 보여줘"); + + ObjectNode response = + selectAiMcp.handle("default", request, "valid-token"); + + verify(selectAiService).generateAndExecute( + "valid-token", "내 휴가 신청 내역을 보여줘"); + assertThat(response.path("result").path("isError").asBoolean()).isFalse(); + assertThat(response.path("result").path("content").get(0).path("text").asText()) + .contains("\"vpdEnforced\" : true"); + } + @Test void rejectsDiscoveryWhenBearerAuthenticationFails() { McpSseService rejectingService = new McpSseService(