refs #740: enforce HMM MCP VPD runtime boundary
This commit is contained in:
11
.env.example
11
.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 배열입니다.
|
||||
|
||||
123
docs/design/740-hmm-mcp-vpd-runtime/README.md
Normal file
123
docs/design/740-hmm-mcp-vpd-runtime/README.md
Normal 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`는 변경하지 않는다.
|
||||
@@ -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 주소와 직원 토큰을 사용한다.
|
||||
|
||||
159
sql/adb/73_hmm_mcp_vpd_runtime.sql
Normal file
159
sql/adb/73_hmm_mcp_vpd_runtime.sql
Normal 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.
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user