[Developer] #619 map stakeholder tokens to permission rules

This commit is contained in:
devmrko
2026-07-07 14:03:40 +09:00
parent 66b57da356
commit 2cdc738eb9
22 changed files with 933 additions and 45 deletions

View File

@@ -0,0 +1,87 @@
# 설계서: 이해당사자 토큰과 역할 기반 KB 원장 권한 예제 (#619)
> **상태**: Implemented · QA passed · deployed
> **작성**: [AI] Architect · **최종수정**: 2026-07-07
> **추적성** — Redmine: #619 · 관련 설계: #618 정형 데이터 원장 조회, #492 토큰 검증 흐름
## 1. 목적
`POC_2.KB_STAKEHOLDERS`를 업무 사용자 원장으로 삼아 모든 이해당사자에게 Bearer Token을 발급한다. 토큰의 실제 주체, 역할, 채널은 해당 원장에서만 해석하고, 접근 허용 여부는 백오피스의 **접근 규칙**(역할 → 보호 객체 → 행 규칙/표시 예외)으로 관리한다.
## 2. 핵심 결정
- `CB_AGENT_BEARER_KEY.STAKEHOLDER_USER_ID``KB_STAKEHOLDERS.USER_ID`를 가리킨다. 기존 숫자형 `USER_ID`는 기존 기능 호환용 application-user bridge로 유지한다.
- 이해당사자마다 `CB_APP_USER.STAKEHOLDER_USER_ID` bridge를 하나 만들고, `KB_STAKEHOLDERS.ROLE``지점장`이면 `KB_BRANCH_MANAGER_ROLE`, `설계사`이면 `KB_DESIGNER_ROLE`을 부여한다. 다른 역할은 토큰은 발급되지만 별도 권한을 추가하기 전까지 fail-closed다.
- VPD package는 토큰에서 `STAKEHOLDER_USER_ID`, `STAKEHOLDER_ROLE`, 정규화된 `STAKEHOLDER_CHANNEL`을 application context에 설정한다. `설계사채널 → 설계사`, `GA채널 → GA`를 정규화한다.
- `지점장` 여부는 package의 하드코딩 분기가 아니라 `KB_BRANCH_MANAGER_ROLE`에 부여된 접근 규칙으로 결정한다.
- `RLS_FILTER`의 문자열은 실행하지 않는다. 권한 테이블에 저장된 allowlist rule type만 predicate로 변환한다.
## 3. 예제 권한
| 역할 | 보호 객체 | 행 규칙 | 결과 |
|---|---|---|---|
| KB_DESIGNER_ROLE | KB_CONTRACTS | `FC_ID / STAKEHOLDER_SELF` | 본인 담당 계약 |
| KB_BRANCH_MANAGER_ROLE | KB_CONTRACTS | `FC_CHANNEL / STAKEHOLDER_CHANNEL` | 같은 채널 계약 |
| KB_DESIGNER_ROLE | 고객·담보·청구·외부보유 | `CUST_ID / STAKEHOLDER_SELF` | 본인 담당 고객 기준 |
| KB_BRANCH_MANAGER_ROLE | 고객·담보·청구·외부보유 | `CUST_ID / STAKEHOLDER_CHANNEL` | 같은 채널 고객 기준 |
| 두 역할 | KB_STAKEHOLDERS | `USER_ID / STAKEHOLDER_SELF` | 토큰 주체 자신의 매핑 행 |
`STAKEHOLDER_SELF``STAKEHOLDER_CHANNEL`은 접근 규칙 화면에서 선택 가능한 행 규칙이다. `FC_ID`/`FC_CHANNEL`에서는 직접 비교하고, `CUST_ID`/`CONTRACT_NO`에서는 계약원장과 안전한 EXISTS 조인으로 변환한다.
## 4. CLS / NULL 처리
아래 컬럼은 `CB_PERMISSION_COLUMN`의 **원문 표시 허용**에 포함되지 않으면 VPD의 `SEC_RELEVANT_COL_OPT=ALL_ROWS`로 NULL 처리한다. 즉, 행 접근과 원문 표시 예외를 같은 접근 규칙 화면에서 분리 관리한다.
| 테이블 | 기본 NULL 처리 컬럼 |
|---|---|
| KB_CUSTOMERS | CUST_NM, RRN_MASKED |
| KB_CLAIMS | CLAIM_AMT, PAID_AMT |
| KB_EXTERNAL_HOLDINGS | EXT_INSURER, EXT_PRODUCT_GRP, EXT_PRODUCT_TYPE |
초기 예제는 표시 예외를 비워 두므로 지점장·설계사 모두 해당 원문을 보지 못한다. 운영자는 역할별 권한에서 필요한 컬럼만 명시적으로 추가할 수 있다.
## 5. 흐름
```text
KB_STAKEHOLDERS
├─ token issue → CB_AGENT_BEARER_KEY.STAKEHOLDER_USER_ID
└─ role sync → CB_APP_USER bridge → CB_USER_ROLE
Bearer Token → CB_AGENT_CTX_PKG → application context
CB_PERMISSION / CB_PERMISSION_RULE
CB_AGENT_DOC_VPD_FILTER + DBMS_RLS
permitted rows / sensitive-column NULL
```
## 6. 인수 조건
- [x] 토큰 발급 화면은 모든 `KB_STAKEHOLDERS` 사용자를 선택지로 제공하고, 목록에 업무 역할·채널을 표시한다.
- [x] 접힌 메뉴를 열지 않아도 상단에서 권한 설정과 토큰 발급 화면으로 이동할 수 있다.
- [x] 권한 설정에 설계사·지점장 예제 역할과 7개 원장 권한이 표시된다.
- [x] 지점장 역할은 정규화된 동일 채널 계약/고객 범위 규칙으로 등록된다.
- [x] 설계사 역할은 `FC_ID = stakeholder user_id`와 담당 고객 범위 규칙으로 등록된다.
- [x] 지정 7개 민감 컬럼은 원문 표시 예외가 없으면 NULL 처리하는 VPD column policy가 등록된다.
- [x] 토큰 없음·역할 없음·지원하지 않는 rule mapping은 VPD filter에서 `1 = 0`으로 fail-closed 된다.
## 7. 검증 계획
1. Maven 단위/템플릿 테스트로 token subject 선택 및 새 rule type validation을 확인한다.
2. SQLcl에서 지점장/설계사 token context를 설정하고 VPD predicate 및 row count를 비교한다.
3. SQLcl에서 민감 컬럼이 NULL 처리되는지 확인한다.
4. HTTPS 로그인 뒤 `/permissions`, `/tokens`의 상단 바로가기를 확인한다.
### 2026-07-07 배포 검증 결과
- Maven 테스트 77건 통과.
- HTTPS 인증 요청으로 `/permissions`, `/tokens`가 각각 200을 반환했다. 기존 `/permissions`의 잘못된 Thymeleaf fallback 표현식도 함께 수정했다.
- `CB_APP_USER` bridge 13명, 지점장 역할 2명, 설계사 역할 5명이 동기화됐다.
- `POC_2`에 행 VPD 7개와 민감 컬럼 NULL VPD policy 7개, 관련 CLS 함수 7개가 모두 `VALID` 상태로 확인됐다.
## 8. 리스크와 제한
- `CB_APP_USER`는 업무 원장이 아니라 permission engine용 bridge다. 업무 identity의 원천은 반드시 `KB_STAKEHOLDERS`다.
- `RLS_FILTER`는 사람이 읽는 설명으로만 보존한다. 저장된 SQL을 실행하면 rule injection 위험이 있으므로 사용하지 않는다.
- 지점장의 “집계만” 요구는 원시 행 조회를 완전히 차단하는 별도 집계 View/ORDS endpoint가 필요하다. 이번 예제는 채널 행 범위와 민감 컬럼 NULL 처리까지 구현하며, 집계 전용 endpoint는 후속 범위다.

View File

@@ -46,6 +46,7 @@ CREATE TABLE cb_app_user (
user_name VARCHAR2(50) NOT NULL, user_name VARCHAR2(50) NOT NULL,
employee_no VARCHAR2(20) NOT NULL, employee_no VARCHAR2(20) NOT NULL,
dept_code VARCHAR2(20) NOT NULL, dept_code VARCHAR2(20) NOT NULL,
stakeholder_user_id VARCHAR2(30),
can_read_contents CHAR(1) DEFAULT 'N' CHECK (can_read_contents IN ('Y','N')) NOT NULL, can_read_contents CHAR(1) DEFAULT 'N' CHECK (can_read_contents IN ('Y','N')) NOT NULL,
active CHAR(1) DEFAULT 'Y' CHECK (active IN ('Y','N')) NOT NULL active CHAR(1) DEFAULT 'Y' CHECK (active IN ('Y','N')) NOT NULL
); );
@@ -87,6 +88,7 @@ CREATE TABLE cb_permission_column (
CREATE TABLE cb_agent_bearer_key ( CREATE TABLE cb_agent_bearer_key (
key_id NUMBER PRIMARY KEY, key_id NUMBER PRIMARY KEY,
user_id NUMBER NOT NULL REFERENCES cb_app_user(user_id), user_id NUMBER NOT NULL REFERENCES cb_app_user(user_id),
stakeholder_user_id VARCHAR2(30),
key_hash VARCHAR2(128) NOT NULL UNIQUE, key_hash VARCHAR2(128) NOT NULL UNIQUE,
key_prefix VARCHAR2(16), key_prefix VARCHAR2(16),
issued_at DATE DEFAULT SYSDATE NOT NULL, issued_at DATE DEFAULT SYSDATE NOT NULL,
@@ -96,9 +98,9 @@ CREATE TABLE cb_agent_bearer_key (
); );
PROMPT === 3. Seeding permissions === PROMPT === 3. Seeding permissions ===
INSERT INTO cb_app_user VALUES (101, '이에이치알', 'E10234', 'HR', 'N', 'Y'); INSERT INTO cb_app_user VALUES (101, '이에이치알', 'E10234', 'HR', NULL, 'N', 'Y');
INSERT INTO cb_app_user VALUES (102, '박파이넨스', 'E2001', 'FIN', 'N', 'Y'); INSERT INTO cb_app_user VALUES (102, '박파이넨스', 'E2001', 'FIN', NULL, 'N', 'Y');
INSERT INTO cb_app_user VALUES (103, '김어드민', 'E99999', 'HQ', 'Y', 'Y'); INSERT INTO cb_app_user VALUES (103, '김어드민', 'E99999', 'HQ', NULL, 'Y', 'Y');
INSERT INTO cb_app_role VALUES (10, 'HR_DEPT_ROLE', 'INTERNAL'); INSERT INTO cb_app_role VALUES (10, 'HR_DEPT_ROLE', 'INTERNAL');
INSERT INTO cb_app_role VALUES (20, 'FIN_SELF_ROLE', 'INTERNAL'); INSERT INTO cb_app_role VALUES (20, 'FIN_SELF_ROLE', 'INTERNAL');

View File

@@ -169,6 +169,58 @@ BEGIN
quote_literal('(^|,)' || UPPER(TRIM(r.rule_value)) || '(,|$)') || ')' quote_literal('(^|,)' || UPPER(TRIM(r.rule_value)) || '(,|$)') || ')'
); );
END IF; END IF;
ELSIF r.rule_type IN ('STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL') THEN
-- 업무 사용자 원장(KB_STAKEHOLDERS)에서 해석한 토큰 주체를 쓴다.
-- CUST_ID/CONTRACT_NO는 계약원장을 한 단계 감싼 EXISTS로만 연결해
-- 임의 SQL이나 외부 rule text가 predicate에 섞이지 않게 한다.
v_column := safe_column(p_schema, p_object, r.rule_column);
IF v_column IS NOT NULL
AND UPPER(p_schema) = 'POC_2' THEN
IF r.rule_type = 'STAKEHOLDER_SELF'
AND ((v_target = 'KB_CONTRACTS' AND v_column = 'FC_ID')
OR (v_target = 'KB_STAKEHOLDERS' AND v_column = 'USER_ID')) THEN
append_or(
r.permission_effect,
v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_USER_ID'')'
);
ELSIF r.rule_type = 'STAKEHOLDER_CHANNEL'
AND v_target = 'KB_CONTRACTS'
AND v_column = 'FC_CHANNEL' THEN
append_or(
r.permission_effect,
v_column || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''STAKEHOLDER_CHANNEL'')'
);
ELSIF v_target IN ('KB_CUSTOMERS', 'KB_COVERAGES', 'KB_CLAIMS', 'KB_EXTERNAL_HOLDINGS')
AND v_column IN ('CUST_ID', 'CONTRACT_NO') THEN
DECLARE
v_contract_key VARCHAR2(30);
v_context_key VARCHAR2(30);
v_context_name VARCHAR2(30);
BEGIN
v_contract_key := CASE v_column
WHEN 'CUST_ID' THEN 'CUST_ID'
WHEN 'CONTRACT_NO' THEN 'CONTRACT_NO'
END;
v_context_key := CASE r.rule_type
WHEN 'STAKEHOLDER_SELF' THEN 'FC_ID'
ELSE 'FC_CHANNEL'
END;
v_context_name := CASE r.rule_type
WHEN 'STAKEHOLDER_SELF' THEN 'STAKEHOLDER_USER_ID'
ELSE 'STAKEHOLDER_CHANNEL'
END;
append_or(
r.permission_effect,
'EXISTS (SELECT 1 FROM (' ||
'SELECT c.' || v_contract_key || ' AS access_key ' ||
'FROM POC_2.KB_CONTRACTS c ' ||
'WHERE c.' || v_context_key || ' = SYS_CONTEXT(''CB_AGENT_CTX'', ''' ||
v_context_name || ''')) allowed_contract ' ||
'WHERE allowed_contract.access_key = ' || v_column || ')'
);
END;
END IF;
END IF;
ELSIF r.rule_type = '=' THEN ELSIF r.rule_type = '=' THEN
v_column := safe_column(p_schema, p_object, r.rule_column); v_column := safe_column(p_schema, p_object, r.rule_column);
IF v_column IS NOT NULL THEN IF v_column IS NOT NULL THEN

View File

@@ -0,0 +1,533 @@
-- ============================================================
-- 46_kb_stakeholder_token_permission_example.sql
--
-- KB_STAKEHOLDERS를 토큰의 업무 주체로 연결하고, 접근 규칙 화면에서
-- 관리하는 설계사/지점장 예제 권한을 등록한다.
--
-- Prerequisite: 17, 25, 26 scripts. Execute as ADMIN.
-- 26 must include STAKEHOLDER_SELF / STAKEHOLDER_CHANNEL rule support.
-- ============================================================
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET ECHO OFF
SET FEEDBACK ON
SET DEFINE OFF
PROMPT === 1. Adding stakeholder bridge columns ===
DECLARE
PROCEDURE add_column_if_missing(
p_table_name IN VARCHAR2,
p_column_name IN VARCHAR2,
p_ddl IN VARCHAR2
) IS
v_count NUMBER;
BEGIN
SELECT COUNT(*)
INTO v_count
FROM user_tab_columns
WHERE table_name = p_table_name
AND column_name = p_column_name;
IF v_count = 0 THEN
EXECUTE IMMEDIATE p_ddl;
END IF;
END;
BEGIN
add_column_if_missing(
'CB_APP_USER',
'STAKEHOLDER_USER_ID',
'ALTER TABLE cb_app_user ADD (stakeholder_user_id VARCHAR2(30))'
);
add_column_if_missing(
'CB_AGENT_BEARER_KEY',
'STAKEHOLDER_USER_ID',
'ALTER TABLE cb_agent_bearer_key ADD (stakeholder_user_id VARCHAR2(30))'
);
END;
/
PROMPT === 2. Resolving Bearer Token to stakeholder context ===
CREATE OR REPLACE PACKAGE cb_agent_ctx_pkg AUTHID DEFINER AS
PROCEDURE clear_user;
PROCEDURE set_user(p_user_id IN NUMBER);
PROCEDURE set_user_values(
p_user_id IN NUMBER,
p_emp_no IN VARCHAR2,
p_dept_code IN VARCHAR2,
p_can_read_contents IN VARCHAR2
);
PROCEDURE set_user_by_bearer(p_bearer_key IN VARCHAR2);
END;
/
CREATE OR REPLACE PACKAGE BODY cb_agent_ctx_pkg AS
PROCEDURE clear_stakeholder_context AS
BEGIN
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID', NULL);
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_ROLE', NULL);
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL', NULL);
END;
PROCEDURE clear_user AS
BEGIN
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'USER_ID', NULL);
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'EMP_NO', NULL);
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE', NULL);
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'CAN_READ_CONTENTS', NULL);
clear_stakeholder_context;
END;
PROCEDURE set_user_values(
p_user_id IN NUMBER,
p_emp_no IN VARCHAR2,
p_dept_code IN VARCHAR2,
p_can_read_contents IN VARCHAR2
) AS
BEGIN
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'USER_ID', TO_CHAR(p_user_id));
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'EMP_NO', p_emp_no);
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE', p_dept_code);
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'CAN_READ_CONTENTS', p_can_read_contents);
clear_stakeholder_context;
END;
PROCEDURE set_stakeholder_context(
p_stakeholder_user_id IN VARCHAR2,
p_role IN VARCHAR2,
p_channel IN VARCHAR2
) AS
v_channel VARCHAR2(50);
BEGIN
v_channel := CASE TRIM(p_channel)
WHEN '설계사채널' THEN '설계사'
WHEN 'GA채널' THEN 'GA'
ELSE TRIM(p_channel)
END;
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID', p_stakeholder_user_id);
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_ROLE', p_role);
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL', v_channel);
END;
PROCEDURE set_user(p_user_id IN NUMBER) AS
v_emp_no cb_app_user.employee_no%TYPE;
v_dept_code cb_app_user.dept_code%TYPE;
v_can_read_contents cb_app_user.can_read_contents%TYPE;
BEGIN
SELECT employee_no,
dept_code,
CASE
WHEN EXISTS (
SELECT 1
FROM cb_user_role ur
JOIN cb_permission p ON p.role_id = ur.role_id
JOIN cb_permission_column pc ON pc.permission_id = p.perm_id
WHERE ur.user_id = p_user_id
AND p.target_name = 'CB_V_SEARCH_DOCUMENTS'
AND p.action_name = 'SELECT'
AND pc.column_name = 'CONTENTS'
) THEN 'Y'
ELSE 'N'
END
INTO v_emp_no, v_dept_code, v_can_read_contents
FROM cb_app_user
WHERE user_id = p_user_id
AND active = 'Y';
set_user_values(p_user_id, v_emp_no, v_dept_code, v_can_read_contents);
EXCEPTION
WHEN NO_DATA_FOUND THEN
clear_user;
RAISE_APPLICATION_ERROR(-20003, 'Mapped application user not found');
END;
PROCEDURE set_user_by_bearer(p_bearer_key IN VARCHAR2) AS
v_user_id cb_app_user.user_id%TYPE;
v_emp_no cb_app_user.employee_no%TYPE;
v_dept_code cb_app_user.dept_code%TYPE;
v_can_read_contents cb_app_user.can_read_contents%TYPE;
v_stakeholder_user_id VARCHAR2(30);
v_stakeholder_role VARCHAR2(50);
v_stakeholder_channel VARCHAR2(50);
BEGIN
IF p_bearer_key IS NULL THEN
clear_user;
RAISE_APPLICATION_ERROR(-20001, 'Authorization Bearer key is required');
END IF;
SELECT u.user_id,
u.employee_no,
u.dept_code,
CASE
WHEN EXISTS (
SELECT 1
FROM cb_user_role ur
JOIN cb_permission p ON p.role_id = ur.role_id
JOIN cb_permission_column pc ON pc.permission_id = p.perm_id
WHERE ur.user_id = u.user_id
AND p.target_name = 'CB_V_SEARCH_DOCUMENTS'
AND p.action_name = 'SELECT'
AND pc.column_name = 'CONTENTS'
) THEN 'Y'
ELSE 'N'
END,
COALESCE(k.stakeholder_user_id, u.stakeholder_user_id)
INTO v_user_id, v_emp_no, v_dept_code, v_can_read_contents, v_stakeholder_user_id
FROM cb_agent_bearer_key k
JOIN cb_app_user u ON u.user_id = k.user_id
WHERE k.key_hash = STANDARD_HASH(p_bearer_key, 'SHA256')
AND k.active = 'Y'
AND k.revoked_at IS NULL
AND (k.expires_at IS NULL OR k.expires_at > SYSDATE)
AND u.active = 'Y';
-- USER_ID를 먼저 넣어야 KB_STAKEHOLDERS 자체가 VPD 보호된 뒤에도
-- 해당 주체의 자기 행만 안전하게 해석할 수 있다.
set_user_values(v_user_id, v_emp_no, v_dept_code, v_can_read_contents);
IF v_stakeholder_user_id IS NOT NULL THEN
DBMS_SESSION.SET_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID', v_stakeholder_user_id);
SELECT role, channel
INTO v_stakeholder_role, v_stakeholder_channel
FROM poc_2.kb_stakeholders
WHERE user_id = v_stakeholder_user_id;
set_stakeholder_context(v_stakeholder_user_id, v_stakeholder_role, v_stakeholder_channel);
END IF;
EXCEPTION
WHEN NO_DATA_FOUND THEN
clear_user;
RAISE_APPLICATION_ERROR(-20002, 'Invalid or expired Bearer key');
END;
END;
/
SHOW ERRORS PACKAGE BODY cb_agent_ctx_pkg
PROMPT === 3. Syncing stakeholder bridge and example permissions ===
DECLARE
v_designer_role_id NUMBER;
v_manager_role_id NUMBER;
v_object_id NUMBER;
v_user_id NUMBER;
PROCEDURE ensure_role(
p_role_name IN VARCHAR2,
p_max_level IN VARCHAR2,
p_role_id OUT NUMBER
) IS
BEGIN
SELECT role_id INTO p_role_id
FROM cb_app_role
WHERE role_name = p_role_name;
EXCEPTION
WHEN NO_DATA_FOUND THEN
SELECT NVL(MAX(role_id), 0) + 1 INTO p_role_id FROM cb_app_role;
INSERT INTO cb_app_role(role_id, role_name, max_sensitivity_level)
VALUES (p_role_id, p_role_name, p_max_level);
END;
PROCEDURE ensure_object(
p_object_name IN VARCHAR2,
p_description IN VARCHAR2,
p_object_id OUT NUMBER
) IS
BEGIN
SELECT object_id INTO p_object_id
FROM cb_protected_object
WHERE owner = 'POC_2'
AND object_name = p_object_name;
UPDATE cb_protected_object
SET enabled_yn = 'Y',
ords_path = 'cb-ords/cb-object-query/poc_2/' || LOWER(p_object_name),
description = p_description
WHERE object_id = p_object_id;
EXCEPTION
WHEN NO_DATA_FOUND THEN
SELECT NVL(MAX(object_id), 0) + 1 INTO p_object_id FROM cb_protected_object;
INSERT INTO cb_protected_object(
object_id, owner, object_name, ords_path, enabled_yn, description
) VALUES (
p_object_id, 'POC_2', p_object_name,
'cb-ords/cb-object-query/poc_2/' || LOWER(p_object_name), 'Y', p_description
);
END;
FUNCTION is_sensitive(p_object_name IN VARCHAR2, p_column_name IN VARCHAR2) RETURN CHAR IS
BEGIN
IF (p_object_name = 'KB_CUSTOMERS' AND p_column_name IN ('CUST_NM', 'RRN_MASKED'))
OR (p_object_name = 'KB_CLAIMS' AND p_column_name IN ('CLAIM_AMT', 'PAID_AMT'))
OR (p_object_name = 'KB_EXTERNAL_HOLDINGS'
AND p_column_name IN ('EXT_INSURER', 'EXT_PRODUCT_GRP', 'EXT_PRODUCT_TYPE')) THEN
RETURN 'Y';
END IF;
RETURN 'N';
END;
PROCEDURE ensure_columns(p_object_name IN VARCHAR2, p_object_id IN NUMBER) IS
v_column_id NUMBER;
v_count NUMBER;
v_sensitive CHAR(1);
BEGIN
FOR c IN (
SELECT column_name
FROM all_tab_columns
WHERE owner = 'POC_2'
AND table_name = p_object_name
ORDER BY column_id
) LOOP
v_sensitive := is_sensitive(p_object_name, c.column_name);
SELECT COUNT(*) INTO v_count
FROM cb_protected_column
WHERE object_id = p_object_id
AND column_name = c.column_name;
IF v_count = 0 THEN
SELECT NVL(MAX(column_id), 0) + 1 INTO v_column_id FROM cb_protected_column;
INSERT INTO cb_protected_column(
column_id, object_id, column_name, sensitive_yn, sensitivity_level, redaction_method
) VALUES (
v_column_id, p_object_id, c.column_name, v_sensitive,
CASE v_sensitive WHEN 'Y' THEN 'CONFIDENTIAL' ELSE 'PUBLIC' END,
CASE v_sensitive WHEN 'Y' THEN 'NULLIFY' ELSE 'NONE' END
);
ELSIF v_sensitive = 'Y' THEN
UPDATE cb_protected_column
SET sensitive_yn = 'Y', sensitivity_level = 'CONFIDENTIAL', redaction_method = 'NULLIFY'
WHERE object_id = p_object_id
AND column_name = c.column_name;
END IF;
END LOOP;
END;
PROCEDURE ensure_user_role(p_user_id IN NUMBER, p_role_id IN NUMBER) IS
v_count NUMBER;
BEGIN
SELECT COUNT(*) INTO v_count
FROM cb_user_role
WHERE user_id = p_user_id
AND role_id = p_role_id;
IF v_count = 0 THEN
INSERT INTO cb_user_role(user_id, role_id) VALUES (p_user_id, p_role_id);
END IF;
END;
PROCEDURE ensure_permission(
p_role_id IN NUMBER,
p_target_name IN VARCHAR2,
p_rule_column IN VARCHAR2,
p_rule_type IN VARCHAR2
) IS
v_permission_id NUMBER;
BEGIN
SELECT perm_id INTO v_permission_id
FROM cb_permission
WHERE role_id = p_role_id
AND target_name = p_target_name
AND action_name = 'SELECT';
EXCEPTION
WHEN NO_DATA_FOUND THEN
SELECT cb_permission_seq.NEXTVAL INTO v_permission_id FROM dual;
INSERT INTO cb_permission(perm_id, role_id, target_name, action_name, permission_effect)
VALUES (v_permission_id, p_role_id, p_target_name, 'SELECT', 'ALLOW');
INSERT INTO cb_permission_rule(rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (cb_permission_rule_seq.NEXTVAL, v_permission_id, p_rule_column, p_rule_type, NULL);
END;
BEGIN
ensure_role('KB_DESIGNER_ROLE', 'CONFIDENTIAL', v_designer_role_id);
ensure_role('KB_BRANCH_MANAGER_ROLE', 'CONFIDENTIAL', v_manager_role_id);
FOR s IN (
SELECT user_id, user_nm, role, channel
FROM poc_2.kb_stakeholders
ORDER BY user_id
) LOOP
BEGIN
SELECT user_id INTO v_user_id
FROM cb_app_user
WHERE stakeholder_user_id = s.user_id;
UPDATE cb_app_user
SET user_name = s.user_nm,
employee_no = s.user_id,
dept_code = s.channel,
active = 'Y'
WHERE user_id = v_user_id;
EXCEPTION
WHEN NO_DATA_FOUND THEN
SELECT NVL(MAX(user_id), 0) + 1 INTO v_user_id FROM cb_app_user;
INSERT INTO cb_app_user(
user_id, user_name, employee_no, dept_code, stakeholder_user_id, can_read_contents, active
) VALUES (
v_user_id, s.user_nm, s.user_id, s.channel, s.user_id, 'N', 'Y'
);
END;
IF s.role = '지점장' THEN
ensure_user_role(v_user_id, v_manager_role_id);
ELSIF s.role = '설계사' THEN
ensure_user_role(v_user_id, v_designer_role_id);
END IF;
END LOOP;
FOR o IN (
SELECT 'KB_STAKEHOLDERS' object_name, '토큰 업무 주체·역할·채널 원장' description FROM dual
UNION ALL SELECT 'KB_CUSTOMERS', '고객 기본정보 원장' FROM dual
UNION ALL SELECT 'KB_PRODUCTS', '보험상품 마스터' FROM dual
UNION ALL SELECT 'KB_CONTRACTS', '보험계약 원장' FROM dual
UNION ALL SELECT 'KB_COVERAGES', '담보·특약 원장' FROM dual
UNION ALL SELECT 'KB_CLAIMS', '보험금 청구·지급 원장' FROM dual
UNION ALL SELECT 'KB_EXTERNAL_HOLDINGS', '외부보유정보 원장' FROM dual
) LOOP
ensure_object(o.object_name, o.description, v_object_id);
ensure_columns(o.object_name, v_object_id);
END LOOP;
-- 토큰은 모든 이해당사자에게 발급되지만, 아래 역할이 없는 주체는
-- 권한 레코드가 없으므로 VPD가 1=0으로 fail-closed 된다.
ensure_permission(v_designer_role_id, 'KB_STAKEHOLDERS', 'USER_ID', 'STAKEHOLDER_SELF');
ensure_permission(v_manager_role_id, 'KB_STAKEHOLDERS', 'USER_ID', 'STAKEHOLDER_SELF');
ensure_permission(v_designer_role_id, 'KB_PRODUCTS', NULL, 'ALL');
ensure_permission(v_manager_role_id, 'KB_PRODUCTS', NULL, 'ALL');
ensure_permission(v_designer_role_id, 'KB_CONTRACTS', 'FC_ID', 'STAKEHOLDER_SELF');
ensure_permission(v_manager_role_id, 'KB_CONTRACTS', 'FC_CHANNEL', 'STAKEHOLDER_CHANNEL');
ensure_permission(v_designer_role_id, 'KB_CUSTOMERS', 'CUST_ID', 'STAKEHOLDER_SELF');
ensure_permission(v_manager_role_id, 'KB_CUSTOMERS', 'CUST_ID', 'STAKEHOLDER_CHANNEL');
ensure_permission(v_designer_role_id, 'KB_COVERAGES', 'CUST_ID', 'STAKEHOLDER_SELF');
ensure_permission(v_manager_role_id, 'KB_COVERAGES', 'CUST_ID', 'STAKEHOLDER_CHANNEL');
ensure_permission(v_designer_role_id, 'KB_CLAIMS', 'CUST_ID', 'STAKEHOLDER_SELF');
ensure_permission(v_manager_role_id, 'KB_CLAIMS', 'CUST_ID', 'STAKEHOLDER_CHANNEL');
ensure_permission(v_designer_role_id, 'KB_EXTERNAL_HOLDINGS', 'CUST_ID', 'STAKEHOLDER_SELF');
ensure_permission(v_manager_role_id, 'KB_EXTERNAL_HOLDINGS', 'CUST_ID', 'STAKEHOLDER_CHANNEL');
END;
/
COMMIT;
PROMPT === 4. Applying row VPD policies to KB business tables ===
DECLARE
PROCEDURE replace_row_policy(p_object_name IN VARCHAR2) IS
BEGIN
BEGIN
DBMS_RLS.DROP_POLICY(
object_schema => 'POC_2', object_name => p_object_name,
policy_name => 'KB_' || p_object_name || '_ROW_POLICY'
);
EXCEPTION WHEN OTHERS THEN NULL;
END;
DBMS_RLS.ADD_POLICY(
object_schema => 'POC_2',
object_name => p_object_name,
policy_name => 'KB_' || p_object_name || '_ROW_POLICY',
function_schema => USER,
policy_function => 'CB_AGENT_DOC_VPD_FILTER',
statement_types => 'SELECT',
policy_type => DBMS_RLS.DYNAMIC,
enable => TRUE
);
END;
BEGIN
replace_row_policy('KB_STAKEHOLDERS');
replace_row_policy('KB_CUSTOMERS');
replace_row_policy('KB_PRODUCTS');
replace_row_policy('KB_CONTRACTS');
replace_row_policy('KB_COVERAGES');
replace_row_policy('KB_CLAIMS');
replace_row_policy('KB_EXTERNAL_HOLDINGS');
END;
/
PROMPT === 5. Applying column NULL policies from permission display exceptions ===
-- ADB rejects application PL/SQL functions inside DBMS_REDACT expressions.
-- SEC_RELEVANT_COL_OPT=ALL_ROWS is the VPD equivalent: it retains the row
-- but returns NULL for the protected column while the column predicate is false.
CREATE OR REPLACE FUNCTION cb_kb_cust_nm_cls(p_schema IN VARCHAR2, p_object IN VARCHAR2)
RETURN VARCHAR2 AUTHID DEFINER AS
BEGIN
RETURN CASE WHEN cb_agent_can_read_column('KB_CUSTOMERS', 'CUST_NM') = 'Y' THEN '1 = 1' ELSE '1 = 0' END;
END;
/
CREATE OR REPLACE FUNCTION cb_kb_rrn_cls(p_schema IN VARCHAR2, p_object IN VARCHAR2)
RETURN VARCHAR2 AUTHID DEFINER AS
BEGIN
RETURN CASE WHEN cb_agent_can_read_column('KB_CUSTOMERS', 'RRN_MASKED') = 'Y' THEN '1 = 1' ELSE '1 = 0' END;
END;
/
CREATE OR REPLACE FUNCTION cb_kb_claim_amt_cls(p_schema IN VARCHAR2, p_object IN VARCHAR2)
RETURN VARCHAR2 AUTHID DEFINER AS
BEGIN
RETURN CASE WHEN cb_agent_can_read_column('KB_CLAIMS', 'CLAIM_AMT') = 'Y' THEN '1 = 1' ELSE '1 = 0' END;
END;
/
CREATE OR REPLACE FUNCTION cb_kb_paid_amt_cls(p_schema IN VARCHAR2, p_object IN VARCHAR2)
RETURN VARCHAR2 AUTHID DEFINER AS
BEGIN
RETURN CASE WHEN cb_agent_can_read_column('KB_CLAIMS', 'PAID_AMT') = 'Y' THEN '1 = 1' ELSE '1 = 0' END;
END;
/
CREATE OR REPLACE FUNCTION cb_kb_ext_insurer_cls(p_schema IN VARCHAR2, p_object IN VARCHAR2)
RETURN VARCHAR2 AUTHID DEFINER AS
BEGIN
RETURN CASE WHEN cb_agent_can_read_column('KB_EXTERNAL_HOLDINGS', 'EXT_INSURER') = 'Y' THEN '1 = 1' ELSE '1 = 0' END;
END;
/
CREATE OR REPLACE FUNCTION cb_kb_ext_product_grp_cls(p_schema IN VARCHAR2, p_object IN VARCHAR2)
RETURN VARCHAR2 AUTHID DEFINER AS
BEGIN
RETURN CASE WHEN cb_agent_can_read_column('KB_EXTERNAL_HOLDINGS', 'EXT_PRODUCT_GRP') = 'Y' THEN '1 = 1' ELSE '1 = 0' END;
END;
/
CREATE OR REPLACE FUNCTION cb_kb_ext_product_type_cls(p_schema IN VARCHAR2, p_object IN VARCHAR2)
RETURN VARCHAR2 AUTHID DEFINER AS
BEGIN
RETURN CASE WHEN cb_agent_can_read_column('KB_EXTERNAL_HOLDINGS', 'EXT_PRODUCT_TYPE') = 'Y' THEN '1 = 1' ELSE '1 = 0' END;
END;
/
DECLARE
PROCEDURE replace_column_policy(
p_object_name IN VARCHAR2,
p_column_name IN VARCHAR2,
p_policy_name IN VARCHAR2,
p_function_name IN VARCHAR2
) IS
BEGIN
BEGIN
DBMS_RLS.DROP_POLICY(
object_schema => 'POC_2', object_name => p_object_name, policy_name => p_policy_name
);
EXCEPTION WHEN OTHERS THEN NULL;
END;
DBMS_RLS.ADD_POLICY(
object_schema => 'POC_2',
object_name => p_object_name,
policy_name => p_policy_name,
function_schema => USER,
policy_function => p_function_name,
statement_types => 'SELECT',
policy_type => DBMS_RLS.DYNAMIC,
sec_relevant_cols => p_column_name,
sec_relevant_cols_opt => DBMS_RLS.ALL_ROWS,
enable => TRUE
);
END;
BEGIN
replace_column_policy('KB_CUSTOMERS', 'CUST_NM', 'KB_CUST_NM_CLS_POLICY', 'CB_KB_CUST_NM_CLS');
replace_column_policy('KB_CUSTOMERS', 'RRN_MASKED', 'KB_RRN_CLS_POLICY', 'CB_KB_RRN_CLS');
replace_column_policy('KB_CLAIMS', 'CLAIM_AMT', 'KB_CLAIM_AMT_CLS_POLICY', 'CB_KB_CLAIM_AMT_CLS');
replace_column_policy('KB_CLAIMS', 'PAID_AMT', 'KB_PAID_AMT_CLS_POLICY', 'CB_KB_PAID_AMT_CLS');
replace_column_policy('KB_EXTERNAL_HOLDINGS', 'EXT_INSURER', 'KB_EXT_INSURER_CLS_POLICY', 'CB_KB_EXT_INSURER_CLS');
replace_column_policy('KB_EXTERNAL_HOLDINGS', 'EXT_PRODUCT_GRP', 'KB_EXT_PRODUCT_GRP_CLS_POLICY', 'CB_KB_EXT_PRODUCT_GRP_CLS');
replace_column_policy('KB_EXTERNAL_HOLDINGS', 'EXT_PRODUCT_TYPE', 'KB_EXT_PRODUCT_TYPE_CLS_POLICY', 'CB_KB_EXT_PRODUCT_TYPE_CLS');
END;
/
PROMPT === 6. Example configuration summary ===
SELECT r.role_name,
p.target_name,
pr.rule_column,
pr.rule_type,
NVL(pr.rule_value, '-') AS rule_value
FROM cb_permission p
JOIN cb_app_role r ON r.role_id = p.role_id
JOIN cb_permission_rule pr ON pr.perm_id = p.perm_id
WHERE r.role_name IN ('KB_DESIGNER_ROLE', 'KB_BRANCH_MANAGER_ROLE')
ORDER BY r.role_name, p.target_name, pr.rule_id;
PROMPT === KB stakeholder token permission example complete ===

View File

@@ -0,0 +1,18 @@
package com.cloudhandson.vpdbackoffice.domain.stakeholder;
/**
* 업무 사용자 원장과 백오피스 권한 bridge를 결합한 Bearer Token 발급 대상이다.
*/
public record StakeholderTokenSubject(
String stakeholderUserId,
String username,
String role,
String channel,
String accessScope,
long appUserId
) {
public String displayLabel() {
return username + " / " + stakeholderUserId + " / " + role + " / " + channel;
}
}

View File

@@ -6,6 +6,9 @@ public record BearerTokenRecord(
long keyId, long keyId,
long userId, long userId,
String username, String username,
String stakeholderUserId,
String stakeholderRole,
String stakeholderChannel,
String keyPrefix, String keyPrefix,
String keyHash, String keyHash,
LocalDateTime expiresAt, LocalDateTime expiresAt,

View File

@@ -7,6 +7,9 @@ public record TokenContextView(
long keyId, long keyId,
long userId, long userId,
String username, String username,
String stakeholderUserId,
String stakeholderRole,
String stakeholderChannel,
String keyPrefix, String keyPrefix,
LocalDateTime expiresAt, LocalDateTime expiresAt,
LocalDateTime revokedAt, LocalDateTime revokedAt,

View File

@@ -0,0 +1,14 @@
package com.cloudhandson.vpdbackoffice.mapper;
import com.cloudhandson.vpdbackoffice.domain.stakeholder.StakeholderTokenSubject;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface StakeholderMapper {
List<StakeholderTokenSubject> findTokenSubjects();
StakeholderTokenSubject findTokenSubject(@Param("stakeholderUserId") String stakeholderUserId);
}

View File

@@ -31,6 +31,7 @@ public class BackofficeSchemaService {
user_name VARCHAR2(100) NOT NULL UNIQUE, user_name VARCHAR2(100) NOT NULL UNIQUE,
employee_no VARCHAR2(50), employee_no VARCHAR2(50),
dept_code VARCHAR2(50), dept_code VARCHAR2(50),
stakeholder_user_id VARCHAR2(30),
can_read_contents CHAR(1) DEFAULT 'N' CHECK (can_read_contents IN ('Y','N')) NOT NULL, can_read_contents CHAR(1) DEFAULT 'N' CHECK (can_read_contents IN ('Y','N')) NOT NULL,
active CHAR(1) DEFAULT 'Y' CHECK (active IN ('Y','N')) NOT NULL active CHAR(1) DEFAULT 'Y' CHECK (active IN ('Y','N')) NOT NULL
) )
@@ -101,6 +102,7 @@ public class BackofficeSchemaService {
CREATE TABLE cb_agent_bearer_key ( CREATE TABLE cb_agent_bearer_key (
key_id NUMBER PRIMARY KEY, key_id NUMBER PRIMARY KEY,
user_id NUMBER NOT NULL, user_id NUMBER NOT NULL,
stakeholder_user_id VARCHAR2(30),
key_prefix VARCHAR2(30) NOT NULL, key_prefix VARCHAR2(30) NOT NULL,
key_hash VARCHAR2(128) NOT NULL UNIQUE, key_hash VARCHAR2(128) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL, expires_at TIMESTAMP NOT NULL,
@@ -178,6 +180,10 @@ public class BackofficeSchemaService {
"ALTER TABLE cb_permission ADD (permission_effect VARCHAR2(10) DEFAULT 'ALLOW' NOT NULL)"), "ALTER TABLE cb_permission ADD (permission_effect VARCHAR2(10) DEFAULT 'ALLOW' NOT NULL)"),
new ColumnDefinition("CB_PERMISSION_RULE", "RULE_COLUMN", new ColumnDefinition("CB_PERMISSION_RULE", "RULE_COLUMN",
"ALTER TABLE cb_permission_rule ADD (rule_column VARCHAR2(128))"), "ALTER TABLE cb_permission_rule ADD (rule_column VARCHAR2(128))"),
new ColumnDefinition("CB_APP_USER", "STAKEHOLDER_USER_ID",
"ALTER TABLE cb_app_user ADD (stakeholder_user_id VARCHAR2(30))"),
new ColumnDefinition("CB_AGENT_BEARER_KEY", "STAKEHOLDER_USER_ID",
"ALTER TABLE cb_agent_bearer_key ADD (stakeholder_user_id VARCHAR2(30))"),
new ColumnDefinition("CB_AGENT_BEARER_KEY", "DESCRIPTION", new ColumnDefinition("CB_AGENT_BEARER_KEY", "DESCRIPTION",
"ALTER TABLE cb_agent_bearer_key ADD (description VARCHAR2(200))"), "ALTER TABLE cb_agent_bearer_key ADD (description VARCHAR2(200))"),
new ColumnDefinition("CB_PROTECTED_OBJECT", "DESCRIPTION", new ColumnDefinition("CB_PROTECTED_OBJECT", "DESCRIPTION",

View File

@@ -8,10 +8,12 @@ import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
import com.cloudhandson.vpdbackoffice.domain.token.IssuedToken; import com.cloudhandson.vpdbackoffice.domain.token.IssuedToken;
import com.cloudhandson.vpdbackoffice.domain.token.TokenContextView; import com.cloudhandson.vpdbackoffice.domain.token.TokenContextView;
import com.cloudhandson.vpdbackoffice.domain.token.TokenIssueCommand; import com.cloudhandson.vpdbackoffice.domain.token.TokenIssueCommand;
import com.cloudhandson.vpdbackoffice.domain.stakeholder.StakeholderTokenSubject;
import com.cloudhandson.vpdbackoffice.domain.user.AppUser; import com.cloudhandson.vpdbackoffice.domain.user.AppUser;
import com.cloudhandson.vpdbackoffice.domain.user.UserRoleView; import com.cloudhandson.vpdbackoffice.domain.user.UserRoleView;
import com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper; import com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper;
import com.cloudhandson.vpdbackoffice.mapper.GroupMapper; import com.cloudhandson.vpdbackoffice.mapper.GroupMapper;
import com.cloudhandson.vpdbackoffice.mapper.StakeholderMapper;
import com.cloudhandson.vpdbackoffice.mapper.UserMapper; import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
import java.time.Clock; import java.time.Clock;
import java.time.Duration; import java.time.Duration;
@@ -33,6 +35,7 @@ public class BearerTokenService {
private final BearerTokenMapper tokenMapper; private final BearerTokenMapper tokenMapper;
private final UserMapper userMapper; private final UserMapper userMapper;
private final GroupMapper groupMapper; private final GroupMapper groupMapper;
private final StakeholderMapper stakeholderMapper;
private final AuditService auditService; private final AuditService auditService;
private final TokenGenerator tokenGenerator; private final TokenGenerator tokenGenerator;
private final TokenHasher tokenHasher; private final TokenHasher tokenHasher;
@@ -43,6 +46,7 @@ public class BearerTokenService {
BearerTokenMapper tokenMapper, BearerTokenMapper tokenMapper,
UserMapper userMapper, UserMapper userMapper,
GroupMapper groupMapper, GroupMapper groupMapper,
StakeholderMapper stakeholderMapper,
AuditService auditService, AuditService auditService,
TokenGenerator tokenGenerator, TokenGenerator tokenGenerator,
TokenHasher tokenHasher, TokenHasher tokenHasher,
@@ -52,6 +56,7 @@ public class BearerTokenService {
this.tokenMapper = tokenMapper; this.tokenMapper = tokenMapper;
this.userMapper = userMapper; this.userMapper = userMapper;
this.groupMapper = groupMapper; this.groupMapper = groupMapper;
this.stakeholderMapper = stakeholderMapper;
this.auditService = auditService; this.auditService = auditService;
this.tokenGenerator = tokenGenerator; this.tokenGenerator = tokenGenerator;
this.tokenHasher = tokenHasher; this.tokenHasher = tokenHasher;
@@ -80,6 +85,9 @@ public class BearerTokenService {
token.keyId(), token.keyId(),
token.userId(), token.userId(),
token.username(), token.username(),
token.stakeholderUserId(),
token.stakeholderRole(),
token.stakeholderChannel(),
token.keyPrefix(), token.keyPrefix(),
token.expiresAt(), token.expiresAt(),
token.revokedAt(), token.revokedAt(),
@@ -114,6 +122,10 @@ public class BearerTokenService {
return tokenMapper.findByHash(tokenHasher.sha256(plainToken)); return tokenMapper.findByHash(tokenHasher.sha256(plainToken));
} }
public List<StakeholderTokenSubject> findStakeholderTokenSubjects() {
return stakeholderMapper.findTokenSubjects();
}
public boolean matches(BearerTokenRecord record, String plainToken) { public boolean matches(BearerTokenRecord record, String plainToken) {
if (record == null || plainToken == null || plainToken.isBlank()) { if (record == null || plainToken == null || plainToken.isBlank()) {
return false; return false;
@@ -171,19 +183,42 @@ public class BearerTokenService {
throw new AppException("비활성 사용자에게는 토큰을 발급할 수 없습니다."); throw new AppException("비활성 사용자에게는 토큰을 발급할 수 없습니다.");
} }
OffsetDateTime now = OffsetDateTime.now(clock); return issueForUser(user, null, command.expiresAt(), command.description());
if (command.expiresAt() == null || !command.expiresAt().isAfter(now)) { }
throw new AppException("만료일은 현재 시각 이후여야 합니다.");
@Transactional
public IssuedToken issueStakeholderToken(
String stakeholderUserId,
OffsetDateTime expiresAt,
String description
) {
if (stakeholderUserId == null || stakeholderUserId.isBlank()) {
throw new AppException("토큰을 발급할 이해당사자를 선택하세요.");
} }
if (command.expiresAt().isAfter(now.plusDays(properties.token().maxDays()))) { StakeholderTokenSubject subject = stakeholderMapper.findTokenSubject(stakeholderUserId.trim());
throw new AppException("토큰 만료일이 최대 허용 기간을 초과했습니다."); if (subject == null) {
throw new AppException("이해당사자 원장과 연결된 활성 사용자를 찾을 수 없습니다.");
} }
AppUser bridgeUser = userMapper.findById(subject.appUserId());
if (bridgeUser == null || !bridgeUser.active()) {
throw new AppException("이해당사자 권한 bridge 사용자가 비활성 상태입니다.");
}
return issueForUser(bridgeUser, subject.stakeholderUserId(), expiresAt, description);
}
private IssuedToken issueForUser(
AppUser user,
String stakeholderUserId,
OffsetDateTime expiresAt,
String description
) {
validateExpiry(expiresAt);
String plainToken = tokenGenerator.generate(); String plainToken = tokenGenerator.generate();
String prefix = tokenGenerator.prefix(plainToken); String prefix = tokenGenerator.prefix(plainToken);
String hash = tokenHasher.sha256(plainToken); String hash = tokenHasher.sha256(plainToken);
long keyId = tokenMapper.nextKeyId(); long keyId = tokenMapper.nextKeyId();
LocalDateTime expiresAt = command.expiresAt() LocalDateTime localExpiresAt = expiresAt
.atZoneSameInstant(ZoneId.systemDefault()) .atZoneSameInstant(ZoneId.systemDefault())
.toLocalDateTime(); .toLocalDateTime();
@@ -191,14 +226,28 @@ public class BearerTokenService {
keyId, keyId,
user.userId(), user.userId(),
user.username(), user.username(),
stakeholderUserId,
null,
null,
prefix, prefix,
hash, hash,
expiresAt, localExpiresAt,
null, null,
command.description() description
)); ));
auditService.record(new AuditEvent("TOKEN_ISSUED", keyId, null, "SUCCESS", null, null, "issued")); auditService.record(new AuditEvent("TOKEN_ISSUED", keyId, null, "SUCCESS", null, null,
return new IssuedToken(keyId, prefix, plainToken, command.expiresAt()); stakeholderUserId == null ? "issued" : "stakeholder=" + stakeholderUserId));
return new IssuedToken(keyId, prefix, plainToken, expiresAt);
}
private void validateExpiry(OffsetDateTime expiresAt) {
OffsetDateTime now = OffsetDateTime.now(clock);
if (expiresAt == null || !expiresAt.isAfter(now)) {
throw new AppException("만료일은 현재 시각 이후여야 합니다.");
}
if (expiresAt.isAfter(now.plusDays(properties.token().maxDays()))) {
throw new AppException("토큰 만료일이 최대 허용 기간을 초과했습니다.");
}
} }
@Transactional @Transactional

View File

@@ -21,7 +21,8 @@ import org.springframework.transaction.annotation.Transactional;
public class PermissionService { public class PermissionService {
private static final Set<String> RULE_TYPES = Set.of( private static final Set<String> RULE_TYPES = Set.of(
"ALL", "=", "!=", "MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG"); "ALL", "=", "!=", "MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG",
"STAKEHOLDER_SELF", "STAKEHOLDER_CHANNEL");
private static final Set<String> VALUE_REQUIRED_RULE_TYPES = Set.of( private static final Set<String> VALUE_REQUIRED_RULE_TYPES = Set.of(
"=", "!=", "DEPT", "EMP_NO", "TAG"); "=", "!=", "DEPT", "EMP_NO", "TAG");
private static final Set<String> DEFAULT_COLUMN_RULE_TYPES = Set.of( private static final Set<String> DEFAULT_COLUMN_RULE_TYPES = Set.of(

View File

@@ -1,7 +1,5 @@
package com.cloudhandson.vpdbackoffice.web; package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.token.TokenIssueCommand;
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
import com.cloudhandson.vpdbackoffice.service.BearerTokenService; import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
import java.time.Clock; import java.time.Clock;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -20,12 +18,10 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
public class TokenController { public class TokenController {
private final BearerTokenService tokenService; private final BearerTokenService tokenService;
private final UserMapper userMapper;
private final Clock clock; private final Clock clock;
public TokenController(BearerTokenService tokenService, UserMapper userMapper, Clock clock) { public TokenController(BearerTokenService tokenService, Clock clock) {
this.tokenService = tokenService; this.tokenService = tokenService;
this.userMapper = userMapper;
this.clock = clock; this.clock = clock;
} }
@@ -36,23 +32,23 @@ public class TokenController {
) { ) {
model.addAttribute("tokens", tokenService.findAll(includeInactive)); model.addAttribute("tokens", tokenService.findAll(includeInactive));
model.addAttribute("includeInactive", includeInactive); model.addAttribute("includeInactive", includeInactive);
model.addAttribute("users", userMapper.findAll()); model.addAttribute("stakeholders", tokenService.findStakeholderTokenSubjects());
model.addAttribute("defaultExpiresAt", defaultExpiresAt()); model.addAttribute("defaultExpiresAt", defaultExpiresAt());
return "tokens"; return "tokens";
} }
@PostMapping("/tokens") @PostMapping("/tokens")
public String issue( public String issue(
@RequestParam long userId, @RequestParam String stakeholderUserId,
@RequestParam String expiresAt, @RequestParam String expiresAt,
@RequestParam(required = false) String description, @RequestParam(required = false) String description,
RedirectAttributes redirectAttributes RedirectAttributes redirectAttributes
) { ) {
var issued = tokenService.issueToken(new TokenIssueCommand( var issued = tokenService.issueStakeholderToken(
userId, stakeholderUserId,
parseBrowserDateTime(expiresAt), parseBrowserDateTime(expiresAt),
description description
)); );
redirectAttributes.addFlashAttribute("issued", issued); redirectAttributes.addFlashAttribute("issued", issued);
return "redirect:/tokens"; return "redirect:/tokens";
} }

View File

@@ -3,28 +3,37 @@
"https://mybatis.org/dtd/mybatis-3-mapper.dtd"> "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper"> <mapper namespace="com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper">
<select id="findAll" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord"> <select id="findAll" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
SELECT k.key_id, k.user_id, u.user_name AS username, k.key_prefix, k.key_hash, SELECT k.key_id, k.user_id, COALESCE(s.user_nm, u.user_name) AS username,
k.stakeholder_user_id, s.role AS stakeholder_role, s.channel AS stakeholder_channel,
k.key_prefix, k.key_hash,
k.expires_at, k.revoked_at, k.description k.expires_at, k.revoked_at, k.description
FROM cb_agent_bearer_key k FROM cb_agent_bearer_key k
JOIN cb_app_user u ON u.user_id = k.user_id JOIN cb_app_user u ON u.user_id = k.user_id
LEFT JOIN poc_2.kb_stakeholders s ON s.user_id = k.stakeholder_user_id
WHERE #{includeInactive} = 1 WHERE #{includeInactive} = 1
OR (k.revoked_at IS NULL AND k.expires_at > SYSTIMESTAMP) OR (k.revoked_at IS NULL AND k.expires_at > SYSTIMESTAMP)
ORDER BY k.key_id DESC ORDER BY k.key_id DESC
</select> </select>
<select id="findById" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord"> <select id="findById" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
SELECT k.key_id, k.user_id, u.user_name AS username, k.key_prefix, k.key_hash, SELECT k.key_id, k.user_id, COALESCE(s.user_nm, u.user_name) AS username,
k.stakeholder_user_id, s.role AS stakeholder_role, s.channel AS stakeholder_channel,
k.key_prefix, k.key_hash,
k.expires_at, k.revoked_at, k.description k.expires_at, k.revoked_at, k.description
FROM cb_agent_bearer_key k FROM cb_agent_bearer_key k
JOIN cb_app_user u ON u.user_id = k.user_id JOIN cb_app_user u ON u.user_id = k.user_id
LEFT JOIN poc_2.kb_stakeholders s ON s.user_id = k.stakeholder_user_id
WHERE k.key_id = #{keyId} WHERE k.key_id = #{keyId}
</select> </select>
<select id="findByHash" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord"> <select id="findByHash" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
SELECT k.key_id, k.user_id, u.user_name AS username, k.key_prefix, k.key_hash, SELECT k.key_id, k.user_id, COALESCE(s.user_nm, u.user_name) AS username,
k.stakeholder_user_id, s.role AS stakeholder_role, s.channel AS stakeholder_channel,
k.key_prefix, k.key_hash,
k.expires_at, k.revoked_at, k.description k.expires_at, k.revoked_at, k.description
FROM cb_agent_bearer_key k FROM cb_agent_bearer_key k
JOIN cb_app_user u ON u.user_id = k.user_id JOIN cb_app_user u ON u.user_id = k.user_id
LEFT JOIN poc_2.kb_stakeholders s ON s.user_id = k.stakeholder_user_id
WHERE k.key_hash = #{keyHash,jdbcType=VARCHAR} WHERE k.key_hash = #{keyHash,jdbcType=VARCHAR}
</select> </select>
@@ -34,10 +43,11 @@
<insert id="insertToken" parameterType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord"> <insert id="insertToken" parameterType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
INSERT INTO cb_agent_bearer_key ( INSERT INTO cb_agent_bearer_key (
key_id, user_id, key_prefix, key_hash, expires_at, revoked_at, description key_id, user_id, stakeholder_user_id, key_prefix, key_hash, expires_at, revoked_at, description
) VALUES ( ) VALUES (
#{keyId,jdbcType=NUMERIC}, #{keyId,jdbcType=NUMERIC},
#{userId,jdbcType=NUMERIC}, #{userId,jdbcType=NUMERIC},
#{stakeholderUserId,jdbcType=VARCHAR},
#{keyPrefix,jdbcType=VARCHAR}, #{keyPrefix,jdbcType=VARCHAR},
#{keyHash,jdbcType=VARCHAR}, #{keyHash,jdbcType=VARCHAR},
#{expiresAt,jdbcType=TIMESTAMP}, #{expiresAt,jdbcType=TIMESTAMP},

View File

@@ -93,6 +93,10 @@
WHEN pr2.rule_type = 'TAG' THEN 'REGEXP_LIKE(UPPER(' WHEN pr2.rule_type = 'TAG' THEN 'REGEXP_LIKE(UPPER('
|| NVL(pr2.rule_column, 'TECH_TAG') || NVL(pr2.rule_column, 'TECH_TAG')
|| '), ''(^|,)' || REPLACE(UPPER(pr2.rule_value), '''', '''''') || '(,|$)'')' || '), ''(^|,)' || REPLACE(UPPER(pr2.rule_value), '''', '''''') || '(,|$)'')'
WHEN pr2.rule_type = 'STAKEHOLDER_SELF' THEN pr2.rule_column
|| ' = token stakeholder / 담당계약'
WHEN pr2.rule_type = 'STAKEHOLDER_CHANNEL' THEN pr2.rule_column
|| ' = token stakeholder channel / 담당계약'
ELSE pr2.rule_column || ' ' || pr2.rule_type || ' ' || pr2.rule_value ELSE pr2.rule_column || ' ' || pr2.rule_type || ' ' || pr2.rule_value
END, END,
CHR(10) || 'AND ' CHR(10) || 'AND '

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.StakeholderMapper">
<select id="findTokenSubjects" resultType="com.cloudhandson.vpdbackoffice.domain.stakeholder.StakeholderTokenSubject">
SELECT s.user_id AS stakeholder_user_id,
s.user_nm AS username,
s.role,
s.channel,
s.access_scope,
u.user_id AS app_user_id
FROM poc_2.kb_stakeholders s
JOIN cb_app_user u ON u.stakeholder_user_id = s.user_id
WHERE u.active = 'Y'
ORDER BY CASE s.role WHEN '지점장' THEN 1 WHEN '설계사' THEN 2 ELSE 3 END,
s.channel,
s.user_nm,
s.user_id
</select>
<select id="findTokenSubject" resultType="com.cloudhandson.vpdbackoffice.domain.stakeholder.StakeholderTokenSubject">
SELECT s.user_id AS stakeholder_user_id,
s.user_nm AS username,
s.role,
s.channel,
s.access_scope,
u.user_id AS app_user_id
FROM poc_2.kb_stakeholders s
JOIN cb_app_user u ON u.stakeholder_user_id = s.user_id
WHERE s.user_id = #{stakeholderUserId,jdbcType=VARCHAR}
AND u.active = 'Y'
</select>
</mapper>

View File

@@ -129,6 +129,25 @@ body {
gap: .5rem; gap: .5rem;
} }
.rw-quick-link {
border: 1px solid var(--rw-border);
border-radius: 999px;
color: var(--rw-primary-hover);
font-size: .82rem;
font-weight: 750;
min-height: 2rem;
padding: .35rem .7rem;
text-decoration: none;
white-space: nowrap;
}
.rw-quick-link:hover,
.rw-quick-link:focus {
background: var(--rw-primary-soft);
border-color: var(--rw-primary);
color: var(--rw-primary-hover);
}
.rw-admin-trigger { .rw-admin-trigger {
background: var(--rw-surface); background: var(--rw-surface);
border: 1px solid var(--rw-border-strong); border: 1px solid var(--rw-border-strong);

View File

@@ -397,13 +397,15 @@ function syncRuleTypeHints(root = document) {
DEPT: '예: HR', DEPT: '예: HR',
EMP_NO: '예: E2001', EMP_NO: '예: E2001',
TAG: '예: SPRING_BOOT', TAG: '예: SPRING_BOOT',
STAKEHOLDER_SELF: '토큰 이해당사자에서 자동 계산',
STAKEHOLDER_CHANNEL: '토큰 채널에서 자동 계산',
'=': '비교 값', '=': '비교 값',
'!=': '비교 값' '!=': '비교 값'
}; };
columnSelect.required = !columnOptional; columnSelect.required = !columnOptional;
valueInput.required = valueRequired; valueInput.required = valueRequired;
valueInput.disabled = ['ALL', 'MY_DEPT', 'SELF'].includes(type); valueInput.disabled = ['ALL', 'MY_DEPT', 'SELF', 'STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL'].includes(type);
valueInput.placeholder = placeholderByType[type] || '비교 값'; valueInput.placeholder = placeholderByType[type] || '비교 값';
if (valueInput.disabled) { if (valueInput.disabled) {
valueInput.value = ''; valueInput.value = '';
@@ -696,12 +698,14 @@ function collectWizardRules(root) {
DEPT: 'DEPT_CODE', DEPT: 'DEPT_CODE',
EMP_NO: 'OWNER_EMP_NO', EMP_NO: 'OWNER_EMP_NO',
TAG: 'TECH_TAG', TAG: 'TECH_TAG',
STAKEHOLDER_SELF: '선택 컬럼',
STAKEHOLDER_CHANNEL: '선택 컬럼',
ALL: '' ALL: ''
}[type] || ''; }[type] || '';
if (type === 'ALL') { if (type === 'ALL') {
return 'ALL'; return 'ALL';
} }
if (['MY_DEPT', 'SELF'].includes(type)) { if (['MY_DEPT', 'SELF', 'STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL'].includes(type)) {
return `${displayColumn} ${type}`; return `${displayColumn} ${type}`;
} }
if (type === 'TAG') { if (type === 'TAG') {
@@ -722,6 +726,8 @@ function collectWizardPredicates(root) {
DEPT: 'DEPT_CODE', DEPT: 'DEPT_CODE',
EMP_NO: 'OWNER_EMP_NO', EMP_NO: 'OWNER_EMP_NO',
TAG: 'TECH_TAG', TAG: 'TECH_TAG',
STAKEHOLDER_SELF: '선택 컬럼',
STAKEHOLDER_CHANNEL: '선택 컬럼',
ALL: '' ALL: ''
}[type] || ''; }[type] || '';
if (type === 'ALL') { if (type === 'ALL') {
@@ -733,6 +739,12 @@ function collectWizardPredicates(root) {
if (type === 'SELF') { if (type === 'SELF') {
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'EMP_NO')`; return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'EMP_NO')`;
} }
if (type === 'STAKEHOLDER_SELF') {
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_USER_ID') 또는 담당계약 EXISTS`;
}
if (type === 'STAKEHOLDER_CHANNEL') {
return `${displayColumn} = SYS_CONTEXT('CB_AGENT_CTX', 'STAKEHOLDER_CHANNEL') 또는 담당계약 EXISTS`;
}
if (type === 'DEPT' || type === 'EMP_NO') { if (type === 'DEPT' || type === 'EMP_NO') {
return `${displayColumn} = ${sqlLiteral(value)}`; return `${displayColumn} = ${sqlLiteral(value)}`;
} }
@@ -864,7 +876,7 @@ function permissionWizardRuleValidationMessage(wizard) {
if (!rule.type) { if (!rule.type) {
return `${position}번째 행 규칙 유형을 선택하세요.`; return `${position}번째 행 규칙 유형을 선택하세요.`;
} }
if (['=', '!='].includes(rule.type) && !rule.column) { if (['=', '!=', 'STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL'].includes(rule.type) && !rule.column) {
return `${position}번째 ${rule.type} 규칙에는 비교할 컬럼이 필요합니다.`; return `${position}번째 ${rule.type} 규칙에는 비교할 컬럼이 필요합니다.`;
} }
if (valueRequired.includes(rule.type) && !rule.value) { if (valueRequired.includes(rule.type) && !rule.value) {

View File

@@ -21,6 +21,8 @@
<button class="rw-menu-trigger" type="button" data-submenu-trigger="operations" aria-controls="submenu-operations" aria-expanded="false">운영</button> <button class="rw-menu-trigger" type="button" data-submenu-trigger="operations" aria-controls="submenu-operations" aria-expanded="false">운영</button>
</div> </div>
<div class="rw-nav-utility ms-auto"> <div class="rw-nav-utility ms-auto">
<a class="rw-quick-link" href="/permissions">권한 설정</a>
<a class="rw-quick-link" href="/tokens">토큰 발급</a>
<button class="rw-admin-trigger" type="button" data-submenu-trigger="admin" aria-controls="submenu-admin" aria-expanded="false">관리자</button> <button class="rw-admin-trigger" type="button" data-submenu-trigger="admin" aria-controls="submenu-admin" aria-expanded="false">관리자</button>
<form method="post" action="/logout"> <form method="post" action="/logout">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
@@ -34,12 +36,12 @@
<a class="nav-link" href="/users">사용자 관리</a> <a class="nav-link" href="/users">사용자 관리</a>
<a class="nav-link" href="/groups">그룹 관리</a> <a class="nav-link" href="/groups">그룹 관리</a>
<a class="nav-link" href="/roles">역할 관리</a> <a class="nav-link" href="/roles">역할 관리</a>
<a class="nav-link" href="/permissions">접근 규칙</a> <a class="nav-link" href="/permissions">권한 설정</a>
<a class="nav-link" href="/effective-matrix">권한 현황</a> <a class="nav-link" href="/effective-matrix">권한 현황</a>
</div> </div>
<div id="submenu-protection" class="rw-submenu" data-submenu-panel="protection" role="navigation" aria-label="보호 및 검증 메뉴" hidden> <div id="submenu-protection" class="rw-submenu" data-submenu-panel="protection" role="navigation" aria-label="보호 및 검증 메뉴" hidden>
<a class="nav-link" href="/vpd-policies">보호 상태</a> <a class="nav-link" href="/vpd-policies">보호 상태</a>
<a class="nav-link" href="/tokens">검증 세션</a> <a class="nav-link" href="/tokens">토큰 발급</a>
<a class="nav-link" href="/probe">접근 검증</a> <a class="nav-link" href="/probe">접근 검증</a>
</div> </div>
<div id="submenu-integration" class="rw-submenu" data-submenu-panel="integration" role="navigation" aria-label="연동 도구 메뉴" hidden> <div id="submenu-integration" class="rw-submenu" data-submenu-panel="integration" role="navigation" aria-label="연동 도구 메뉴" hidden>

View File

@@ -8,7 +8,7 @@
<h1>접근 규칙</h1> <h1>접근 규칙</h1>
<details class="explanation-details"> <details class="explanation-details">
<summary>도움말</summary> <summary>도움말</summary>
<p>VPD policy function이 참조할 사용자, 역할, 행 규칙, 컬럼 원문 허용 규칙을 저장합니다. 이 화면에서 만든 규칙이 기본 Filter의 동적 입력이 됩니다.</p> <p>VPD policy function이 참조할 사용자, 역할, 행 규칙, 컬럼 원문 허용 규칙을 저장합니다. KB 원장 예제에서 <code>STAKEHOLDER_SELF</code>는 토큰의 이해당사자 ID, <code>STAKEHOLDER_CHANNEL</code>은 역할에 연결된 채널로 행 범위를 계산합니다.</p>
</details> </details>
</div> </div>
@@ -167,6 +167,8 @@
<option value="DEPT">지정 부서</option> <option value="DEPT">지정 부서</option>
<option value="EMP_NO">지정 사번</option> <option value="EMP_NO">지정 사번</option>
<option value="TAG">특정 기술 태그</option> <option value="TAG">특정 기술 태그</option>
<option value="STAKEHOLDER_SELF">이해당사자 본인 담당</option>
<option value="STAKEHOLDER_CHANNEL">이해당사자 채널 범위</option>
<option value="=">=</option> <option value="=">=</option>
<option value="!=">!=</option> <option value="!=">!=</option>
</select> </select>
@@ -176,7 +178,7 @@
</div> </div>
<details class="explanation-details"> <details class="explanation-details">
<summary>행 규칙과 기술 태그 OR 조건 보기</summary> <summary>행 규칙과 기술 태그 OR 조건 보기</summary>
<p class="wizard-hint">특정 기술 태그는 기본적으로 <code>TECH_TAG</code> 컬럼을 봅니다. 같은 ALLOW 권한에 태그를 여러 개 추가하면 “태그 A 또는 태그 B”로 조회되고, DENY 태그는 허용 결과에서 제외됩니다. 객체에 TECH_TAG가 없으면 실제 태그 컬럼을 직접 지정하세요.</p> <p class="wizard-hint">특정 기술 태그는 기본적으로 <code>TECH_TAG</code> 컬럼을 봅니다. 같은 ALLOW 권한에 태그를 여러 개 추가하면 “태그 A 또는 태그 B”로 조회됩니다. <code>이해당사자 본인 담당</code><code>FC_ID</code>를 직접 비교하거나 <code>CUST_ID</code>/<code>CONTRACT_NO</code>를 계약원장에 안전하게 연결합니다. <code>이해당사자 채널 범위</code>도 같은 방식으로 <code>FC_CHANNEL</code>을 비교합니다. 두 이해당사자 규칙은 비교할 컬럼을 반드시 선택하세요.</p>
</details> </details>
</div> </div>
</div> </div>
@@ -261,7 +263,7 @@
th:classappend="${permission.permissionEffect() == 'DENY'} ? ' text-bg-danger' : ' text-bg-success'" th:classappend="${permission.permissionEffect() == 'DENY'} ? ' text-bg-danger' : ' text-bg-success'"
th:text="${permission.permissionEffect()}">ALLOW</span> th:text="${permission.permissionEffect()}">ALLOW</span>
</td> </td>
<td><pre class="table-pre" th:text="${permission.filterPreview()} ?: ${permission.rules()} ?: '-'">ALL ROWS</pre></td> <td><pre class="table-pre" th:text="${permission.filterPreview() ?: permission.rules() ?: '-'}">ALL ROWS</pre></td>
<td th:text="${permission.visibleColumns()} ?: '-'">CONTENTS</td> <td th:text="${permission.visibleColumns()} ?: '-'">CONTENTS</td>
<td> <td>
<details class="row-management"> <details class="row-management">

View File

@@ -8,7 +8,7 @@
<h1>검증 세션</h1> <h1>검증 세션</h1>
<details class="explanation-details"> <details class="explanation-details">
<summary>도움말</summary> <summary>도움말</summary>
<p>검증할 사용자를 나타내는 토큰을 만들고, 한 번만 표시되는 원문을 다음 단계에서 사용합니다. 원문은 저장하지 않고 해시와 식별용 prefix만 보관합니다.</p> <p>이해당사자 원장에 등록된 업무 사용자를 토큰에 연결합니다. 토큰 원문은 한 번만 표시하며, DB에는 해시와 식별용 prefix만 보관합니다. 역할·채널·행 권한은 토큰이 아니라 접근 규칙에서 동적으로 계산합니다.</p>
</details> </details>
</div> </div>
@@ -27,16 +27,19 @@
<section class="content-band"> <section class="content-band">
<div class="section-heading"> <div class="section-heading">
<div> <div>
<h2>검증 사용자 선택</h2> <h2>이해당사자 토큰 발급</h2>
<p class="section-subtitle">토큰에는 사용자의 직접 역할과 그룹 상속 역할이 연결됩니다. 권한 자체를 토큰에 복사하지 않으므로 이후 권한 변경도 동적으로 반영됩니다.</p> <p class="section-subtitle">모든 KB 이해당사자에게 토큰을 발급할 수 있습니다. 지점장·설계사 등의 실제 행 범위는 접근 규칙에 연결된 역할로 결정되므로, 토큰을 다시 만들지 않아도 권한 변경이 반영됩니다.</p>
</div> </div>
</div> </div>
<form method="post" action="/tokens" class="form-grid"> <form method="post" action="/tokens" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label> <label>
이해당사자
<select class="form-select" name="userId" required> <select class="form-select" name="stakeholderUserId" required>
<option th:each="user : ${users}" th:value="${user.userId()}" th:text="${user.username()}"></option> <option value="" selected disabled>토큰을 발급할 이해당사자 선택</option>
<option th:each="stakeholder : ${stakeholders}"
th:value="${stakeholder.stakeholderUserId()}"
th:text="${stakeholder.displayLabel()}"></option>
</select> </select>
</label> </label>
<label> <label>
@@ -71,12 +74,13 @@
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm align-middle"> <table class="table table-sm align-middle">
<thead><tr><th>ID</th><th>사용자</th><th>용도</th><th>식별용 Prefix</th><th>만료</th><th>회수</th><th></th></tr></thead> <thead><tr><th>ID</th><th>이해당사자</th><th>역할·채널</th><th>용도</th><th>식별용 Prefix</th><th>만료</th><th>회수</th><th></th></tr></thead>
<tbody> <tbody>
<tr th:each="token : ${tokens}" <tr th:each="token : ${tokens}"
th:attr="data-key-id=${token.keyId()},data-description=${token.description()},data-revoked=${token.revokedAt() != null}"> th:attr="data-key-id=${token.keyId()},data-description=${token.description()},data-revoked=${token.revokedAt() != null}">
<td th:text="${token.keyId()}">1</td> <td th:text="${token.keyId()}">1</td>
<td th:text="${token.username()}">user</td> <td th:text="${token.username()}">user</td>
<td th:text="${token.stakeholderRole() == null ? '기존 앱 사용자' : token.stakeholderRole() + ' / ' + token.stakeholderChannel()}">지점장 / 설계사채널</td>
<td th:text="${token.description()} ?: '-'">용도</td> <td th:text="${token.description()} ?: '-'">용도</td>
<td><code th:text="${token.keyPrefix() + '****'}">vpd_****</code></td> <td><code th:text="${token.keyPrefix() + '****'}">vpd_****</code></td>
<td th:text="${token.expiresAt()}">2026</td> <td th:text="${token.expiresAt()}">2026</td>
@@ -90,7 +94,7 @@
</form> </form>
</td> </td>
</tr> </tr>
<tr th:if="${#lists.isEmpty(tokens)}"><td colspan="7" class="text-muted">아직 발급된 토큰이 없습니다.</td></tr> <tr th:if="${#lists.isEmpty(tokens)}"><td colspan="8" class="text-muted">아직 발급된 토큰이 없습니다.</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@@ -167,6 +167,44 @@ class PermissionServiceTest {
.containsExactly("SPRING_BOOT", "ORACLE_VPD"); .containsExactly("SPRING_BOOT", "ORACLE_VPD");
} }
@Test
void acceptsStakeholderContextRulesWithAnExplicitObjectColumn() {
var command = new PermissionSetCommand(
10L,
1L,
"SELECT",
"ALLOW",
List.of(
new RuleCommand("DEPT_CODE", "STAKEHOLDER_SELF", null),
new RuleCommand("OWNER_EMP_NO", "STAKEHOLDER_CHANNEL", null)
),
List.of()
);
permissionService.savePermissionSet(command);
FakePermissionMapper mapper = (FakePermissionMapper) permissionMapper;
assertThat(mapper.insertedRules)
.extracting(PermissionRule::ruleType)
.containsExactly("STAKEHOLDER_SELF", "STAKEHOLDER_CHANNEL");
}
@Test
void rejectsStakeholderContextRuleWithoutAnObjectColumn() {
var command = new PermissionSetCommand(
10L,
1L,
"SELECT",
"ALLOW",
List.of(new RuleCommand(null, "STAKEHOLDER_SELF", null)),
List.of()
);
assertThatThrownBy(() -> permissionService.savePermissionSet(command))
.isInstanceOf(AppException.class)
.hasMessageContaining("컬럼이 필요");
}
@Test @Test
void acceptsMultipleVisibleColumns() { void acceptsMultipleVisibleColumns() {
var command = new PermissionSetCommand( var command = new PermissionSetCommand(

View File

@@ -86,7 +86,7 @@ class GuidedFlowTemplateTest {
.contains("data-wizard-target=\"2\">대상") .contains("data-wizard-target=\"2\">대상")
.doesNotContain(">1 역할<", ">2 객체<", "wizard-step-number"); .doesNotContain(">1 역할<", ">2 객체<", "wizard-step-number");
assertThat(tokens) assertThat(tokens)
.contains("검증 사용자 선택", "검증 세션 발급") .contains("이해당사자 토큰 발급", "검증 세션 발급")
.doesNotContain("1. 검증할 사용자 선택", "2. 검증 세션 발급"); .doesNotContain("1. 검증할 사용자 선택", "2. 검증 세션 발급");
assertThat(probe) assertThat(probe)
.contains("접근 검증 실행", "<strong>검증 결과</strong>") .contains("접근 검증 실행", "<strong>검증 결과</strong>")