refs #740: enforce HMM MCP VPD runtime boundary

This commit is contained in:
devmrko
2026-07-31 13:57:04 +09:00
parent af6add5a44
commit 2e44ed0b97
8 changed files with 468 additions and 26 deletions

View File

@@ -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 배열입니다.

View File

@@ -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=<ADB JDBC URL>
BACKOFFICE_SELECT_AI_RUNTIME_DB_USERNAME=CB_ORDS
BACKOFFICE_SELECT_AI_RUNTIME_DB_PASSWORD=<secret>
```
비밀번호는 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`는 변경하지 않는다.

View File

@@ -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=<ADB JDBC 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 주소와 직원 토큰을 사용한다.

View File

@@ -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.

View File

@@ -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();
}
}
}

View File

@@ -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@<identity>, which is not usable MCP evidence.
row.put(column, resultSet.getString(columnIndex));
}
}

View File

@@ -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:}

View File

@@ -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(