From 2cdc738eb90ee106588f148d1a02774ed10147dc Mon Sep 17 00:00:00 2001 From: devmrko Date: Tue, 7 Jul 2026 14:03:40 +0900 Subject: [PATCH] [Developer] #619 map stakeholder tokens to permission rules --- .../README.md | 87 +++ ...17_agent_ords_security_local_vpd_setup.sql | 8 +- ...agent_ords_security_dynamic_vpd_filter.sql | 52 ++ ...b_stakeholder_token_permission_example.sql | 533 ++++++++++++++++++ .../stakeholder/StakeholderTokenSubject.java | 18 + .../domain/token/BearerTokenRecord.java | 3 + .../domain/token/TokenContextView.java | 3 + .../mapper/StakeholderMapper.java | 14 + .../service/BackofficeSchemaService.java | 6 + .../service/BearerTokenService.java | 69 ++- .../service/PermissionService.java | 3 +- .../vpdbackoffice/web/TokenController.java | 16 +- .../resources/mapper/BearerTokenMapper.xml | 18 +- .../resources/mapper/PermissionMapper.xml | 4 + .../resources/mapper/StakeholderMapper.xml | 33 ++ src/main/resources/static/css/app.css | 19 + src/main/resources/static/js/app.js | 18 +- .../resources/templates/fragments/layout.html | 6 +- src/main/resources/templates/permissions.html | 8 +- src/main/resources/templates/tokens.html | 20 +- .../service/PermissionServiceTest.java | 38 ++ .../web/GuidedFlowTemplateTest.java | 2 +- 22 files changed, 933 insertions(+), 45 deletions(-) create mode 100644 docs/design/619-stakeholder-token-permission-example/README.md create mode 100644 sql/adb/46_kb_stakeholder_token_permission_example.sql create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/domain/stakeholder/StakeholderTokenSubject.java create mode 100644 src/main/java/com/cloudhandson/vpdbackoffice/mapper/StakeholderMapper.java create mode 100644 src/main/resources/mapper/StakeholderMapper.xml diff --git a/docs/design/619-stakeholder-token-permission-example/README.md b/docs/design/619-stakeholder-token-permission-example/README.md new file mode 100644 index 0000000..16c9c02 --- /dev/null +++ b/docs/design/619-stakeholder-token-permission-example/README.md @@ -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는 후속 범위다. diff --git a/sql/adb/17_agent_ords_security_local_vpd_setup.sql b/sql/adb/17_agent_ords_security_local_vpd_setup.sql index 7e72ed8..2536986 100644 --- a/sql/adb/17_agent_ords_security_local_vpd_setup.sql +++ b/sql/adb/17_agent_ords_security_local_vpd_setup.sql @@ -46,6 +46,7 @@ CREATE TABLE cb_app_user ( user_name VARCHAR2(50) NOT NULL, employee_no 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, 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 ( key_id NUMBER PRIMARY KEY, user_id NUMBER NOT NULL REFERENCES cb_app_user(user_id), + stakeholder_user_id VARCHAR2(30), key_hash VARCHAR2(128) NOT NULL UNIQUE, key_prefix VARCHAR2(16), issued_at DATE DEFAULT SYSDATE NOT NULL, @@ -96,9 +98,9 @@ CREATE TABLE cb_agent_bearer_key ( ); PROMPT === 3. Seeding permissions === -INSERT INTO cb_app_user VALUES (101, '이에이치알', 'E10234', 'HR', 'N', 'Y'); -INSERT INTO cb_app_user VALUES (102, '박파이넨스', 'E2001', 'FIN', 'N', 'Y'); -INSERT INTO cb_app_user VALUES (103, '김어드민', 'E99999', 'HQ', 'Y', 'Y'); +INSERT INTO cb_app_user VALUES (101, '이에이치알', 'E10234', 'HR', NULL, 'N', 'Y'); +INSERT INTO cb_app_user VALUES (102, '박파이넨스', 'E2001', 'FIN', NULL, 'N', '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 (20, 'FIN_SELF_ROLE', 'INTERNAL'); diff --git a/sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql b/sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql index 6e7bc35..73fda73 100644 --- a/sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql +++ b/sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql @@ -169,6 +169,58 @@ BEGIN quote_literal('(^|,)' || UPPER(TRIM(r.rule_value)) || '(,|$)') || ')' ); 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 v_column := safe_column(p_schema, p_object, r.rule_column); IF v_column IS NOT NULL THEN diff --git a/sql/adb/46_kb_stakeholder_token_permission_example.sql b/sql/adb/46_kb_stakeholder_token_permission_example.sql new file mode 100644 index 0000000..4cfda01 --- /dev/null +++ b/sql/adb/46_kb_stakeholder_token_permission_example.sql @@ -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 === diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/stakeholder/StakeholderTokenSubject.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/stakeholder/StakeholderTokenSubject.java new file mode 100644 index 0000000..8b223a6 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/stakeholder/StakeholderTokenSubject.java @@ -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; + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/BearerTokenRecord.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/BearerTokenRecord.java index ccb0651..f2118a5 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/BearerTokenRecord.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/BearerTokenRecord.java @@ -6,6 +6,9 @@ public record BearerTokenRecord( long keyId, long userId, String username, + String stakeholderUserId, + String stakeholderRole, + String stakeholderChannel, String keyPrefix, String keyHash, LocalDateTime expiresAt, diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenContextView.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenContextView.java index 01cb575..6a4cabd 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenContextView.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/TokenContextView.java @@ -7,6 +7,9 @@ public record TokenContextView( long keyId, long userId, String username, + String stakeholderUserId, + String stakeholderRole, + String stakeholderChannel, String keyPrefix, LocalDateTime expiresAt, LocalDateTime revokedAt, diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/StakeholderMapper.java b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/StakeholderMapper.java new file mode 100644 index 0000000..f70d392 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/StakeholderMapper.java @@ -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 findTokenSubjects(); + + StakeholderTokenSubject findTokenSubject(@Param("stakeholderUserId") String stakeholderUserId); +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java index ab1f53b..a0120c7 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java @@ -31,6 +31,7 @@ public class BackofficeSchemaService { user_name VARCHAR2(100) NOT NULL UNIQUE, employee_no 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, 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 ( key_id NUMBER PRIMARY KEY, user_id NUMBER NOT NULL, + stakeholder_user_id VARCHAR2(30), key_prefix VARCHAR2(30) NOT NULL, key_hash VARCHAR2(128) NOT NULL UNIQUE, 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)"), new ColumnDefinition("CB_PERMISSION_RULE", "RULE_COLUMN", "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", "ALTER TABLE cb_agent_bearer_key ADD (description VARCHAR2(200))"), new ColumnDefinition("CB_PROTECTED_OBJECT", "DESCRIPTION", diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java index cbddf94..1c34ac5 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java @@ -8,10 +8,12 @@ import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord; import com.cloudhandson.vpdbackoffice.domain.token.IssuedToken; import com.cloudhandson.vpdbackoffice.domain.token.TokenContextView; 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.UserRoleView; import com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper; import com.cloudhandson.vpdbackoffice.mapper.GroupMapper; +import com.cloudhandson.vpdbackoffice.mapper.StakeholderMapper; import com.cloudhandson.vpdbackoffice.mapper.UserMapper; import java.time.Clock; import java.time.Duration; @@ -33,6 +35,7 @@ public class BearerTokenService { private final BearerTokenMapper tokenMapper; private final UserMapper userMapper; private final GroupMapper groupMapper; + private final StakeholderMapper stakeholderMapper; private final AuditService auditService; private final TokenGenerator tokenGenerator; private final TokenHasher tokenHasher; @@ -43,6 +46,7 @@ public class BearerTokenService { BearerTokenMapper tokenMapper, UserMapper userMapper, GroupMapper groupMapper, + StakeholderMapper stakeholderMapper, AuditService auditService, TokenGenerator tokenGenerator, TokenHasher tokenHasher, @@ -52,6 +56,7 @@ public class BearerTokenService { this.tokenMapper = tokenMapper; this.userMapper = userMapper; this.groupMapper = groupMapper; + this.stakeholderMapper = stakeholderMapper; this.auditService = auditService; this.tokenGenerator = tokenGenerator; this.tokenHasher = tokenHasher; @@ -80,6 +85,9 @@ public class BearerTokenService { token.keyId(), token.userId(), token.username(), + token.stakeholderUserId(), + token.stakeholderRole(), + token.stakeholderChannel(), token.keyPrefix(), token.expiresAt(), token.revokedAt(), @@ -114,6 +122,10 @@ public class BearerTokenService { return tokenMapper.findByHash(tokenHasher.sha256(plainToken)); } + public List findStakeholderTokenSubjects() { + return stakeholderMapper.findTokenSubjects(); + } + public boolean matches(BearerTokenRecord record, String plainToken) { if (record == null || plainToken == null || plainToken.isBlank()) { return false; @@ -171,19 +183,42 @@ public class BearerTokenService { throw new AppException("비활성 사용자에게는 토큰을 발급할 수 없습니다."); } - OffsetDateTime now = OffsetDateTime.now(clock); - if (command.expiresAt() == null || !command.expiresAt().isAfter(now)) { - throw new AppException("만료일은 현재 시각 이후여야 합니다."); + return issueForUser(user, null, command.expiresAt(), command.description()); + } + + @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()))) { - throw new AppException("토큰 만료일이 최대 허용 기간을 초과했습니다."); + StakeholderTokenSubject subject = stakeholderMapper.findTokenSubject(stakeholderUserId.trim()); + 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 prefix = tokenGenerator.prefix(plainToken); String hash = tokenHasher.sha256(plainToken); long keyId = tokenMapper.nextKeyId(); - LocalDateTime expiresAt = command.expiresAt() + LocalDateTime localExpiresAt = expiresAt .atZoneSameInstant(ZoneId.systemDefault()) .toLocalDateTime(); @@ -191,14 +226,28 @@ public class BearerTokenService { keyId, user.userId(), user.username(), + stakeholderUserId, + null, + null, prefix, hash, - expiresAt, + localExpiresAt, null, - command.description() + description )); - auditService.record(new AuditEvent("TOKEN_ISSUED", keyId, null, "SUCCESS", null, null, "issued")); - return new IssuedToken(keyId, prefix, plainToken, command.expiresAt()); + auditService.record(new AuditEvent("TOKEN_ISSUED", keyId, null, "SUCCESS", null, null, + 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 diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java index a2f0919..cc0bac2 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/PermissionService.java @@ -21,7 +21,8 @@ import org.springframework.transaction.annotation.Transactional; public class PermissionService { private static final Set 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 VALUE_REQUIRED_RULE_TYPES = Set.of( "=", "!=", "DEPT", "EMP_NO", "TAG"); private static final Set DEFAULT_COLUMN_RULE_TYPES = Set.of( diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/TokenController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/TokenController.java index 72a5b97..32fe8fe 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/TokenController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/TokenController.java @@ -1,7 +1,5 @@ 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 java.time.Clock; import java.time.LocalDateTime; @@ -20,12 +18,10 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes; public class TokenController { private final BearerTokenService tokenService; - private final UserMapper userMapper; private final Clock clock; - public TokenController(BearerTokenService tokenService, UserMapper userMapper, Clock clock) { + public TokenController(BearerTokenService tokenService, Clock clock) { this.tokenService = tokenService; - this.userMapper = userMapper; this.clock = clock; } @@ -36,23 +32,23 @@ public class TokenController { ) { model.addAttribute("tokens", tokenService.findAll(includeInactive)); model.addAttribute("includeInactive", includeInactive); - model.addAttribute("users", userMapper.findAll()); + model.addAttribute("stakeholders", tokenService.findStakeholderTokenSubjects()); model.addAttribute("defaultExpiresAt", defaultExpiresAt()); return "tokens"; } @PostMapping("/tokens") public String issue( - @RequestParam long userId, + @RequestParam String stakeholderUserId, @RequestParam String expiresAt, @RequestParam(required = false) String description, RedirectAttributes redirectAttributes ) { - var issued = tokenService.issueToken(new TokenIssueCommand( - userId, + var issued = tokenService.issueStakeholderToken( + stakeholderUserId, parseBrowserDateTime(expiresAt), description - )); + ); redirectAttributes.addFlashAttribute("issued", issued); return "redirect:/tokens"; } diff --git a/src/main/resources/mapper/BearerTokenMapper.xml b/src/main/resources/mapper/BearerTokenMapper.xml index b693ee3..875a4ba 100644 --- a/src/main/resources/mapper/BearerTokenMapper.xml +++ b/src/main/resources/mapper/BearerTokenMapper.xml @@ -3,28 +3,37 @@ "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> @@ -34,10 +43,11 @@ 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 ( #{keyId,jdbcType=NUMERIC}, #{userId,jdbcType=NUMERIC}, + #{stakeholderUserId,jdbcType=VARCHAR}, #{keyPrefix,jdbcType=VARCHAR}, #{keyHash,jdbcType=VARCHAR}, #{expiresAt,jdbcType=TIMESTAMP}, diff --git a/src/main/resources/mapper/PermissionMapper.xml b/src/main/resources/mapper/PermissionMapper.xml index 2b74e3b..fdc0077 100644 --- a/src/main/resources/mapper/PermissionMapper.xml +++ b/src/main/resources/mapper/PermissionMapper.xml @@ -93,6 +93,10 @@ WHEN pr2.rule_type = 'TAG' THEN 'REGEXP_LIKE(UPPER(' || NVL(pr2.rule_column, 'TECH_TAG') || '), ''(^|,)' || 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 END, CHR(10) || 'AND ' diff --git a/src/main/resources/mapper/StakeholderMapper.xml b/src/main/resources/mapper/StakeholderMapper.xml new file mode 100644 index 0000000..6348b17 --- /dev/null +++ b/src/main/resources/mapper/StakeholderMapper.xml @@ -0,0 +1,33 @@ + + + + + + + diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css index 209a43b..afc5c65 100644 --- a/src/main/resources/static/css/app.css +++ b/src/main/resources/static/css/app.css @@ -129,6 +129,25 @@ body { 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 { background: var(--rw-surface); border: 1px solid var(--rw-border-strong); diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js index 0298531..361435c 100644 --- a/src/main/resources/static/js/app.js +++ b/src/main/resources/static/js/app.js @@ -397,13 +397,15 @@ function syncRuleTypeHints(root = document) { DEPT: '예: HR', EMP_NO: '예: E2001', TAG: '예: SPRING_BOOT', + STAKEHOLDER_SELF: '토큰 이해당사자에서 자동 계산', + STAKEHOLDER_CHANNEL: '토큰 채널에서 자동 계산', '=': '비교 값', '!=': '비교 값' }; columnSelect.required = !columnOptional; 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] || '비교 값'; if (valueInput.disabled) { valueInput.value = ''; @@ -696,12 +698,14 @@ function collectWizardRules(root) { DEPT: 'DEPT_CODE', EMP_NO: 'OWNER_EMP_NO', TAG: 'TECH_TAG', + STAKEHOLDER_SELF: '선택 컬럼', + STAKEHOLDER_CHANNEL: '선택 컬럼', ALL: '' }[type] || ''; if (type === 'ALL') { return 'ALL'; } - if (['MY_DEPT', 'SELF'].includes(type)) { + if (['MY_DEPT', 'SELF', 'STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL'].includes(type)) { return `${displayColumn} ${type}`; } if (type === 'TAG') { @@ -722,6 +726,8 @@ function collectWizardPredicates(root) { DEPT: 'DEPT_CODE', EMP_NO: 'OWNER_EMP_NO', TAG: 'TECH_TAG', + STAKEHOLDER_SELF: '선택 컬럼', + STAKEHOLDER_CHANNEL: '선택 컬럼', ALL: '' }[type] || ''; if (type === 'ALL') { @@ -733,6 +739,12 @@ function collectWizardPredicates(root) { if (type === 'SELF') { 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') { return `${displayColumn} = ${sqlLiteral(value)}`; } @@ -864,7 +876,7 @@ function permissionWizardRuleValidationMessage(wizard) { if (!rule.type) { return `${position}번째 행 규칙 유형을 선택하세요.`; } - if (['=', '!='].includes(rule.type) && !rule.column) { + if (['=', '!=', 'STAKEHOLDER_SELF', 'STAKEHOLDER_CHANNEL'].includes(rule.type) && !rule.column) { return `${position}번째 ${rule.type} 규칙에는 비교할 컬럼이 필요합니다.`; } if (valueRequired.includes(rule.type) && !rule.value) { diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html index caca1cb..f0ccaaf 100644 --- a/src/main/resources/templates/fragments/layout.html +++ b/src/main/resources/templates/fragments/layout.html @@ -21,6 +21,8 @@ @@ -167,6 +167,8 @@ + + @@ -176,7 +178,7 @@
행 규칙과 기술 태그 OR 조건 보기 -

특정 기술 태그는 기본적으로 TECH_TAG 컬럼을 봅니다. 같은 ALLOW 권한에 태그를 여러 개 추가하면 “태그 A 또는 태그 B”로 조회되고, DENY 태그는 허용 결과에서 제외됩니다. 객체에 TECH_TAG가 없으면 실제 태그 컬럼을 직접 지정하세요.

+

특정 기술 태그는 기본적으로 TECH_TAG 컬럼을 봅니다. 같은 ALLOW 권한에 태그를 여러 개 추가하면 “태그 A 또는 태그 B”로 조회됩니다. 이해당사자 본인 담당FC_ID를 직접 비교하거나 CUST_ID/CONTRACT_NO를 계약원장에 안전하게 연결합니다. 이해당사자 채널 범위도 같은 방식으로 FC_CHANNEL을 비교합니다. 두 이해당사자 규칙은 비교할 컬럼을 반드시 선택하세요.

@@ -261,7 +263,7 @@ th:classappend="${permission.permissionEffect() == 'DENY'} ? ' text-bg-danger' : ' text-bg-success'" th:text="${permission.permissionEffect()}">ALLOW -
ALL ROWS
+
ALL ROWS
CONTENTS
diff --git a/src/main/resources/templates/tokens.html b/src/main/resources/templates/tokens.html index 06f6b18..476b7f3 100644 --- a/src/main/resources/templates/tokens.html +++ b/src/main/resources/templates/tokens.html @@ -8,7 +8,7 @@

검증 세션

도움말 -

검증할 사용자를 나타내는 토큰을 만들고, 한 번만 표시되는 원문을 다음 단계에서 사용합니다. 원문은 저장하지 않고 해시와 식별용 prefix만 보관합니다.

+

이해당사자 원장에 등록된 업무 사용자를 토큰에 연결합니다. 토큰 원문은 한 번만 표시하며, DB에는 해시와 식별용 prefix만 보관합니다. 역할·채널·행 권한은 토큰이 아니라 접근 규칙에서 동적으로 계산합니다.

@@ -27,16 +27,19 @@
-

검증 사용자 선택

-

토큰에는 사용자의 직접 역할과 그룹 상속 역할이 연결됩니다. 권한 자체를 토큰에 복사하지 않으므로 이후 권한 변경도 동적으로 반영됩니다.

+

이해당사자 토큰 발급

+

모든 KB 이해당사자에게 토큰을 발급할 수 있습니다. 지점장·설계사 등의 실제 행 범위는 접근 규칙에 연결된 역할로 결정되므로, 토큰을 다시 만들지 않아도 권한 변경이 반영됩니다.