diff --git a/sql/adb/25_agent_ords_security_backoffice_support.sql b/sql/adb/25_agent_ords_security_backoffice_support.sql index 2d65d2c..941a265 100644 --- a/sql/adb/25_agent_ords_security_backoffice_support.sql +++ b/sql/adb/25_agent_ords_security_backoffice_support.sql @@ -72,6 +72,54 @@ EXCEPTION END; / +PROMPT === Creating group role support tables === +BEGIN + EXECUTE IMMEDIATE ' + CREATE TABLE cb_app_group ( + group_id NUMBER PRIMARY KEY, + group_code VARCHAR2(100) NOT NULL UNIQUE, + group_name VARCHAR2(100) NOT NULL, + description VARCHAR2(200), + active_yn CHAR(1) DEFAULT ''Y'' CHECK (active_yn IN (''Y'',''N'')) NOT NULL + )'; +EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN + RAISE; + END IF; +END; +/ + +BEGIN + EXECUTE IMMEDIATE ' + CREATE TABLE cb_user_group ( + group_id NUMBER NOT NULL, + user_id NUMBER NOT NULL, + CONSTRAINT cb_user_group_pk PRIMARY KEY (group_id, user_id) + )'; +EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN + RAISE; + END IF; +END; +/ + +BEGIN + EXECUTE IMMEDIATE ' + CREATE TABLE cb_group_role ( + group_id NUMBER NOT NULL, + role_id NUMBER NOT NULL, + CONSTRAINT cb_group_role_pk PRIMARY KEY (group_id, role_id) + )'; +EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN + RAISE; + END IF; +END; +/ + CREATE TABLE cb_protected_object ( object_id NUMBER PRIMARY KEY, owner VARCHAR2(128) NOT NULL, @@ -226,13 +274,25 @@ BEGIN SELECT COUNT(*) INTO v_allowed - FROM cb_user_role ur + FROM ( + SELECT ur.role_id + FROM cb_user_role ur + WHERE ur.user_id = TO_NUMBER(SYS_CONTEXT('CB_AGENT_CTX', 'USER_ID')) + UNION + SELECT gr.role_id + FROM cb_user_group ug + JOIN cb_app_group g + ON g.group_id = ug.group_id + AND g.active_yn = 'Y' + JOIN cb_group_role gr + ON gr.group_id = ug.group_id + WHERE ug.user_id = TO_NUMBER(SYS_CONTEXT('CB_AGENT_CTX', 'USER_ID')) + ) er JOIN cb_permission p - ON p.role_id = ur.role_id + ON p.role_id = er.role_id JOIN cb_permission_column pc ON pc.permission_id = p.perm_id - WHERE ur.user_id = TO_NUMBER(SYS_CONTEXT('CB_AGENT_CTX', 'USER_ID')) - AND p.target_name = UPPER(p_target_name) + WHERE p.target_name = UPPER(p_target_name) AND p.action_name = 'SELECT' AND pc.column_name = UPPER(p_column_name); diff --git a/sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql b/sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql index 1db0fce..37d2aea 100644 --- a/sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql +++ b/sql/adb/26_agent_ords_security_dynamic_vpd_filter.sql @@ -3,7 +3,8 @@ -- Replaces the demo VPD filter with a whitelist predicate builder. -- -- The function does not hardcode object names or rule values. It reads: --- CB_AGENT_CTX.USER_ID -> CB_USER_ROLE -> CB_PERMISSION -> CB_PERMISSION_RULE +-- CB_AGENT_CTX.USER_ID -> direct CB_USER_ROLE + group CB_USER_GROUP/CB_GROUP_ROLE +-- -> CB_PERMISSION -> CB_PERMISSION_RULE -- and builds a row predicate for the object passed by DBMS_RLS. -- ============================================================ WHENEVER SQLERROR EXIT SQL.SQLCODE @@ -94,17 +95,30 @@ BEGIN v_target := UPPER(TRIM(p_object)); FOR r IN ( + WITH effective_role AS ( + SELECT ur.role_id + FROM cb_user_role ur + WHERE ur.user_id = v_user_id + UNION + SELECT gr.role_id + FROM cb_user_group ug + JOIN cb_app_group g + ON g.group_id = ug.group_id + AND g.active_yn = 'Y' + JOIN cb_group_role gr + ON gr.group_id = ug.group_id + WHERE ug.user_id = v_user_id + ) SELECT UPPER(TRIM(r.rule_type)) AS rule_type, UPPER(TRIM(r.rule_column)) AS rule_column, NVL(UPPER(TRIM(p.permission_effect)), 'ALLOW') AS permission_effect, r.rule_value - FROM cb_user_role ur + FROM effective_role er JOIN cb_permission p - ON p.role_id = ur.role_id + ON p.role_id = er.role_id JOIN cb_permission_rule r ON r.perm_id = p.perm_id - WHERE ur.user_id = v_user_id - AND p.target_name = v_target + WHERE p.target_name = v_target AND p.action_name = 'SELECT' ORDER BY r.rule_id ) LOOP diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/AppGroup.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/AppGroup.java new file mode 100644 index 0000000..344e24e --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/AppGroup.java @@ -0,0 +1,14 @@ +package com.cloudhandson.vpdbackoffice.domain.group; + +public record AppGroup( + long groupId, + String groupCode, + String groupName, + String description, + String activeYn +) { + + public boolean active() { + return "Y".equalsIgnoreCase(activeYn); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupCreateCommand.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupCreateCommand.java new file mode 100644 index 0000000..0de228b --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupCreateCommand.java @@ -0,0 +1,8 @@ +package com.cloudhandson.vpdbackoffice.domain.group; + +public record GroupCreateCommand( + String groupCode, + String groupName, + String description +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupRoleView.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupRoleView.java new file mode 100644 index 0000000..cc6e12a --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupRoleView.java @@ -0,0 +1,10 @@ +package com.cloudhandson.vpdbackoffice.domain.group; + +public record GroupRoleView( + long groupId, + String groupCode, + String groupName, + long roleId, + String roleName +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupUserView.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupUserView.java new file mode 100644 index 0000000..d1f21bf --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/group/GroupUserView.java @@ -0,0 +1,10 @@ +package com.cloudhandson.vpdbackoffice.domain.group; + +public record GroupUserView( + long groupId, + String groupCode, + String groupName, + long userId, + String username +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/GroupMapper.java b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/GroupMapper.java new file mode 100644 index 0000000..bd2c8fd --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/GroupMapper.java @@ -0,0 +1,33 @@ +package com.cloudhandson.vpdbackoffice.mapper; + +import com.cloudhandson.vpdbackoffice.domain.group.AppGroup; +import com.cloudhandson.vpdbackoffice.domain.group.GroupCreateCommand; +import com.cloudhandson.vpdbackoffice.domain.group.GroupRoleView; +import com.cloudhandson.vpdbackoffice.domain.group.GroupUserView; +import java.util.List; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface GroupMapper { + + List findAll(); + + List findGroupUsers(); + + List findGroupRoles(); + + long nextGroupId(); + + void insertGroup(@Param("groupId") long groupId, @Param("command") GroupCreateCommand command); + + int updateActive(@Param("groupId") long groupId, @Param("activeYn") String activeYn); + + void insertGroupUser(@Param("groupId") long groupId, @Param("userId") long userId); + + int deleteGroupUser(@Param("groupId") long groupId, @Param("userId") long userId); + + void insertGroupRole(@Param("groupId") long groupId, @Param("roleId") long roleId); + + int deleteGroupRole(@Param("groupId") long groupId, @Param("roleId") long roleId); +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java index f280d30..a8a7d54 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/BackofficeSchemaService.java @@ -47,6 +47,15 @@ public class BackofficeSchemaService { """); addColumn(results, "cb_app_role", "max_sensitivity_level", "ALTER TABLE cb_app_role ADD (max_sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL)"); + createTable(results, "cb_app_group", """ + CREATE TABLE cb_app_group ( + group_id NUMBER PRIMARY KEY, + group_code VARCHAR2(100) NOT NULL UNIQUE, + group_name VARCHAR2(100) NOT NULL, + description VARCHAR2(200), + active_yn CHAR(1) DEFAULT 'Y' CHECK (active_yn IN ('Y','N')) NOT NULL + ) + """); createTable(results, "cb_user_role", """ CREATE TABLE cb_user_role ( user_id NUMBER NOT NULL, @@ -54,6 +63,20 @@ public class BackofficeSchemaService { CONSTRAINT cb_user_role_pk PRIMARY KEY (user_id, role_id) ) """); + createTable(results, "cb_user_group", """ + CREATE TABLE cb_user_group ( + group_id NUMBER NOT NULL, + user_id NUMBER NOT NULL, + CONSTRAINT cb_user_group_pk PRIMARY KEY (group_id, user_id) + ) + """); + createTable(results, "cb_group_role", """ + CREATE TABLE cb_group_role ( + group_id NUMBER NOT NULL, + role_id NUMBER NOT NULL, + CONSTRAINT cb_group_role_pk PRIMARY KEY (group_id, role_id) + ) + """); createTable(results, "cb_permission", """ CREATE TABLE cb_permission ( perm_id NUMBER PRIMARY KEY, diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/GroupService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/GroupService.java new file mode 100644 index 0000000..fe18013 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/GroupService.java @@ -0,0 +1,86 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.domain.audit.AuditEvent; +import com.cloudhandson.vpdbackoffice.domain.group.AppGroup; +import com.cloudhandson.vpdbackoffice.domain.group.GroupCreateCommand; +import com.cloudhandson.vpdbackoffice.domain.group.GroupRoleView; +import com.cloudhandson.vpdbackoffice.domain.group.GroupUserView; +import com.cloudhandson.vpdbackoffice.mapper.GroupMapper; +import java.util.List; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class GroupService { + + private final GroupMapper groupMapper; + private final AuditService auditService; + + public GroupService(GroupMapper groupMapper, AuditService auditService) { + this.groupMapper = groupMapper; + this.auditService = auditService; + } + + public List findAll() { + return groupMapper.findAll(); + } + + public List findGroupUsers() { + return groupMapper.findGroupUsers(); + } + + public List findGroupRoles() { + return groupMapper.findGroupRoles(); + } + + @Transactional + public void createGroup(GroupCreateCommand command) { + long groupId = groupMapper.nextGroupId(); + groupMapper.insertGroup(groupId, command); + auditService.record(new AuditEvent("GROUP_CREATED", null, null, "SUCCESS", null, null, command.groupCode())); + } + + @Transactional + public void setActive(long groupId, boolean active) { + int updated = groupMapper.updateActive(groupId, active ? "Y" : "N"); + if (updated == 0) { + throw new AppException("그룹을 찾을 수 없습니다."); + } + auditService.record(new AuditEvent("GROUP_ACTIVE_CHANGED", null, null, "SUCCESS", null, null, + "groupId=" + groupId + ",active=" + active)); + } + + @Transactional + public void addUser(long groupId, long userId) { + groupMapper.insertGroupUser(groupId, userId); + auditService.record(new AuditEvent("GROUP_USER_ADDED", null, null, "SUCCESS", null, null, + "groupId=" + groupId + ",userId=" + userId)); + } + + @Transactional + public void removeUser(long groupId, long userId) { + int deleted = groupMapper.deleteGroupUser(groupId, userId); + if (deleted == 0) { + throw new AppException("삭제할 그룹 사용자 매핑을 찾을 수 없습니다."); + } + auditService.record(new AuditEvent("GROUP_USER_REMOVED", null, null, "SUCCESS", null, null, + "groupId=" + groupId + ",userId=" + userId)); + } + + @Transactional + public void addRole(long groupId, long roleId) { + groupMapper.insertGroupRole(groupId, roleId); + auditService.record(new AuditEvent("GROUP_ROLE_ADDED", null, null, "SUCCESS", null, null, + "groupId=" + groupId + ",roleId=" + roleId)); + } + + @Transactional + public void removeRole(long groupId, long roleId) { + int deleted = groupMapper.deleteGroupRole(groupId, roleId); + if (deleted == 0) { + throw new AppException("삭제할 그룹 역할 매핑을 찾을 수 없습니다."); + } + auditService.record(new AuditEvent("GROUP_ROLE_REMOVED", null, null, "SUCCESS", null, null, + "groupId=" + groupId + ",roleId=" + roleId)); + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/GroupController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/GroupController.java new file mode 100644 index 0000000..a8e74f5 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/GroupController.java @@ -0,0 +1,103 @@ +package com.cloudhandson.vpdbackoffice.web; + +import com.cloudhandson.vpdbackoffice.domain.group.GroupCreateCommand; +import com.cloudhandson.vpdbackoffice.service.GroupService; +import com.cloudhandson.vpdbackoffice.service.PermissionService; +import com.cloudhandson.vpdbackoffice.service.UserService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +@Controller +public class GroupController { + + private final GroupService groupService; + private final UserService userService; + private final PermissionService permissionService; + + public GroupController(GroupService groupService, UserService userService, PermissionService permissionService) { + this.groupService = groupService; + this.userService = userService; + this.permissionService = permissionService; + } + + @GetMapping("/groups") + public String groups(Model model) { + model.addAttribute("groups", groupService.findAll()); + model.addAttribute("users", userService.findAll()); + model.addAttribute("roles", permissionService.findRoles()); + model.addAttribute("groupUsers", groupService.findGroupUsers()); + model.addAttribute("groupRoles", groupService.findGroupRoles()); + return "groups"; + } + + @PostMapping("/groups") + public String create( + @RequestParam String groupCode, + @RequestParam String groupName, + @RequestParam(required = false) String description, + RedirectAttributes redirectAttributes + ) { + groupService.createGroup(new GroupCreateCommand(groupCode, groupName, description)); + redirectAttributes.addFlashAttribute("message", "그룹을 추가했습니다."); + return "redirect:/groups"; + } + + @PostMapping("/groups/active") + public String active( + @RequestParam long groupId, + @RequestParam boolean active, + RedirectAttributes redirectAttributes + ) { + groupService.setActive(groupId, active); + redirectAttributes.addFlashAttribute("message", "그룹 상태를 변경했습니다."); + return "redirect:/groups"; + } + + @PostMapping("/groups/users") + public String addUser( + @RequestParam long groupId, + @RequestParam long userId, + RedirectAttributes redirectAttributes + ) { + groupService.addUser(groupId, userId); + redirectAttributes.addFlashAttribute("message", "그룹에 사용자를 추가했습니다."); + return "redirect:/groups"; + } + + @PostMapping("/groups/users/delete") + public String removeUser( + @RequestParam long groupId, + @RequestParam long userId, + RedirectAttributes redirectAttributes + ) { + groupService.removeUser(groupId, userId); + redirectAttributes.addFlashAttribute("message", "그룹 사용자를 해제했습니다."); + return "redirect:/groups"; + } + + @PostMapping("/groups/roles") + public String addRole( + @RequestParam long groupId, + @RequestParam long roleId, + RedirectAttributes redirectAttributes + ) { + groupService.addRole(groupId, roleId); + redirectAttributes.addFlashAttribute("message", "그룹에 역할을 부여했습니다."); + return "redirect:/groups"; + } + + @PostMapping("/groups/roles/delete") + public String removeRole( + @RequestParam long groupId, + @RequestParam long roleId, + RedirectAttributes redirectAttributes + ) { + groupService.removeRole(groupId, roleId); + redirectAttributes.addFlashAttribute("message", "그룹 역할을 해제했습니다."); + return "redirect:/groups"; + } +} diff --git a/src/main/resources/mapper/GroupMapper.xml b/src/main/resources/mapper/GroupMapper.xml new file mode 100644 index 0000000..6a52f48 --- /dev/null +++ b/src/main/resources/mapper/GroupMapper.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + INSERT INTO cb_app_group ( + group_id, group_code, group_name, description, active_yn + ) VALUES ( + #{groupId,jdbcType=NUMERIC}, + UPPER(#{command.groupCode,jdbcType=VARCHAR}), + #{command.groupName,jdbcType=VARCHAR}, + #{command.description,jdbcType=VARCHAR}, + 'Y' + ) + + + + UPDATE cb_app_group + SET active_yn = #{activeYn,jdbcType=VARCHAR} + WHERE group_id = #{groupId,jdbcType=NUMERIC} + + + + INSERT INTO cb_user_group (group_id, user_id) + VALUES (#{groupId,jdbcType=NUMERIC}, #{userId,jdbcType=NUMERIC}) + + + + DELETE FROM cb_user_group + WHERE group_id = #{groupId,jdbcType=NUMERIC} + AND user_id = #{userId,jdbcType=NUMERIC} + + + + INSERT INTO cb_group_role (group_id, role_id) + VALUES (#{groupId,jdbcType=NUMERIC}, #{roleId,jdbcType=NUMERIC}) + + + + DELETE FROM cb_group_role + WHERE group_id = #{groupId,jdbcType=NUMERIC} + AND role_id = #{roleId,jdbcType=NUMERIC} + + diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js index 1dcddce..e04db4b 100644 --- a/src/main/resources/static/js/app.js +++ b/src/main/resources/static/js/app.js @@ -243,6 +243,29 @@ function filterUserRoleDetail() { empty.hidden = shown !== 0; } +function filterGroupDetail(masterId) { + const master = document.getElementById(masterId); + const table = document.querySelector(`[data-group-detail-table="${masterId}"]`); + if (!master || !table) { + return; + } + const selected = master.value; + let shown = 0; + table.querySelectorAll('tbody tr[data-group-id]').forEach((row) => { + const visible = row.dataset.groupId === selected; + row.hidden = !visible; + shown += visible ? 1 : 0; + }); + let empty = table.querySelector('tbody tr.empty-group-detail-runtime'); + if (!empty) { + empty = document.createElement('tr'); + empty.className = 'empty-group-detail-runtime'; + empty.innerHTML = `${escapeHtml(table.dataset.emptyMessage || '선택한 그룹에 등록된 항목이 없습니다.')}`; + table.querySelector('tbody').appendChild(empty); + } + empty.hidden = shown !== 0; +} + function renderRuleColumnOptions(columns) { document.querySelectorAll('.rule-column-select').forEach((select) => { const current = select.value; @@ -477,6 +500,10 @@ document.addEventListener('DOMContentLoaded', () => { master.addEventListener('change', filterUserRoleDetail); filterUserRoleDetail(); } + document.querySelectorAll('.group-master-select').forEach((select) => { + select.addEventListener('change', () => filterGroupDetail(select.id)); + filterGroupDetail(select.id); + }); const objectSelect = document.querySelector('select[name="objectRef"]'); if (objectSelect) { objectSelect.addEventListener('change', () => { diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html index 491cc17..f157a56 100644 --- a/src/main/resources/templates/fragments/layout.html +++ b/src/main/resources/templates/fragments/layout.html @@ -19,6 +19,7 @@
사용자 + 그룹 역할 권한 토큰 @@ -62,7 +63,7 @@
Backoffice Tables 권한 테이블 -

사용자, 역할, 행 규칙, 컬럼 원문 허용을 저장합니다.

+

사용자, 그룹, 역할, 행 규칙, 컬럼 원문 허용을 저장합니다.

diff --git a/src/main/resources/templates/groups.html b/src/main/resources/templates/groups.html new file mode 100644 index 0000000..558af9f --- /dev/null +++ b/src/main/resources/templates/groups.html @@ -0,0 +1,173 @@ + + + + + +
+
+

그룹 관리

+

사용자 그룹을 만들고 그룹에 사용자와 역할을 부여합니다. 권한은 역할에 연결되고, 사용자는 직접 역할과 그룹 역할을 함께 상속합니다.

+
+ +
+ +
+

그룹 추가

+
+ + + + + +
+
+ +
+

그룹 목록

+
+ + + + + + + + + + + + + + + + + + + + + + + + +
ID코드그룹명설명상태
1SALES_TEAM영업팀설명Y +
+ + + + +
+
등록된 그룹이 없습니다.
+
+
+ +
+

그룹 사용자

+
+ + + + +
+ +

선택 그룹 사용자

+
+ + + + + + + + + + + + + + + + + + +
그룹사용자
SALES_TEAMagent_sales +
+ + + + +
+
등록된 그룹 사용자가 없습니다.
+
+
+ +
+

그룹 역할

+
+ + + + +
+ +

선택 그룹 역할

+
+ + + + + + + + + + + + + + + + + + +
그룹역할
SALES_TEAMSALES_ROLE +
+ + + + +
+
등록된 그룹 역할이 없습니다.
+
+
+
+ +