diff --git a/docs/design/702-hmm-backoffice-identity/README.md b/docs/design/702-hmm-backoffice-identity/README.md new file mode 100644 index 0000000..a4457ef --- /dev/null +++ b/docs/design/702-hmm-backoffice-identity/README.md @@ -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_*` 테이블은 운영 데이터가 생긴 뒤에는 +삭제하지 않으며, 문제 발생 시 화면 매퍼만 이전 버전으로 복구한다. diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java index db20d4f..1ea66b1 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/DbPoolWarmup.java @@ -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); } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java b/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java index 1fa6707..f6eda66 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/config/SecurityConfig.java @@ -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()) diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/TokenController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/TokenController.java index 32fe8fe..2bd673f 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/TokenController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/TokenController.java @@ -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"; } diff --git a/src/main/resources/mapper/AuditMapper.xml b/src/main/resources/mapper/AuditMapper.xml index 306c78c..e68c641 100644 --- a/src/main/resources/mapper/AuditMapper.xml +++ b/src/main/resources/mapper/AuditMapper.xml @@ -3,10 +3,9 @@ "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> - 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}, diff --git a/src/main/resources/mapper/BearerTokenMapper.xml b/src/main/resources/mapper/BearerTokenMapper.xml index 875a4ba..523e981 100644 --- a/src/main/resources/mapper/BearerTokenMapper.xml +++ b/src/main/resources/mapper/BearerTokenMapper.xml @@ -3,51 +3,50 @@ "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> - 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 @@ - 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 diff --git a/src/main/resources/mapper/GroupMapper.xml b/src/main/resources/mapper/GroupMapper.xml index d6c0000..bff534c 100644 --- a/src/main/resources/mapper/GroupMapper.xml +++ b/src/main/resources/mapper/GroupMapper.xml @@ -8,7 +8,7 @@ group_name, description, active_yn - FROM sg_app_group + FROM hmm_access_groups ORDER BY group_code @@ -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 - 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 @@ - UPDATE sg_app_group + UPDATE hmm_access_groups SET active_yn = #{activeYn,jdbcType=VARCHAR} WHERE group_id = #{groupId,jdbcType=NUMERIC} - 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}) - 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} - 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}) - DELETE FROM sg_group_role + DELETE FROM hmm_group_access_roles WHERE group_id = #{groupId,jdbcType=NUMERIC} AND role_id = #{roleId,jdbcType=NUMERIC} diff --git a/src/main/resources/mapper/PermissionMapper.xml b/src/main/resources/mapper/PermissionMapper.xml index e2584ed..03c65e3 100644 --- a/src/main/resources/mapper/PermissionMapper.xml +++ b/src/main/resources/mapper/PermissionMapper.xml @@ -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 @@ -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} - 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' ) - UPDATE sg_app_role + UPDATE hmm_access_roles SET max_sensitivity_level = #{maxSensitivityLevel,jdbcType=VARCHAR} WHERE role_id = #{roleId,jdbcType=NUMERIC} - DELETE FROM sg_app_role + DELETE FROM hmm_access_roles WHERE role_id = #{roleId,jdbcType=NUMERIC} @@ -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} - 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} - UPDATE sg_permission + UPDATE cb_permission SET action_name = #{action} WHERE perm_id = #{permissionId} - UPDATE sg_permission + UPDATE cb_permission SET permission_effect = #{permissionEffect} WHERE perm_id = #{permissionId} - DELETE FROM sg_permission_rule + DELETE FROM cb_permission_rule WHERE perm_id = #{permissionId} - 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 @@ - DELETE FROM sg_permission_column + DELETE FROM cb_permission_column WHERE permission_id = #{permissionId} - INSERT INTO sg_permission_column (permission_id, column_name) + INSERT INTO cb_permission_column (permission_id, column_name) VALUES (#{permissionId}, #{columnName}) - DELETE FROM sg_permission + DELETE FROM cb_permission WHERE perm_id = #{permissionId} diff --git a/src/main/resources/mapper/UserMapper.xml b/src/main/resources/mapper/UserMapper.xml index 9fbf3d1..9eeadb5 100644 --- a/src/main/resources/mapper/UserMapper.xml +++ b/src/main/resources/mapper/UserMapper.xml @@ -3,25 +3,27 @@ "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> - 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} - 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} - INSERT INTO sg_user_role (user_id, role_id) + INSERT INTO hmm_employee_access_roles (employee_id, role_id) VALUES (#{userId}, #{roleId}) - DELETE FROM sg_user_role - WHERE user_id = #{userId} + DELETE FROM hmm_employee_access_roles + WHERE employee_id = #{userId} AND role_id = #{roleId} diff --git a/src/main/resources/templates/tokens.html b/src/main/resources/templates/tokens.html index 2c1986c..d787dcc 100644 --- a/src/main/resources/templates/tokens.html +++ b/src/main/resources/templates/tokens.html @@ -1,25 +1,25 @@ - +
-

검증 세션

+

HMM 액세스 토큰

도움말 -

이해당사자 원장에 등록된 업무 사용자를 토큰에 연결합니다. 토큰 원문은 한 번만 표시하며, DB에는 해시와 식별용 prefix만 보관합니다. 역할·채널·행 권한은 토큰이 아니라 행 접근 규칙에서 동적으로 계산합니다.

+

HMM HR 직원에게 접근 토큰을 발급합니다. 토큰 원문은 한 번만 표시하며, DB에는 SHA-256 해시와 식별용 prefix만 보관합니다.

발급 완료 · 지금 한 번만 표시됩니다 -

이 토큰 원문을 복사한 뒤 접근 검증으로 이동하세요.

+

이 토큰 원문을 복사해 안전한 위치에 보관하세요.

화면을 떠난 뒤에는 원문을 다시 볼 수 없습니다. 잃어버리면 기존 토큰을 복구하지 말고 새로 발급하세요.

@@ -27,19 +27,19 @@
-

이해당사자 토큰 발급

-

모든 KB 이해당사자에게 토큰을 발급할 수 있습니다. 지점장·설계사 등의 실제 행 범위는 행 접근 규칙에 연결된 역할로 결정되므로, 토큰을 다시 만들지 않아도 권한 변경이 반영됩니다.

+

직원 토큰 발급

+

활성 HMM 직원에게 토큰을 발급합니다. 직접 역할과 접근 그룹 역할은 토큰 재발급 없이 조회 시점에 반영됩니다.