[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

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