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_USERNAME="CB_ORDS"
|
||||||
export BACKOFFICE_ORDS_DB_PASSWORD=""
|
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_URL="${BACKOFFICE_DB_URL}"
|
||||||
export BACKOFFICE_SELECT_AI_DB_USERNAME=""
|
export BACKOFFICE_SELECT_AI_DB_USERNAME="${BACKOFFICE_DB_USERNAME}"
|
||||||
export BACKOFFICE_SELECT_AI_DB_PASSWORD=""
|
export BACKOFFICE_SELECT_AI_DB_PASSWORD="${BACKOFFICE_DB_PASSWORD}"
|
||||||
export BACKOFFICE_SELECT_AI_PROFILE=""
|
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) 재사용 가능한 백오피스 카탈로그와 표시 설정 ---
|
# --- (2c) 재사용 가능한 백오피스 카탈로그와 표시 설정 ---
|
||||||
# 승인 객체는 key/tableName/objectType/businessName/description JSON 배열입니다.
|
# 승인 객체는 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을 각각 발급한다.
|
1. 백오피스에서 E1001, E1002 등 직원별 opaque token을 각각 발급한다.
|
||||||
2. MCP 서버는 그 직원 token 자체를 Bearer Token으로 검증한다.
|
2. MCP 서버는 그 직원 token 자체를 Bearer Token으로 검증한다.
|
||||||
3. 검증된 같은 token으로 DB 연결에서
|
3. `ADMIN` 연결은 Select AI `SHOWSQL` 생성까지만 수행한다.
|
||||||
`ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(:token)`을 호출한다.
|
4. `EXEMPT ACCESS POLICY`가 없는 `CB_ORDS` 연결에서
|
||||||
4. 같은 DB 세션에서 `HMM_LEAVE_BALANCES`, `HMM_LEAVE_REQUESTS`를 조회한다.
|
`CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT('Bearer ' || :token)`을 호출한다.
|
||||||
5. `finally`에서 context를 초기화하고 connection pool에 반환한다.
|
5. 같은 `CB_ORDS` DB 세션에서 생성된 읽기 전용 SQL로
|
||||||
6. 서버 관리용 공통 토큰과 직원별 토큰을 동시에 요구해야 한다면 OAuth/API Gateway에서
|
`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으로 합친다.
|
application identity와 user subject를 하나의 검증 가능한 access token으로 합친다.
|
||||||
|
|
||||||
이 흐름은 2026-07-23 백오피스 MCP에 적용됐다. 모든 MCP method가 토큰 해시·만료·회수·재직
|
이 흐름은 2026-07-31 백오피스 MCP `search_hr_data`에 적용됐다. 모든 MCP method가 토큰
|
||||||
상태를 확인하며, Tool 호출은 같은 JDBC connection에서 `HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER`
|
해시·만료·회수·재직 상태를 확인한다. `search_hr_data`는
|
||||||
후 실행하고 `finally`에서 context를 지운다. 무토큰·무효·회수 토큰은 HTTP 401이다.
|
`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 라우팅이다.
|
포털의 `HMM_MCP_BEARER_TOKEN` 공용 preset은 별도 호환 gateway를 사용하는 기존 UI 라우팅이다.
|
||||||
Agent Factory의 사용자별 권한 검증에는 반드시 백오피스 MCP 주소와 직원 토큰을 사용한다.
|
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 dbUrl,
|
||||||
String dbUsername,
|
String dbUsername,
|
||||||
String dbPassword,
|
String dbPassword,
|
||||||
String profile
|
String profile,
|
||||||
|
String runtimeDbUrl,
|
||||||
|
String runtimeDbUsername,
|
||||||
|
String runtimeDbPassword
|
||||||
) {
|
) {
|
||||||
|
|
||||||
public boolean configured() {
|
public boolean configured() {
|
||||||
@@ -88,5 +91,12 @@ public record BackofficeProperties(
|
|||||||
&& dbPassword != null && !dbPassword.isBlank()
|
&& dbPassword != null && !dbPassword.isBlank()
|
||||||
&& profile != null && !profile.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.ResultSet;
|
||||||
import java.sql.ResultSetMetaData;
|
import java.sql.ResultSetMetaData;
|
||||||
import java.sql.Statement;
|
import java.sql.Statement;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ public class SelectAiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public JsonNode generateAndExecute(String bearerToken, String prompt) {
|
public JsonNode generateAndExecute(String bearerToken, String prompt) {
|
||||||
bearerAuthenticator.authenticate(bearerToken);
|
HmmMcpPrincipal principal = bearerAuthenticator.authenticate(bearerToken);
|
||||||
String normalizedPrompt = requiredPrompt(prompt);
|
String normalizedPrompt = requiredPrompt(prompt);
|
||||||
BackofficeProperties.SelectAi selectAi =
|
BackofficeProperties.SelectAi selectAi =
|
||||||
properties == null ? null : properties.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_URL, BACKOFFICE_SELECT_AI_DB_USERNAME, "
|
||||||
+ "BACKOFFICE_SELECT_AI_DB_PASSWORD, BACKOFFICE_SELECT_AI_PROFILE을 확인하세요.");
|
+ "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);
|
String normalizedSql = validateReadOnlySql(generatedSql);
|
||||||
QueryExecution execution = executeReadOnly(
|
QueryExecution execution = executeReadOnly(
|
||||||
selectAi, normalizedSql, bearerToken.trim());
|
selectAi, normalizedSql, bearerToken.trim(), principal);
|
||||||
ObjectNode response = objectMapper.createObjectNode();
|
ObjectNode response = objectMapper.createObjectNode();
|
||||||
response.put("status", "SHOWSQL_AND_EXECUTED");
|
response.put("status", "SHOWSQL_AND_EXECUTED");
|
||||||
response.put("profile", selectAi.profile());
|
response.put("profile", selectAi.profile());
|
||||||
@@ -66,6 +74,8 @@ public class SelectAiService {
|
|||||||
response.put("execution", "READ_ONLY_EXECUTED");
|
response.put("execution", "READ_ONLY_EXECUTED");
|
||||||
response.put("rowCount", execution.items().size());
|
response.put("rowCount", execution.items().size());
|
||||||
response.put("truncated", execution.truncated());
|
response.put("truncated", execution.truncated());
|
||||||
|
response.put("vpdEnforced", true);
|
||||||
|
response.put("scopeEmployeeCode", principal.employeeCode());
|
||||||
response.set("items", execution.items());
|
response.set("items", execution.items());
|
||||||
response.put("nextStep", execution.truncated()
|
response.put("nextStep", execution.truncated()
|
||||||
? "최초 " + MAX_RESULT_ROWS + "건만 반환했습니다."
|
? "최초 " + MAX_RESULT_ROWS + "건만 반환했습니다."
|
||||||
@@ -84,6 +94,18 @@ public class SelectAiService {
|
|||||||
return normalized;
|
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(
|
private String generate(
|
||||||
BackofficeProperties.SelectAi selectAi,
|
BackofficeProperties.SelectAi selectAi,
|
||||||
String prompt
|
String prompt
|
||||||
@@ -110,20 +132,25 @@ public class SelectAiService {
|
|||||||
private QueryExecution executeReadOnly(
|
private QueryExecution executeReadOnly(
|
||||||
BackofficeProperties.SelectAi selectAi,
|
BackofficeProperties.SelectAi selectAi,
|
||||||
String generatedSql,
|
String generatedSql,
|
||||||
String bearerToken
|
String bearerToken,
|
||||||
|
HmmMcpPrincipal principal
|
||||||
) {
|
) {
|
||||||
ArrayNode items = objectMapper.createArrayNode();
|
ArrayNode items = objectMapper.createArrayNode();
|
||||||
boolean truncated = false;
|
boolean truncated = false;
|
||||||
try (Connection connection = DriverManager.getConnection(
|
try (Connection connection = DriverManager.getConnection(
|
||||||
selectAi.dbUrl(), selectAi.dbUsername(), selectAi.dbPassword())) {
|
selectAi.runtimeDbUrl(),
|
||||||
|
selectAi.runtimeDbUsername(),
|
||||||
|
selectAi.runtimeDbPassword())) {
|
||||||
|
verifyNonExemptRuntime(connection);
|
||||||
boolean contextSet = false;
|
boolean contextSet = false;
|
||||||
try {
|
try {
|
||||||
try (CallableStatement statement = connection.prepareCall(
|
try (CallableStatement statement = connection.prepareCall(
|
||||||
"BEGIN ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(?); END;")) {
|
"BEGIN CB_ORDS_HANDLER_PKG.SET_VPD_CONTEXT(?); END;")) {
|
||||||
statement.setString(1, bearerToken);
|
statement.setString(1, "Bearer " + bearerToken);
|
||||||
statement.execute();
|
statement.execute();
|
||||||
contextSet = true;
|
contextSet = true;
|
||||||
}
|
}
|
||||||
|
verifyVpdContext(connection, principal);
|
||||||
connection.setAutoCommit(false);
|
connection.setAutoCommit(false);
|
||||||
connection.setReadOnly(true);
|
connection.setReadOnly(true);
|
||||||
try (Statement transaction = connection.createStatement()) {
|
try (Statement transaction = connection.createStatement()) {
|
||||||
@@ -148,7 +175,7 @@ public class SelectAiService {
|
|||||||
if (column == null || column.isBlank()) {
|
if (column == null || column.isBlank()) {
|
||||||
column = metadata.getColumnName(columnIndex);
|
column = metadata.getColumnName(columnIndex);
|
||||||
}
|
}
|
||||||
putResultValue(row, column, resultSet.getObject(columnIndex));
|
putResultValue(row, column, resultSet, columnIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +186,7 @@ public class SelectAiService {
|
|||||||
} finally {
|
} finally {
|
||||||
if (contextSet) {
|
if (contextSet) {
|
||||||
try (CallableStatement statement = connection.prepareCall(
|
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();
|
statement.execute();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,7 +198,53 @@ public class SelectAiService {
|
|||||||
return new QueryExecution(items, truncated);
|
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) {
|
if (value == null) {
|
||||||
row.putNull(column);
|
row.putNull(column);
|
||||||
} else if (value instanceof BigDecimal number) {
|
} else if (value instanceof BigDecimal number) {
|
||||||
@@ -191,7 +264,9 @@ public class SelectAiService {
|
|||||||
} else if (value instanceof Boolean bool) {
|
} else if (value instanceof Boolean bool) {
|
||||||
row.put(column, bool);
|
row.put(column, bool);
|
||||||
} else {
|
} 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-region: ${BACKOFFICE_AI_OCI_REGION:${POC3_LLM_GPT55_OCI_REGION:}}
|
||||||
oci-compartment-id: ${BACKOFFICE_AI_OCI_COMPARTMENT_ID:${OCI_GENAI_COMPARTMENT_ID:}}
|
oci-compartment-id: ${BACKOFFICE_AI_OCI_COMPARTMENT_ID:${OCI_GENAI_COMPARTMENT_ID:}}
|
||||||
select-ai:
|
select-ai:
|
||||||
db-url: ${BACKOFFICE_SELECT_AI_DB_URL:}
|
# Profile-owner connection: SHOWSQL generation only.
|
||||||
db-username: ${BACKOFFICE_SELECT_AI_DB_USERNAME:}
|
db-url: ${BACKOFFICE_SELECT_AI_DB_URL:${BACKOFFICE_DB_URL:}}
|
||||||
db-password: ${BACKOFFICE_SELECT_AI_DB_PASSWORD:}
|
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:}
|
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:
|
catalog:
|
||||||
owner: ${BACKOFFICE_CATALOG_OWNER:}
|
owner: ${BACKOFFICE_CATALOG_OWNER:}
|
||||||
objects: ${BACKOFFICE_CATALOG_OBJECTS:}
|
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.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.Mockito.mock;
|
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.BackofficeProperties;
|
||||||
import com.cloudhandson.vpdbackoffice.config.McpProperties;
|
import com.cloudhandson.vpdbackoffice.config.McpProperties;
|
||||||
@@ -106,6 +108,50 @@ class McpSseServiceTest {
|
|||||||
assertThat(response.path("result").path("isError").asBoolean()).isFalse();
|
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
|
@Test
|
||||||
void rejectsDiscoveryWhenBearerAuthenticationFails() {
|
void rejectsDiscoveryWhenBearerAuthenticationFails() {
|
||||||
McpSseService rejectingService = new McpSseService(
|
McpSseService rejectingService = new McpSseService(
|
||||||
|
|||||||
Reference in New Issue
Block a user