feat(hmm): align backoffice identity administration (#702)
This commit is contained in:
59
docs/design/702-hmm-backoffice-identity/README.md
Normal file
59
docs/design/702-hmm-backoffice-identity/README.md
Normal 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_*` 테이블은 운영 데이터가 생긴 뒤에는
|
||||
삭제하지 않으며, 문제 발생 시 화면 매퍼만 이전 버전으로 복구한다.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>")
|
||||
|
||||
Reference in New Issue
Block a user