feat(hmm): align backoffice identity administration (#702)

This commit is contained in:
devmrko
2026-07-22 13:49:59 +09:00
parent 64965c294b
commit 572dcd9a67
11 changed files with 226 additions and 158 deletions

View File

@@ -0,0 +1,59 @@
# HMM 백오피스 사용자·그룹·역할·토큰 관리 전환 (#702)
> 상태: Implementing
> 추적: Redmine #702
> 대상: `hmm-backoffice.cloud-handson.com`, HMMAIPOC / `ADMIN`
## 목적
기존 KB/VPD 데모의 `CB_*` 사용자·그룹·역할·Bearer 토큰 모델을 HMM HR 데모의 실제 기준
테이블로 바꾼다. 직원과 조직 정보는 이미 적재된 HMM 테이블을 재사용하고, 접근 제어에만 필요한
테이블은 `HMM_ACCESS_*` 접두어로 추가한다.
## 기준 테이블과 소유권
| 관리 기능 | 기준 테이블 | 처리 방식 |
| --- | --- | --- |
| 사용자 | `HMM_HR_EMPLOYEES` | 기존 직원 ID·사번·성명·소속팀·재직 상태를 직접 사용 |
| 조직 참조 | `HMM_ORG_TEAMS` | 직원 생성 시 소속팀 검증에 사용 |
| 접근 그룹 | `HMM_ACCESS_GROUPS`, `HMM_ACCESS_GROUP_MEMBERS` | HR 조직과 독립적인 논리 접근 그룹을 새로 관리 |
| 역할 | `HMM_ACCESS_ROLES`, `HMM_EMPLOYEE_ACCESS_ROLES`, `HMM_GROUP_ACCESS_ROLES` | 직원 직접 역할과 그룹 상속 역할을 분리 |
| 토큰 | `HMM_ACCESS_BEARER_TOKENS` | 원문은 발급 화면에서 한 번만 표시하고 SHA-256 해시만 저장 |
| 감사 | `HMM_ACCESS_AUDIT` | 사용자·그룹·역할·토큰 변경 이력 저장 |
`HMM_ORG_TEAMS`는 인사 조직 원장이므로 접근 그룹과 혼합하지 않는다. 이로써 한 직원이 여러
업무 접근 그룹에 속하면서도 인사 소속팀은 하나로 유지된다.
## 데이터 흐름
```
HMM_HR_EMPLOYEES ──< HMM_EMPLOYEE_ACCESS_ROLES >── HMM_ACCESS_ROLES
└──< HMM_ACCESS_GROUP_MEMBERS >── HMM_ACCESS_GROUPS
└──< HMM_GROUP_ACCESS_ROLES >── HMM_ACCESS_ROLES
HMM_HR_EMPLOYEES ──< HMM_ACCESS_BEARER_TOKENS
HMM_ACCESS_AUDIT records every backoffice change
```
## 화면 및 호환성
- `/users``HMM_HR_EMPLOYEES`를 사용자 목록으로 표시한다. 활성/비활성은
`employment_status``ACTIVE`/`INACTIVE`에 매핑한다.
- `/groups``HMM_ACCESS_GROUPS`를 관리한다. HR 팀 이동 기능으로 오해되지 않도록
논리 접근 그룹임을 화면에 명시한다.
- `/roles`는 HMM 접근 역할만 관리한다. 기존 VPD 객체 권한 규칙은 이번 범위에서 노출하지 않는다.
- `/tokens`는 HMM 직원에게 토큰을 발급·회수한다. KB 이해당사자 원장은 참조하지 않는다.
## 보안·검증
- 토큰 원문, DB 비밀번호, Wallet은 테이블·Git·로그에 저장하지 않는다.
- DDL은 재실행 가능해야 하며 기존 HR 행을 수정하거나 삭제하지 않는다.
- 배포 전 SQLcl로 테이블·FK·시드 역할을 확인하고, 배포 후 사용자·그룹·역할·토큰 화면을 HTTP
수준에서 확인한다.
## 롤백
애플리케이션은 이전 JAR로 되돌릴 수 있다. 새 `HMM_ACCESS_*` 테이블은 운영 데이터가 생긴 뒤에는
삭제하지 않으며, 문제 발생 시 화면 매퍼만 이전 버전으로 복구한다.

View File

@@ -1,6 +1,8 @@
package com.cloudhandson.vpdbackoffice.config;
import com.cloudhandson.vpdbackoffice.service.PermissionService;
import com.cloudhandson.vpdbackoffice.service.GroupService;
import com.cloudhandson.vpdbackoffice.service.UserService;
import java.sql.Connection;
import javax.sql.DataSource;
import org.slf4j.Logger;
@@ -14,13 +16,19 @@ public class DbPoolWarmup {
private static final Logger log = LoggerFactory.getLogger(DbPoolWarmup.class);
private final DataSource dataSource;
private final UserService userService;
private final GroupService groupService;
private final PermissionService permissionService;
public DbPoolWarmup(
DataSource dataSource,
UserService userService,
GroupService groupService,
PermissionService permissionService
) {
this.dataSource = dataSource;
this.userService = userService;
this.groupService = groupService;
this.permissionService = permissionService;
}
@@ -36,10 +44,13 @@ public class DbPoolWarmup {
private void warmupBackofficeCatalog() {
long started = System.nanoTime();
// Only warm the Smilegate identity catalog. VPD catalog objects are not
// part of this PoC and may deliberately be absent from the ADB schema.
userService.findAll();
userService.findUserRoles();
groupService.findAll();
groupService.findGroupUsers();
groupService.findGroupRoles();
permissionService.findRoles();
permissionService.findPermissionViews();
log.info("Backoffice DB catalog cache warmed up in {}ms", (System.nanoTime() - started) / 1_000_000);
log.info("HMM identity catalog cache warmed up in {}ms", (System.nanoTime() - started) / 1_000_000);
}
}

View File

@@ -13,6 +13,7 @@ import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
@Configuration
public class SecurityConfig {
@@ -45,6 +46,8 @@ public class SecurityConfig {
.headers(headers -> headers.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31_536_000)))
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")))
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/css/**", "/js/**", "/webjars/**",
@@ -64,8 +67,6 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.POST, "/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/**").hasRole("ADMIN")
.anyRequest().authenticated())
.httpBasic(basic -> {
})
.formLogin(login -> login
.loginPage("/login")
.permitAll())

View File

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

View File

@@ -3,10 +3,9 @@
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.AuditMapper">
<insert id="insert" parameterType="com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent">
INSERT INTO sg_audit_event (
audit_id, event_type, key_id, object_id, status, row_count, error_code, message, created_at
INSERT INTO hmm_access_audit (
event_type, key_id, object_id, status, row_count, error_code, message, created_at
) VALUES (
sg_audit_event_seq.NEXTVAL,
#{eventType,jdbcType=VARCHAR},
#{keyId,jdbcType=NUMERIC},
#{objectId,jdbcType=NUMERIC},

View File

@@ -3,51 +3,50 @@
"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, COALESCE(s.user_nm, u.user_name) AS username,
k.stakeholder_user_id, s.role AS stakeholder_role, s.channel AS stakeholder_channel,
SELECT k.key_id, k.employee_id AS user_id, u.full_name AS username,
CAST(NULL AS VARCHAR2(100)) AS stakeholder_user_id,
CAST(NULL AS VARCHAR2(100)) AS stakeholder_role, CAST(NULL AS VARCHAR2(100)) 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
FROM hmm_access_bearer_tokens k
JOIN hmm_hr_employees u ON u.employee_id = k.employee_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, COALESCE(s.user_nm, u.user_name) AS username,
k.stakeholder_user_id, s.role AS stakeholder_role, s.channel AS stakeholder_channel,
SELECT k.key_id, k.employee_id AS user_id, u.full_name AS username,
CAST(NULL AS VARCHAR2(100)) AS stakeholder_user_id,
CAST(NULL AS VARCHAR2(100)) AS stakeholder_role, CAST(NULL AS VARCHAR2(100)) 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
FROM hmm_access_bearer_tokens k
JOIN hmm_hr_employees u ON u.employee_id = k.employee_id
WHERE k.key_id = #{keyId}
</select>
<select id="findByHash" resultType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
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,
SELECT k.key_id, k.employee_id AS user_id, u.full_name AS username,
CAST(NULL AS VARCHAR2(100)) AS stakeholder_user_id,
CAST(NULL AS VARCHAR2(100)) AS stakeholder_role, CAST(NULL AS VARCHAR2(100)) 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
FROM hmm_access_bearer_tokens k
JOIN hmm_hr_employees u ON u.employee_id = k.employee_id
WHERE k.key_hash = #{keyHash,jdbcType=VARCHAR}
</select>
<select id="nextKeyId" resultType="long">
SELECT cb_agent_bearer_key_seq.NEXTVAL FROM dual
SELECT NVL(MAX(key_id), 0) + 1 FROM hmm_access_bearer_tokens
</select>
<insert id="insertToken" parameterType="com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord">
INSERT INTO cb_agent_bearer_key (
key_id, user_id, stakeholder_user_id, key_prefix, key_hash, expires_at, revoked_at, description
INSERT INTO hmm_access_bearer_tokens (
key_id, employee_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},
@@ -57,7 +56,7 @@
</insert>
<update id="revokeToken">
UPDATE cb_agent_bearer_key
UPDATE hmm_access_bearer_tokens
SET revoked_at = #{revokedAt,jdbcType=TIMESTAMP},
description = CASE
WHEN #{reason,jdbcType=VARCHAR} IS NULL THEN description

View File

@@ -8,7 +8,7 @@
group_name,
description,
active_yn
FROM sg_app_group
FROM hmm_access_groups
ORDER BY group_code
</select>
@@ -16,12 +16,12 @@
SELECT g.group_id,
g.group_code,
g.group_name,
u.user_id,
u.user_name AS username
FROM sg_user_group ug
JOIN sg_app_group g ON g.group_id = ug.group_id
JOIN sg_app_user u ON u.user_id = ug.user_id
ORDER BY g.group_code, u.user_name
u.employee_id AS user_id,
u.full_name AS username
FROM hmm_access_group_members ug
JOIN hmm_access_groups g ON g.group_id = ug.group_id
JOIN hmm_hr_employees u ON u.employee_id = ug.employee_id
ORDER BY g.group_code, u.full_name
</select>
<select id="findGroupRoles" resultType="com.cloudhandson.vpdbackoffice.domain.group.GroupRoleView">
@@ -30,18 +30,18 @@
g.group_name,
r.role_id,
r.role_name
FROM sg_group_role gr
JOIN sg_app_group g ON g.group_id = gr.group_id
JOIN sg_app_role r ON r.role_id = gr.role_id
FROM hmm_group_access_roles gr
JOIN hmm_access_groups g ON g.group_id = gr.group_id
JOIN hmm_access_roles r ON r.role_id = gr.role_id
ORDER BY g.group_code, r.role_name
</select>
<select id="nextGroupId" resultType="long">
SELECT NVL(MAX(group_id), 0) + 1 FROM sg_app_group
SELECT NVL(MAX(group_id), 0) + 1 FROM hmm_access_groups
</select>
<insert id="insertGroup">
INSERT INTO sg_app_group (
INSERT INTO hmm_access_groups (
group_id, group_code, group_name, description, active_yn
) VALUES (
#{groupId,jdbcType=NUMERIC},
@@ -53,29 +53,29 @@
</insert>
<update id="updateActive">
UPDATE sg_app_group
UPDATE hmm_access_groups
SET active_yn = #{activeYn,jdbcType=VARCHAR}
WHERE group_id = #{groupId,jdbcType=NUMERIC}
</update>
<insert id="insertGroupUser">
INSERT INTO sg_user_group (group_id, user_id)
INSERT INTO hmm_access_group_members (group_id, employee_id)
VALUES (#{groupId,jdbcType=NUMERIC}, #{userId,jdbcType=NUMERIC})
</insert>
<delete id="deleteGroupUser">
DELETE FROM sg_user_group
DELETE FROM hmm_access_group_members
WHERE group_id = #{groupId,jdbcType=NUMERIC}
AND user_id = #{userId,jdbcType=NUMERIC}
AND employee_id = #{userId,jdbcType=NUMERIC}
</delete>
<insert id="insertGroupRole">
INSERT INTO sg_group_role (group_id, role_id)
INSERT INTO hmm_group_access_roles (group_id, role_id)
VALUES (#{groupId,jdbcType=NUMERIC}, #{roleId,jdbcType=NUMERIC})
</insert>
<delete id="deleteGroupRole">
DELETE FROM sg_group_role
DELETE FROM hmm_group_access_roles
WHERE group_id = #{groupId,jdbcType=NUMERIC}
AND role_id = #{roleId,jdbcType=NUMERIC}
</delete>

View File

@@ -6,8 +6,9 @@
SELECT role_id,
role_name,
description,
NVL(max_sensitivity_level, 'PUBLIC') AS max_sensitivity_level
FROM sg_app_role
max_sensitivity_level
FROM hmm_access_roles
WHERE active_yn = 'Y'
ORDER BY role_name
</select>
@@ -15,96 +16,90 @@
SELECT role_id,
role_name,
description,
NVL(max_sensitivity_level, 'PUBLIC') AS max_sensitivity_level
FROM sg_app_role
max_sensitivity_level
FROM hmm_access_roles
WHERE role_id = #{roleId}
</select>
<select id="nextRoleId" resultType="long">
SELECT NVL(MAX(role_id), 0) + 1 FROM sg_app_role
SELECT NVL(MAX(role_id), 0) + 1 FROM hmm_access_roles
</select>
<insert id="insertRole">
INSERT INTO sg_app_role (role_id, role_name, description, max_sensitivity_level)
INSERT INTO hmm_access_roles (role_id, role_name, description, max_sensitivity_level, active_yn)
VALUES (
#{roleId,jdbcType=NUMERIC},
UPPER(#{roleName,jdbcType=VARCHAR}),
#{description,jdbcType=VARCHAR},
#{maxSensitivityLevel,jdbcType=VARCHAR}
#{maxSensitivityLevel,jdbcType=VARCHAR},
'Y'
)
</insert>
<update id="updateRoleMaxSensitivity">
UPDATE sg_app_role
UPDATE hmm_access_roles
SET max_sensitivity_level = #{maxSensitivityLevel,jdbcType=VARCHAR}
WHERE role_id = #{roleId,jdbcType=NUMERIC}
</update>
<delete id="deleteRole">
DELETE FROM sg_app_role
DELETE FROM hmm_access_roles
WHERE role_id = #{roleId,jdbcType=NUMERIC}
</delete>
<select id="countUserRolesByRoleId" resultType="int">
SELECT COUNT(*)
FROM sg_user_role
FROM hmm_employee_access_roles
WHERE role_id = #{roleId,jdbcType=NUMERIC}
</select>
<select id="countGroupRolesByRoleId" resultType="int">
SELECT COUNT(*)
FROM sg_group_role
FROM hmm_group_access_roles
WHERE role_id = #{roleId,jdbcType=NUMERIC}
</select>
<select id="countPermissionsByRoleId" resultType="int">
SELECT COUNT(*)
FROM sg_permission
WHERE role_id = #{roleId,jdbcType=NUMERIC}
FROM dual
WHERE 1 = 0
</select>
<select id="findPermissionViews" resultType="com.cloudhandson.vpdbackoffice.domain.permission.PermissionView">
SELECT p.perm_id AS permission_id,
r.role_id,
r.role_name,
NVL(o.object_id, -1) AS object_id,
p.target_name AS object_name,
p.action_name AS action,
NVL(p.permission_effect, 'ALLOW') AS permission_effect,
LISTAGG(NVL2(pr.rule_column, pr.rule_column || ' ', '') || pr.rule_type || NVL2(pr.rule_value, ' ' || pr.rule_value, ''), ', ')
WITHIN GROUP (ORDER BY pr.rule_id) AS rules,
(SELECT LISTAGG(pc.column_name, ', ') WITHIN GROUP (ORDER BY pc.column_name)
FROM sg_permission_column pc WHERE pc.permission_id = p.perm_id) AS visible_columns,
(SELECT LISTAGG(NVL2(pr2.rule_column, pr2.rule_column || ' ', '') || pr2.rule_type || NVL2(pr2.rule_value, ' ' || pr2.rule_value, ''), CHR(10) || 'AND ')
WITHIN GROUP (ORDER BY pr2.rule_id) FROM sg_permission_rule pr2 WHERE pr2.perm_id = p.perm_id) AS filter_preview,
'Game-data access policy' AS null_policy_preview
FROM sg_permission p
JOIN sg_app_role r ON r.role_id = p.role_id
LEFT JOIN sg_protected_object o ON o.object_name = p.target_name
LEFT JOIN sg_permission_rule pr ON pr.perm_id = p.perm_id
GROUP BY p.perm_id, r.role_id, r.role_name, o.object_id, p.target_name, p.action_name, p.permission_effect
ORDER BY r.role_name, p.target_name
SELECT CAST(NULL AS NUMBER) AS permission_id,
CAST(NULL AS NUMBER) AS role_id,
CAST(NULL AS VARCHAR2(80)) AS role_name,
CAST(NULL AS NUMBER) AS object_id,
CAST(NULL AS VARCHAR2(200)) AS object_name,
CAST(NULL AS VARCHAR2(30)) AS action,
CAST(NULL AS VARCHAR2(20)) AS permission_effect,
CAST(NULL AS VARCHAR2(4000)) AS rules,
CAST(NULL AS VARCHAR2(4000)) AS visible_columns,
CAST(NULL AS VARCHAR2(4000)) AS filter_preview,
CAST(NULL AS VARCHAR2(4000)) AS null_policy_preview
FROM dual
WHERE 1 = 0
</select>
<select id="findPermissionId" resultType="long">
SELECT p.perm_id
FROM sg_permission p
JOIN sg_protected_object o ON o.object_name = p.target_name
FROM cb_permission p
JOIN cb_protected_object o ON o.object_name = p.target_name
WHERE p.role_id = #{roleId}
AND o.object_id = #{objectId}
</select>
<select id="findObjectIdByPermissionId" resultType="long">
SELECT o.object_id
FROM sg_permission p
JOIN sg_protected_object o ON o.object_name = p.target_name
FROM cb_permission p
JOIN cb_protected_object o ON o.object_name = p.target_name
WHERE p.perm_id = #{permissionId}
</select>
<select id="countPermissionsByObjectId" resultType="int">
SELECT COUNT(*)
FROM sg_permission p
JOIN sg_protected_object o ON o.object_name = p.target_name
FROM cb_permission p
JOIN cb_protected_object o ON o.object_name = p.target_name
WHERE o.object_id = #{objectId}
</select>
@@ -114,46 +109,46 @@
o.object_id,
p.action_name AS action,
NVL(p.permission_effect, 'ALLOW') AS permission_effect
FROM sg_permission p
JOIN sg_protected_object o ON o.object_name = p.target_name
FROM cb_permission p
JOIN cb_protected_object o ON o.object_name = p.target_name
WHERE p.role_id = #{roleId}
AND o.object_id = #{objectId}
</select>
<select id="nextPermissionId" resultType="long">
SELECT sg_permission_seq.NEXTVAL FROM dual
SELECT cb_permission_seq.NEXTVAL FROM dual
</select>
<select id="nextRuleId" resultType="long">
SELECT sg_permission_rule_seq.NEXTVAL FROM dual
SELECT cb_permission_rule_seq.NEXTVAL FROM dual
</select>
<insert id="insertPermission">
INSERT INTO sg_permission (perm_id, role_id, target_name, action_name, permission_effect)
INSERT INTO cb_permission (perm_id, role_id, target_name, action_name, permission_effect)
SELECT #{permissionId}, #{roleId}, object_name, #{action}, #{permissionEffect}
FROM sg_protected_object
FROM cb_protected_object
WHERE object_id = #{objectId}
</insert>
<update id="updatePermissionAction">
UPDATE sg_permission
UPDATE cb_permission
SET action_name = #{action}
WHERE perm_id = #{permissionId}
</update>
<update id="updatePermissionEffect">
UPDATE sg_permission
UPDATE cb_permission
SET permission_effect = #{permissionEffect}
WHERE perm_id = #{permissionId}
</update>
<delete id="deleteRules">
DELETE FROM sg_permission_rule
DELETE FROM cb_permission_rule
WHERE perm_id = #{permissionId}
</delete>
<insert id="insertRule" parameterType="com.cloudhandson.vpdbackoffice.domain.permission.PermissionRule">
INSERT INTO sg_permission_rule (rule_id, perm_id, rule_column, rule_type, rule_value)
INSERT INTO cb_permission_rule (rule_id, perm_id, rule_column, rule_type, rule_value)
VALUES (
#{ruleId,jdbcType=NUMERIC},
#{permissionId,jdbcType=NUMERIC},
@@ -164,17 +159,17 @@
</insert>
<delete id="deleteVisibleColumns">
DELETE FROM sg_permission_column
DELETE FROM cb_permission_column
WHERE permission_id = #{permissionId}
</delete>
<insert id="insertVisibleColumn">
INSERT INTO sg_permission_column (permission_id, column_name)
INSERT INTO cb_permission_column (permission_id, column_name)
VALUES (#{permissionId}, #{columnName})
</insert>
<delete id="deletePermission">
DELETE FROM sg_permission
DELETE FROM cb_permission
WHERE perm_id = #{permissionId}
</delete>
</mapper>

View File

@@ -3,25 +3,27 @@
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cloudhandson.vpdbackoffice.mapper.UserMapper">
<select id="findAll" resultType="com.cloudhandson.vpdbackoffice.domain.user.AppUser">
SELECT user_id,
user_name AS username,
employee_no AS emp_no,
dept_code,
can_read_contents,
active AS active_yn
FROM sg_app_user
SELECT e.employee_id AS user_id,
e.full_name AS username,
e.employee_code AS emp_no,
t.team_code AS dept_code,
'N' AS can_read_contents,
CASE WHEN e.employment_status = 'ACTIVE' THEN 'Y' ELSE 'N' END AS active_yn
FROM hmm_hr_employees e
JOIN hmm_org_teams t ON t.team_id = e.team_id
ORDER BY username
</select>
<select id="findById" resultType="com.cloudhandson.vpdbackoffice.domain.user.AppUser">
SELECT user_id,
user_name AS username,
employee_no AS emp_no,
dept_code,
can_read_contents,
active AS active_yn
FROM sg_app_user
WHERE user_id = #{userId}
SELECT e.employee_id AS user_id,
e.full_name AS username,
e.employee_code AS emp_no,
t.team_code AS dept_code,
'N' AS can_read_contents,
CASE WHEN e.employment_status = 'ACTIVE' THEN 'Y' ELSE 'N' END AS active_yn
FROM hmm_hr_employees e
JOIN hmm_org_teams t ON t.team_id = e.team_id
WHERE e.employee_id = #{userId}
</select>
<select id="findUserRoles" resultType="com.cloudhandson.vpdbackoffice.domain.user.UserRoleView">
@@ -29,43 +31,46 @@
u.user_name AS username,
r.role_id,
r.role_name
FROM sg_user_role ur
JOIN sg_app_user u ON u.user_id = ur.user_id
JOIN sg_app_role r ON r.role_id = ur.role_id
ORDER BY u.user_name, r.role_name
FROM hmm_employee_access_roles ur
JOIN hmm_hr_employees u ON u.employee_id = ur.employee_id
JOIN hmm_access_roles r ON r.role_id = ur.role_id
ORDER BY u.full_name, r.role_name
</select>
<select id="nextUserId" resultType="long">
SELECT NVL(MAX(user_id), 0) + 1 FROM sg_app_user
SELECT NVL(MAX(employee_id), 0) + 1 FROM hmm_hr_employees
</select>
<insert id="insertUser">
INSERT INTO sg_app_user (
user_id, user_name, employee_no, dept_code, can_read_contents, active
) VALUES (
#{userId},
#{command.username},
#{command.empNo},
#{command.deptCode},
'N',
'Y'
INSERT INTO hmm_hr_employees (
employee_id, employee_code, full_name, email, job_title, team_id, hire_date, employment_status
)
SELECT #{userId},
#{command.empNo},
#{command.username},
LOWER(#{command.empNo}) || '@example.test',
'HMM Demo Employee',
t.team_id,
TRUNC(SYSDATE),
'ACTIVE'
FROM hmm_org_teams t
WHERE t.team_code = #{command.deptCode}
</insert>
<update id="updateActive">
UPDATE sg_app_user
SET active = #{activeYn}
WHERE user_id = #{userId}
UPDATE hmm_hr_employees
SET employment_status = CASE WHEN #{activeYn} = 'Y' THEN 'ACTIVE' ELSE 'INACTIVE' END
WHERE employee_id = #{userId}
</update>
<insert id="insertUserRole">
INSERT INTO sg_user_role (user_id, role_id)
INSERT INTO hmm_employee_access_roles (employee_id, role_id)
VALUES (#{userId}, #{roleId})
</insert>
<delete id="deleteUserRole">
DELETE FROM sg_user_role
WHERE user_id = #{userId}
DELETE FROM hmm_employee_access_roles
WHERE employee_id = #{userId}
AND role_id = #{roleId}
</delete>
</mapper>

View File

@@ -1,25 +1,25 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('검증 세션')}"></head>
<head th:replace="~{fragments/layout :: head('HMM 액세스 토큰')}"></head>
<body>
<nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4">
<div class="page-title">
<h1>검증 세션</h1>
<h1>HMM 액세스 토큰</h1>
<details class="explanation-details">
<summary>도움말</summary>
<p>이해당사자 원장에 등록된 업무 사용자를 토큰에 연결합니다. 토큰 원문은 한 번만 표시하며, DB에는 해시와 식별용 prefix만 보관합니다. 역할·채널·행 권한은 토큰이 아니라 행 접근 규칙에서 동적으로 계산합니다.</p>
<p>HMM HR 직원에게 접근 토큰을 발급합니다. 토큰 원문은 한 번만 표시하며, DB에는 SHA-256 해시와 식별용 prefix만 보관합니다.</p>
</details>
</div>
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
<section class="issued-token-card" th:if="${issued}" th:attr="data-issued-key-id=${issued.keyId()}">
<span class="architecture-kicker">발급 완료 · 지금 한 번만 표시됩니다</span>
<h2>이 토큰 원문을 복사한 뒤 접근 검증으로 이동하세요.</h2>
<h2>이 토큰 원문을 복사해 안전한 위치에 보관하세요.</h2>
<code class="token-value" id="issued-token-value" th:text="${issued.plainToken()}"></code>
<div class="action-stack mt-3">
<button class="btn rw-btn-primary" type="button" data-copy-source="issued-token-value">토큰 원문 복사</button>
<a class="btn rw-btn-secondary" href="/probe">접근 검증으로 이동</a>
<a class="btn rw-btn-secondary" href="/users">사용자 역할 관리</a>
</div>
<p class="form-hint mb-0">화면을 떠난 뒤에는 원문을 다시 볼 수 없습니다. 잃어버리면 기존 토큰을 복구하지 말고 새로 발급하세요.</p>
</section>
@@ -27,19 +27,19 @@
<section class="content-band">
<div class="section-heading">
<div>
<h2>이해당사자 토큰 발급</h2>
<p class="section-subtitle">모든 KB 이해당사자에게 토큰을 발급할 수 있습니다. 지점장·설계사 등의 실제 행 범위는 행 접근 규칙에 연결된 역할로 결정되므로, 토큰을 다시 만들지 않아도 권한 변경이 반영됩니다.</p>
<h2>직원 토큰 발급</h2>
<p class="section-subtitle">활성 HMM 직원에게 토큰을 발급합니다. 직접 역할과 접근 그룹 역할은 토큰 재발급 없이 조회 시점에 반영됩니다.</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="stakeholderUserId" required>
<option value="" selected disabled>토큰을 발급할 이해당사자 선택</option>
<option th:each="stakeholder : ${stakeholders}"
th:value="${stakeholder.stakeholderUserId()}"
th:text="${stakeholder.displayLabel()}"></option>
HMM 직원
<select class="form-select" name="userId" required>
<option value="" selected disabled>토큰을 발급할 직원 선택</option>
<option th:each="user : ${users}"
th:value="${user.userId()}"
th:text="${user.empNo() + ' / ' + user.username() + ' / ' + user.deptCode()}"></option>
</select>
</label>
<label>
@@ -74,13 +74,12 @@
</div>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead><tr><th>ID</th><th>이해당사자</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>식별용 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>
@@ -94,7 +93,7 @@
</form>
</td>
</tr>
<tr th:if="${#lists.isEmpty(tokens)}"><td colspan="8" class="text-muted">아직 발급된 토큰이 없습니다.</td></tr>
<tr th:if="${#lists.isEmpty(tokens)}"><td colspan="7" class="text-muted">아직 발급된 토큰이 없습니다.</td></tr>
</tbody>
</table>
</div>

View File

@@ -122,7 +122,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>")