[Developer] #619 map stakeholder tokens to permission rules

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

View File

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

View File

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

View File

@@ -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,

View File

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

View File

@@ -31,6 +31,7 @@ public class BackofficeSchemaService {
user_name VARCHAR2(100) NOT NULL UNIQUE,
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",

View File

@@ -8,10 +8,12 @@ import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
import com.cloudhandson.vpdbackoffice.domain.token.IssuedToken;
import com.cloudhandson.vpdbackoffice.domain.token.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

View File

@@ -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(

View File

@@ -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";
}

View File

@@ -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},

View File

@@ -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 '

View File

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

View File

@@ -129,6 +129,25 @@ body {
gap: .5rem;
}
.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);

View File

@@ -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) {

View File

@@ -21,6 +21,8 @@
<button class="rw-menu-trigger" type="button" data-submenu-trigger="operations" aria-controls="submenu-operations" aria-expanded="false">운영</button>
</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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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(

View File

@@ -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>")