[Developer] #619 map stakeholder tokens to permission rules
This commit is contained in:
@@ -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는 후속 범위다.
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
533
sql/adb/46_kb_stakeholder_token_permission_example.sql
Normal file
533
sql/adb/46_kb_stakeholder_token_permission_example.sql
Normal 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 ===
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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<StakeholderTokenSubject> 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
|
||||
|
||||
@@ -21,7 +21,8 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
public class PermissionService {
|
||||
|
||||
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(
|
||||
"=", "!=", "DEPT", "EMP_NO", "TAG");
|
||||
private static final Set<String> DEFAULT_COLUMN_RULE_TYPES = Set.of(
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -3,28 +3,37 @@
|
||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper">
|
||||
<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
|
||||
FROM cb_agent_bearer_key k
|
||||
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
|
||||
OR (k.revoked_at IS NULL AND k.expires_at > SYSTIMESTAMP)
|
||||
ORDER BY k.key_id DESC
|
||||
</select>
|
||||
|
||||
<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
|
||||
FROM cb_agent_bearer_key k
|
||||
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}
|
||||
</select>
|
||||
|
||||
<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
|
||||
FROM cb_agent_bearer_key k
|
||||
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}
|
||||
</select>
|
||||
|
||||
@@ -34,10 +43,11 @@
|
||||
|
||||
<insert id="insertToken" parameterType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
|
||||
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},
|
||||
|
||||
@@ -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 '
|
||||
|
||||
33
src/main/resources/mapper/StakeholderMapper.xml
Normal file
33
src/main/resources/mapper/StakeholderMapper.xml
Normal 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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
<button class="rw-menu-trigger" type="button" data-submenu-trigger="operations" aria-controls="submenu-operations" aria-expanded="false">운영</button>
|
||||
</div>
|
||||
<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>
|
||||
<form method="post" action="/logout">
|
||||
<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="/groups">그룹 관리</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>
|
||||
</div>
|
||||
<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="/tokens">검증 세션</a>
|
||||
<a class="nav-link" href="/tokens">토큰 발급</a>
|
||||
<a class="nav-link" href="/probe">접근 검증</a>
|
||||
</div>
|
||||
<div id="submenu-integration" class="rw-submenu" data-submenu-panel="integration" role="navigation" aria-label="연동 도구 메뉴" hidden>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<h1>접근 규칙</h1>
|
||||
<details class="explanation-details">
|
||||
<summary>도움말</summary>
|
||||
<p>VPD policy function이 참조할 사용자, 역할, 행 규칙, 컬럼 원문 허용 규칙을 저장합니다. 이 화면에서 만든 규칙이 기본 Filter의 동적 입력이 됩니다.</p>
|
||||
<p>VPD policy function이 참조할 사용자, 역할, 행 규칙, 컬럼 원문 허용 규칙을 저장합니다. KB 원장 예제에서 <code>STAKEHOLDER_SELF</code>는 토큰의 이해당사자 ID, <code>STAKEHOLDER_CHANNEL</code>은 역할에 연결된 채널로 행 범위를 계산합니다.</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -167,6 +167,8 @@
|
||||
<option value="DEPT">지정 부서</option>
|
||||
<option value="EMP_NO">지정 사번</option>
|
||||
<option value="TAG">특정 기술 태그</option>
|
||||
<option value="STAKEHOLDER_SELF">이해당사자 본인 담당</option>
|
||||
<option value="STAKEHOLDER_CHANNEL">이해당사자 채널 범위</option>
|
||||
<option value="=">=</option>
|
||||
<option value="!=">!=</option>
|
||||
</select>
|
||||
@@ -176,7 +178,7 @@
|
||||
</div>
|
||||
<details class="explanation-details">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -261,7 +263,7 @@
|
||||
th:classappend="${permission.permissionEffect() == 'DENY'} ? ' text-bg-danger' : ' text-bg-success'"
|
||||
th:text="${permission.permissionEffect()}">ALLOW</span>
|
||||
</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>
|
||||
<details class="row-management">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<h1>검증 세션</h1>
|
||||
<details class="explanation-details">
|
||||
<summary>도움말</summary>
|
||||
<p>검증할 사용자를 나타내는 토큰을 만들고, 한 번만 표시되는 원문을 다음 단계에서 사용합니다. 원문은 저장하지 않고 해시와 식별용 prefix만 보관합니다.</p>
|
||||
<p>이해당사자 원장에 등록된 업무 사용자를 토큰에 연결합니다. 토큰 원문은 한 번만 표시하며, DB에는 해시와 식별용 prefix만 보관합니다. 역할·채널·행 권한은 토큰이 아니라 접근 규칙에서 동적으로 계산합니다.</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -27,16 +27,19 @@
|
||||
<section class="content-band">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>검증 사용자 선택</h2>
|
||||
<p class="section-subtitle">토큰에는 사용자의 직접 역할과 그룹 상속 역할이 연결됩니다. 권한 자체를 토큰에 복사하지 않으므로 이후 권한 변경도 동적으로 반영됩니다.</p>
|
||||
<h2>이해당사자 토큰 발급</h2>
|
||||
<p class="section-subtitle">모든 KB 이해당사자에게 토큰을 발급할 수 있습니다. 지점장·설계사 등의 실제 행 범위는 접근 규칙에 연결된 역할로 결정되므로, 토큰을 다시 만들지 않아도 권한 변경이 반영됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/tokens" class="form-grid">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
<label>
|
||||
사용자
|
||||
<select class="form-select" name="userId" required>
|
||||
<option th:each="user : ${users}" th:value="${user.userId()}" th:text="${user.username()}"></option>
|
||||
이해당사자
|
||||
<select class="form-select" name="stakeholderUserId" required>
|
||||
<option value="" selected disabled>토큰을 발급할 이해당사자 선택</option>
|
||||
<option th:each="stakeholder : ${stakeholders}"
|
||||
th:value="${stakeholder.stakeholderUserId()}"
|
||||
th:text="${stakeholder.displayLabel()}"></option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
@@ -71,12 +74,13 @@
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<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>
|
||||
<tr th:each="token : ${tokens}"
|
||||
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.username()}">user</td>
|
||||
<td th:text="${token.stakeholderRole() == null ? '기존 앱 사용자' : token.stakeholderRole() + ' / ' + token.stakeholderChannel()}">지점장 / 설계사채널</td>
|
||||
<td th:text="${token.description()} ?: '-'">용도</td>
|
||||
<td><code th:text="${token.keyPrefix() + '****'}">vpd_****</code></td>
|
||||
<td th:text="${token.expiresAt()}">2026</td>
|
||||
@@ -90,7 +94,7 @@
|
||||
</form>
|
||||
</td>
|
||||
</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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -167,6 +167,44 @@ class PermissionServiceTest {
|
||||
.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
|
||||
void acceptsMultipleVisibleColumns() {
|
||||
var command = new PermissionSetCommand(
|
||||
|
||||
@@ -86,7 +86,7 @@ class GuidedFlowTemplateTest {
|
||||
.contains("data-wizard-target=\"2\">대상")
|
||||
.doesNotContain(">1 역할<", ">2 객체<", "wizard-step-number");
|
||||
assertThat(tokens)
|
||||
.contains("검증 사용자 선택", "검증 세션 발급")
|
||||
.contains("이해당사자 토큰 발급", "검증 세션 발급")
|
||||
.doesNotContain("1. 검증할 사용자 선택", "2. 검증 세션 발급");
|
||||
assertThat(probe)
|
||||
.contains("접근 검증 실행", "<strong>검증 결과</strong>")
|
||||
|
||||
Reference in New Issue
Block a user