From 1e48864bce831ce2467c2591b357aeefb938094a Mon Sep 17 00:00:00 2001 From: devmrko Date: Tue, 30 Jun 2026 07:51:36 +0900 Subject: [PATCH] feat: publish application permissions to DDS grants --- dds-backoffice/README.md | 16 +- .../ddsbackoffice/config/DdsProperties.java | 54 ++- .../ddsbackoffice/domain/DdsGrantPlan.java | 18 + .../domain/DdsProvisioningPlan.java | 24 + .../service/DdsGrantPublisher.java | 455 ++++++++++++++++++ .../web/DdsProvisionController.java | 52 ++ .../src/main/resources/application.yml | 11 + .../main/resources/templates/dds-home.html | 7 +- .../resources/templates/dds-provision.html | 99 ++++ .../resources/templates/fragments/layout.html | 1 + .../resources/templates/vector-knowledge.html | 5 +- .../resources/templates/vpd-policies.html | 5 +- .../config/DdsPropertiesTest.java | 23 + scripts/deploy-dds-backoffice-local.sh | 2 +- sql/adb/32_dds_vector_tag_setup.sql | 81 +++- 15 files changed, 841 insertions(+), 12 deletions(-) create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsGrantPlan.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsProvisioningPlan.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsGrantPublisher.java create mode 100644 dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProvisionController.java create mode 100644 dds-backoffice/src/main/resources/templates/dds-provision.html diff --git a/dds-backoffice/README.md b/dds-backoffice/README.md index dc43a1b..c20a29c 100644 --- a/dds-backoffice/README.md +++ b/dds-backoffice/README.md @@ -4,6 +4,7 @@ - 기본 포트: `8083` - 기본 화면: `/` (공통 권한 여정), `/dds` (DDS 직접 조회 검증) +- 권한 반영: `/dds-provision` (공통 권한을 DDS DATA GRANT로 미리보기·게시) - 기본 보호 객체: `ADMIN.V_DDS_CUSTOMERS_PG`, `ADMIN.V_DDS_CUSTOMERS_MY` - 지식 검색 보호 객체: `ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS` - DDS 전용 사용자: `dds_demo_my`, `dds_demo_pg`, `dds_demo_both`, `dds_demo_none` @@ -14,11 +15,13 @@ 두 인스턴스는 프로세스·포트·실행 경계를 분리하지만, 화면에서 설명하는 관리 순서는 같습니다. ```text -사용자·그룹·역할 → 데이터 권한 규칙 → 보호 연결 → 실제 결과 확인 +사용자·그룹·역할 → 데이터 권한 규칙 → DDS Grant 게시 → 보호 연결 → 실제 결과 확인 ``` 8083 DDS 화면의 `/users`, `/groups`, `/roles`, `/permissions`, `/effective-matrix`는 8082 VPD 화면과 같은 관리 기준을 보여줍니다. 다만 보호를 실행하는 DB 계층만 다릅니다. VPD 인스턴스는 공통 DB 계정과 Bearer 컨텍스트로 동적 predicate를 계산하고, DDS 인스턴스는 DDS `END USER`의 DATA ROLE/DATA GRANT로 직접 집행합니다. +`/dds-provision`은 이 차이를 연결하는 명시적 배포 단계입니다. 직접 역할과 활성 그룹에서 상속된 역할을 합쳐 사용자별 유효 권한을 계산하고, 애플리케이션 객체 매핑을 거쳐 `CREATE OR REPLACE DATA GRANT` SQL을 미리 보여줍니다. 그룹을 DDS 그룹으로 복사하지 않고, 그룹이 상속한 역할을 해당 DDS DATA ROLE의 최종 predicate로 합칩니다. 매핑되지 않은 객체는 경고로 남기고 게시하지 않습니다. + | 데모 | 사용자 식별 | 정책 표현 | 권한 변경 | |---|---|---|---| | VPD | 공통 계정 + Bearer/세션 컨텍스트 | 동적 predicate 함수 | 업무 권한 테이블 | @@ -26,6 +29,15 @@ 따라서 두 인스턴스의 관리 흐름과 기대 결과는 맞출 수 있지만, Bearer 요청마다 권한을 계산하는 VPD 실행 모델을 DDS가 그대로 대체하는 것은 아닙니다. DDS로 Bearer 기반 호출을 운영하려면 지원 드라이버의 `EndUserSecurityContext` 전파가 별도로 필요합니다. +권한 변경 후에는 다음 순서로 확인합니다. + +```text +1. /permissions에서 사용자·그룹·역할·행/TAG·컬럼 기준을 저장 +2. /dds-provision에서 사용자별 predicate와 제외 컬럼을 검토 +3. 승인된 경우에만 DDS DATA GRANT 게시 +4. /dds 또는 /vector-knowledge에서 END USER별 결과를 검증 +``` + 이 앱의 조회 경계는 다음과 같습니다. ```text @@ -43,7 +55,7 @@ sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @sql/adb/31_dds_standalone_demo_setup sqlplus "$ADB_USER/$ADB_PASSWORD@$ADB_TNS" @sql/adb/32_dds_vector_tag_setup.sql ``` -`32_dds_vector_tag_setup.sql`은 공통 청크·태그 저장소를 읽는 DDS 전용 VIEW를 만들고, `SPRING_BOOT`, `ORDS`, `ORACLE_VPD`, `MCP` 같은 기술 태그를 DATA GRANT의 `WHERE` 조건으로 연결합니다. 검색 화면에서는 같은 문서를 청킹·임베딩한 뒤 선택한 END USER로 직접 조회하므로, DDS grant가 없는 주체는 보호 VIEW 자체를 볼 수 없습니다. +`32_dds_vector_tag_setup.sql`은 공통 청크·태그 저장소를 읽는 DDS 전용 VIEW를 만들고, 동일한 `CB_PERMISSION`/`CB_PERMISSION_RULE`에 역할별 TAG 규칙을 등록합니다. `SPRING_BOOT`, `ORDS`, `ORACLE_VPD`, `MCP`처럼 한 역할에 여러 TAG가 있으면 `/dds-provision`이 OR 조건으로 합쳐 DDS DATA GRANT를 다시 만듭니다. 벡터 검색에 필요한 `EMBEDDING`은 DDS 엔진이 거리 계산에 사용하므로 Grant에서 제외하지 않고, 검색 응답에서는 애플리케이션이 반환하지 않습니다. 그 외 민감 컬럼은 애플리케이션 권한의 원문 허용 목록에 없으면 `ALL COLUMNS EXCEPT`로 제외됩니다. 검색 화면에서는 같은 문서를 청킹·임베딩한 뒤 선택한 END USER로 직접 조회하므로, DDS Grant가 없는 주체는 보호 VIEW 자체를 볼 수 없습니다. 그 다음 DDS 인스턴스를 실행합니다. diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsProperties.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsProperties.java index 4a9516a..efda576 100644 --- a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsProperties.java +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsProperties.java @@ -14,7 +14,8 @@ public record DdsProperties( String pgObject, String myObject, String vectorObject, - Map users + Map users, + Map objectMappings ) { private static final Pattern OBJECT_NAME = Pattern.compile( @@ -37,6 +38,15 @@ public record DdsProperties( }); } users = Map.copyOf(normalizedUsers); + var normalizedMappings = new LinkedHashMap(); + if (objectMappings != null) { + objectMappings.forEach((key, value) -> { + if (key != null && value != null && !key.isBlank() && !value.isBlank()) { + normalizedMappings.put(key.trim().toUpperCase(), normalizeObject(value, "")); + } + }); + } + objectMappings = Map.copyOf(normalizedMappings); } /** @@ -52,7 +62,19 @@ public record DdsProperties( Map users ) { this(dbUrl, queryTimeout, pgObject, myObject, - "ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS", users); + "ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS", users, Map.of( + "CB_VECTOR_SEARCH_DOCUMENTS", "ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS")); + } + + public DdsProperties( + String dbUrl, + Duration queryTimeout, + String pgObject, + String myObject, + String vectorObject, + Map users + ) { + this(dbUrl, queryTimeout, pgObject, myObject, vectorObject, users, Map.of()); } public String objectFor(String sourceKey) { @@ -73,11 +95,37 @@ public record DdsProperties( return candidate.toUpperCase(); } - public record User(String label, String description, String username, String password) { + public record User( + String label, + String description, + String username, + String password, + long applicationUserId, + String dataRole + ) { + + /** + * Keep the six-field constructor as the configuration binding target. + * The four-field overload below exists only for the small query-service + * tests and older local callers; without an explicit binding marker, + * Spring Boot can select that overload and silently leave the application + * user/data-role mapping at its default values. + */ + @ConstructorBinding + public User { + } + + public User(String label, String description, String username, String password) { + this(label, description, username, password, 0L, ""); + } public boolean configured() { return username != null && !username.isBlank() && password != null && !password.isBlank(); } + + public boolean mapped() { + return applicationUserId > 0 && dataRole != null && !dataRole.isBlank(); + } } } diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsGrantPlan.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsGrantPlan.java new file mode 100644 index 0000000..a07911c --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsGrantPlan.java @@ -0,0 +1,18 @@ +package com.cloudhandson.ddsbackoffice.domain; + +/** One generated DDS DATA GRANT and its management-model origin. */ +public record DdsGrantPlan( + String userKey, + String userLabel, + long applicationUserId, + String dataRole, + String applicationObject, + String ddsObject, + String grantName, + String predicate, + String excludedColumns, + String sql, + boolean publishable, + String note +) { +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsProvisioningPlan.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsProvisioningPlan.java new file mode 100644 index 0000000..6ecba46 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsProvisioningPlan.java @@ -0,0 +1,24 @@ +package com.cloudhandson.ddsbackoffice.domain; + +import java.util.List; + +/** Preview of the effective application permissions compiled to DDS grants. */ +public record DdsProvisioningPlan( + List grants, + List warnings, + int mappedUserCount +) { + + public DdsProvisioningPlan { + grants = grants == null ? List.of() : List.copyOf(grants); + warnings = warnings == null ? List.of() : List.copyOf(warnings); + } + + public long publishableCount() { + return grants.stream().filter(DdsGrantPlan::publishable).count(); + } + + public boolean hasWarnings() { + return !warnings.isEmpty(); + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsGrantPublisher.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsGrantPublisher.java new file mode 100644 index 0000000..ea015f5 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsGrantPublisher.java @@ -0,0 +1,455 @@ +package com.cloudhandson.ddsbackoffice.service; + +import com.cloudhandson.ddsbackoffice.config.DdsProperties; +import com.cloudhandson.ddsbackoffice.domain.DdsGrantPlan; +import com.cloudhandson.ddsbackoffice.domain.DdsProvisioningPlan; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * Compiles the application's effective permission model into DDS grants. + * + * DDS does not evaluate CB_PERMISSION at query time. This service is the + * explicit publish boundary: application users/groups/roles are expanded, + * ALLOW/DENY rules are compiled into one predicate per user/object, and the + * resulting DATA GRANT is created or replaced. A publish with no ALLOW rule + * drops the reserved grant, preserving default deny. + */ +@Service +public class DdsGrantPublisher { + + private static final Pattern IDENTIFIER = Pattern.compile("[A-Z][A-Z0-9_$#]*"); + private static final Pattern OBJECT = Pattern.compile( + "[A-Z][A-Z0-9_$#]*(\\.[A-Z][A-Z0-9_$#]*)*" + ); + + private final JdbcTemplate jdbcTemplate; + private final DdsProperties properties; + + public DdsGrantPublisher(JdbcTemplate jdbcTemplate, DdsProperties properties) { + this.jdbcTemplate = jdbcTemplate; + this.properties = properties; + } + + public DdsProvisioningPlan preview() { + List grants = new ArrayList<>(); + List warnings = new ArrayList<>(); + int mappedUsers = 0; + + for (Map.Entry entry : properties.users().entrySet()) { + String userKey = entry.getKey(); + DdsProperties.User ddsUser = entry.getValue(); + if (!ddsUser.mapped()) { + // The demo's "none" identity is deliberately a DDS-only default-deny + // subject. It has no application user mapping by design, so do not + // turn that expected state into a noisy warning. + if (ddsUser.applicationUserId() == 0L && "none".equals(userKey)) { + continue; + } + warnings.add(userKey + ": 애플리케이션 사용자 ID 또는 DDS DATA ROLE 매핑이 없습니다."); + continue; + } + AppIdentity identity = findIdentity(ddsUser.applicationUserId()); + if (identity == null) { + warnings.add(userKey + ": CB_APP_USER " + ddsUser.applicationUserId() + "를 찾을 수 없습니다."); + continue; + } + if (!identity.active()) { + warnings.add(userKey + ": 애플리케이션 사용자가 비활성 상태입니다."); + } + mappedUsers++; + + Map permissions = findEffectivePermissions(identity.userId()); + // Include every approved mapping, not only objects that still have a + // permission row. This lets an explicit publish revoke a previously + // generated grant when an application permission is removed; the DDS + // side otherwise has no way to observe that deletion. + Set targets = new LinkedHashSet<>(properties.objectMappings().keySet()); + permissions.values().forEach(permission -> targets.add(permission.targetName())); + for (String target : targets) { + String normalizedTarget = target.toUpperCase(Locale.ROOT); + String ddsObject = properties.objectMappings().get(normalizedTarget); + String grantName = grantName(userKey, normalizedTarget); + if (ddsObject == null || ddsObject.isBlank()) { + warnings.add(userKey + ": " + normalizedTarget + "에 대한 DDS 보호 객체 매핑이 없습니다."); + grants.add(unpublishable(userKey, ddsUser, normalizedTarget, grantName, + "DDS object mapping 필요")); + continue; + } + if (!OBJECT.matcher(ddsObject).matches()) { + warnings.add(userKey + ": 안전하지 않은 DDS 객체 매핑을 건너뛰었습니다: " + ddsObject); + grants.add(unpublishable(userKey, ddsUser, normalizedTarget, grantName, + "DDS object 이름 검증 실패")); + continue; + } + ObjectInfo object = findProtectedObject(normalizedTarget); + if (object == null) { + warnings.add(userKey + ": 보호 객체 메타데이터가 없습니다: " + normalizedTarget); + grants.add(unpublishable(userKey, ddsUser, normalizedTarget, grantName, + "CB_PROTECTED_OBJECT 등록 필요")); + continue; + } + if (!ddsObjectExists(ddsObject)) { + warnings.add(userKey + ": DDS 보호 객체가 DB에 없습니다: " + ddsObject); + grants.add(unpublishable(userKey, ddsUser, normalizedTarget, grantName, + "DDS VIEW/TABLE 생성 필요")); + continue; + } + + List targetPermissions = permissions.values().stream() + .filter(permission -> normalizedTarget.equals(permission.targetName())) + .toList(); + PredicateBuild predicate = buildPredicate(targetPermissions, identity, object); + String excludedColumns = excludedColumns(normalizedTarget, targetPermissions, object.objectId()); + if (predicate.allow().isBlank()) { + grants.add(new DdsGrantPlan( + userKey, valueOrDefault(ddsUser.label(), userKey), identity.userId(), + ddsUser.dataRole(), normalizedTarget, ddsObject, grantName, + "1 = 0", excludedColumns, "", false, + "ALLOW 규칙이 없어 기존 DDS Grant를 회수합니다.") + ); + continue; + } + + String where = predicate.expression(); + String sql = createGrantSql(grantName, ddsObject, where, excludedColumns, ddsUser.dataRole()); + grants.add(new DdsGrantPlan( + userKey, valueOrDefault(ddsUser.label(), userKey), identity.userId(), + ddsUser.dataRole(), normalizedTarget, ddsObject, grantName, + where, excludedColumns, sql, true, + "애플리케이션 유효 권한을 DDS DATA GRANT로 게시할 수 있습니다.") + ); + warnings.addAll(predicate.warnings()); + } + } + return new DdsProvisioningPlan(grants, warnings, mappedUsers); + } + + public DdsProvisioningPlan publish() { + DdsProvisioningPlan plan = preview(); + for (DdsGrantPlan grant : plan.grants()) { + // Never touch a grant for an application object that has no approved + // DDS object mapping. An operator may still manage that DDS object by a + // separate process; the preview warning must not become an accidental + // revoke. Known mappings are safe to replace or revoke. + if (grant.ddsObject() == null || grant.ddsObject().isBlank()) { + continue; + } + dropGrant(grant.grantName()); + if (grant.publishable()) { + jdbcTemplate.execute(grant.sql()); + } + } + return plan; + } + + private Map findEffectivePermissions(long userId) { + String sql = """ + WITH effective_role AS ( + SELECT ur.role_id + FROM cb_user_role ur + WHERE ur.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 = ? + ) + SELECT p.perm_id, p.target_name, p.permission_effect, + r.rule_id, r.rule_column, r.rule_type, r.rule_value + FROM effective_role er + JOIN cb_permission p ON p.role_id = er.role_id + LEFT JOIN cb_permission_rule r ON r.perm_id = p.perm_id + WHERE p.action_name = 'SELECT' + ORDER BY p.perm_id, r.rule_id + """; + Map result = new LinkedHashMap<>(); + List rows = jdbcTemplate.query(sql, (row, ignored) -> new RawPermissionRow( + row.getLong("perm_id"), + row.getString("target_name"), + row.getString("permission_effect"), + row.getString("rule_column"), + row.getString("rule_type"), + row.getString("rule_value") + ), userId, userId); + for (RawPermissionRow row : rows) { + long permissionId = row.permissionId(); + PermissionEntry permission = result.computeIfAbsent(permissionId, ignored -> + new PermissionEntry( + permissionId, + upper(row.targetName()), + normalizeEffect(row.permissionEffect()), + new ArrayList<>(), + findVisibleColumns(permissionId) + )); + if (row.ruleType() != null && !row.ruleType().isBlank()) { + permission.rules().add(new Rule( + upperNullable(row.ruleColumn()), + upper(row.ruleType()), + row.ruleValue() + )); + } + } + return result; + } + + private List findVisibleColumns(long permissionId) { + return jdbcTemplate.query( + "SELECT column_name FROM cb_permission_column WHERE permission_id = ? ORDER BY column_name", + (row, ignored) -> upper(row.getString("column_name")), permissionId); + } + + private AppIdentity findIdentity(long userId) { + List rows = jdbcTemplate.query( + "SELECT user_id, employee_no, dept_code, active FROM cb_app_user WHERE user_id = ?", + (row, ignored) -> new AppIdentity( + row.getLong("user_id"), + row.getString("employee_no"), + row.getString("dept_code"), + "Y".equalsIgnoreCase(row.getString("active")) + ), userId); + return rows.isEmpty() ? null : rows.get(0); + } + + private ObjectInfo findProtectedObject(String targetName) { + List rows = jdbcTemplate.query( + "SELECT object_id, owner, object_name FROM cb_protected_object WHERE object_name = UPPER(?)", + (row, ignored) -> new ObjectInfo( + row.getLong("object_id"), upper(row.getString("owner")), upper(row.getString("object_name"))), + targetName); + return rows.isEmpty() ? null : rows.get(0); + } + + private List sensitiveColumns(long objectId) { + return jdbcTemplate.query( + "SELECT column_name FROM cb_protected_column WHERE object_id = ? AND sensitive_yn = 'Y' ORDER BY column_id", + (row, ignored) -> upper(row.getString("column_name")), objectId); + } + + private boolean ddsObjectExists(String objectName) { + String[] parts = objectName.split("\\.", 2); + if (parts.length != 2) { + return false; + } + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM all_objects WHERE owner = ? AND object_name = ? AND object_type IN ('TABLE','VIEW')", + Integer.class, parts[0], parts[1]); + return count != null && count > 0; + } + + private PredicateBuild buildPredicate( + List permissions, + AppIdentity identity, + ObjectInfo object + ) { + List allow = new ArrayList<>(); + List deny = new ArrayList<>(); + List warnings = new ArrayList<>(); + for (PermissionEntry permission : permissions) { + List clauses = new ArrayList<>(); + for (Rule rule : permission.rules()) { + String clause = compileRule(rule, identity, object); + if (clause == null || clause.isBlank()) { + warnings.add(permission.targetName() + ": 지원하지 않거나 컬럼을 확인할 수 없는 규칙을 건너뛰었습니다: " + + rule.ruleType()); + continue; + } + clauses.add(clause); + } + if (clauses.isEmpty()) { + continue; + } + String expression = clauses.size() == 1 ? clauses.get(0) : "(" + String.join(" OR ", clauses) + ")"; + if ("DENY".equals(permission.effect())) { + deny.add(expression); + } else { + allow.add(expression); + } + } + if (allow.isEmpty()) { + return new PredicateBuild("", "", warnings); + } + String allowExpression = allow.size() == 1 ? allow.get(0) : "(" + String.join(" OR ", allow) + ")"; + if (deny.isEmpty()) { + return new PredicateBuild(allowExpression, allowExpression, warnings); + } + String denyExpression = deny.size() == 1 ? deny.get(0) : "(" + String.join(" OR ", deny) + ")"; + return new PredicateBuild(allowExpression, "(" + allowExpression + ") AND NOT (" + denyExpression + ")", warnings); + } + + private String compileRule(Rule rule, AppIdentity identity, ObjectInfo object) { + String type = rule.ruleType(); + if ("ALL".equals(type)) { + return "1 = 1"; + } + String column = rule.ruleColumn(); + if (column == null || column.isBlank()) { + column = switch (type) { + case "MY_DEPT", "DEPT" -> "DEPT_CODE"; + case "SELF", "EMP_NO" -> "OWNER_EMP_NO"; + case "TAG" -> "TECH_TAG"; + default -> null; + }; + } + if (column == null || !IDENTIFIER.matcher(column).matches() || !hasColumn(object, column)) { + return null; + } + return switch (type) { + case "MY_DEPT" -> identity.deptCode() == null ? null : column + " = " + quote(identity.deptCode()); + case "SELF" -> identity.employeeNo() == null ? null : column + " = " + quote(identity.employeeNo()); + case "DEPT", "EMP_NO", "=" -> rule.ruleValue() == null ? null + : "TO_CHAR(" + column + ") = " + quote(rule.ruleValue()); + case "!=", "<>" -> rule.ruleValue() == null ? null + : "TO_CHAR(" + column + ") <> " + quote(rule.ruleValue()); + case "TAG" -> rule.ruleValue() == null || !rule.ruleValue().trim().matches("[A-Za-z0-9_-]+") ? null + : "REGEXP_LIKE(UPPER(" + column + "), " + + quote("(^|,)" + rule.ruleValue().trim().toUpperCase(Locale.ROOT) + "(,|$)") + ")"; + default -> null; + }; + } + + private boolean hasColumn(ObjectInfo object, String column) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM all_tab_columns WHERE owner = ? AND table_name = ? AND column_name = ?", + Integer.class, object.owner(), object.objectName(), column); + return count != null && count > 0; + } + + private String excludedColumns( + String applicationObject, + List permissions, + long objectId + ) { + Set visible = new LinkedHashSet<>(); + permissions.stream() + .filter(permission -> "ALLOW".equals(permission.effect())) + .forEach(permission -> visible.addAll(permission.visibleColumns())); + return sensitiveColumns(objectId).stream() + // VECTOR_DISTANCE needs the VECTOR column while DDS evaluates the + // search. The service deliberately omits EMBEDDING from its response, + // but excluding a VECTOR column from DATA GRANT makes the predicate + // invalid at runtime (ORA-52561). Keep it available to the engine and + // enforce response shaping in DdsVectorKnowledgeService instead. + .filter(column -> !("CB_VECTOR_SEARCH_DOCUMENTS".equals(applicationObject) + && "EMBEDDING".equals(column))) + .filter(column -> !visible.contains(column)) + .reduce((left, right) -> left + ", " + right) + .orElse(""); + } + + private String createGrantSql( + String grantName, + String ddsObject, + String predicate, + String excludedColumns, + String dataRole + ) { + String select = excludedColumns == null || excludedColumns.isBlank() + ? "AS SELECT" + : "AS SELECT (ALL COLUMNS EXCEPT " + excludedColumns + ")"; + return "CREATE OR REPLACE DATA GRANT ADMIN." + grantName + + " " + select + + " ON " + ddsObject + + " WHERE " + predicate + + " TO " + safeRole(dataRole); + } + + private void dropGrant(String grantName) { + try { + jdbcTemplate.execute("DROP DATA GRANT ADMIN." + grantName); + } catch (DataAccessException ignored) { + // The first publish has no generated grant yet. DDS returns an object + // not found error, which is intentionally harmless here. + } + } + + private DdsGrantPlan unpublishable( + String userKey, + DdsProperties.User user, + String target, + String grantName, + String note + ) { + return new DdsGrantPlan( + userKey, valueOrDefault(user.label(), userKey), user.applicationUserId(), user.dataRole(), + target, properties.objectMappings().getOrDefault(target, ""), grantName, + "", "", "", false, note); + } + + private String grantName(String userKey, String target) { + if ("CB_VECTOR_SEARCH_DOCUMENTS".equals(target)) { + return "DDS_DEMO_" + userKey.toUpperCase(Locale.ROOT) + "_VECTOR_GRANT"; + } + String candidate = "DDS_" + userKey.toUpperCase(Locale.ROOT) + "_" + target + "_GRANT"; + return candidate.length() <= 120 ? candidate : candidate.substring(0, 120); + } + + private String safeRole(String value) { + String normalized = upper(value); + if (normalized == null || !OBJECT.matcher(normalized).matches()) { + throw new IllegalArgumentException("안전하지 않은 DDS DATA ROLE 이름입니다: " + value); + } + return normalized; + } + + private String quote(String value) { + return "'" + value.replace("'", "''") + "'"; + } + + private String normalizeEffect(String value) { + return "DENY".equalsIgnoreCase(value) ? "DENY" : "ALLOW"; + } + + private String upper(String value) { + return value == null ? null : value.trim().toUpperCase(Locale.ROOT); + } + + private String upperNullable(String value) { + return value == null || value.isBlank() ? null : upper(value); + } + + private String valueOrDefault(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value; + } + + private record AppIdentity(long userId, String employeeNo, String deptCode, boolean active) { + } + + private record ObjectInfo(long objectId, String owner, String objectName) { + } + + private record Rule(String ruleColumn, String ruleType, String ruleValue) { + } + + private record RawPermissionRow( + long permissionId, + String targetName, + String permissionEffect, + String ruleColumn, + String ruleType, + String ruleValue + ) { + } + + private record PermissionEntry( + long permissionId, + String targetName, + String effect, + List rules, + List visibleColumns + ) { + } + + private record PredicateBuild(String allow, String expression, List warnings) { + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProvisionController.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProvisionController.java new file mode 100644 index 0000000..f46b1e6 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsProvisionController.java @@ -0,0 +1,52 @@ +package com.cloudhandson.ddsbackoffice.web; + +import com.cloudhandson.ddsbackoffice.domain.DdsProvisioningPlan; +import com.cloudhandson.ddsbackoffice.service.DdsGrantPublisher; +import org.springframework.dao.DataAccessException; +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.servlet.mvc.support.RedirectAttributes; + +@Controller +public class DdsProvisionController { + + private final DdsGrantPublisher publisher; + + public DdsProvisionController(DdsGrantPublisher publisher) { + this.publisher = publisher; + } + + @GetMapping("/dds-provision") + public String page(Model model) { + loadPlan(model); + return "dds-provision"; + } + + @PostMapping("/dds-provision/publish") + public String publish(RedirectAttributes redirectAttributes) { + try { + DdsProvisioningPlan plan = publisher.publish(); + redirectAttributes.addFlashAttribute( + "successMessage", + plan.publishableCount() + "개 DDS DATA GRANT를 게시했습니다. 권한 없는 대상의 기존 Grant는 회수했습니다." + ); + if (plan.hasWarnings()) { + redirectAttributes.addFlashAttribute("warningMessage", String.join(" / ", plan.warnings())); + } + } catch (DataAccessException | IllegalArgumentException exception) { + redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage()); + } + return "redirect:/dds-provision"; + } + + private void loadPlan(Model model) { + try { + model.addAttribute("plan", publisher.preview()); + } catch (DataAccessException | IllegalArgumentException exception) { + model.addAttribute("plan", new DdsProvisioningPlan(java.util.List.of(), + java.util.List.of(exception.getMessage()), 0)); + } + } +} diff --git a/dds-backoffice/src/main/resources/application.yml b/dds-backoffice/src/main/resources/application.yml index 3a2227b..8236c97 100644 --- a/dds-backoffice/src/main/resources/application.yml +++ b/dds-backoffice/src/main/resources/application.yml @@ -61,24 +61,35 @@ dds: pg-object: ${DDS_BACKOFFICE_PG_OBJECT:ADMIN.V_DDS_CUSTOMERS_PG} my-object: ${DDS_BACKOFFICE_MY_OBJECT:ADMIN.V_DDS_CUSTOMERS_MY} vector-object: ${DDS_BACKOFFICE_VECTOR_OBJECT:ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS} + object-mappings: + CB_VECTOR_SEARCH_DOCUMENTS: ${DDS_BACKOFFICE_VECTOR_OBJECT:ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS} + CB_V_SEARCH_DOCUMENTS: ${DDS_BACKOFFICE_DOCUMENT_OBJECT:ADMIN.CB_DDS_V_SEARCH_DOCUMENTS} users: my: label: MY 전용 사용자 description: MySQL 원본만 허용하는 DATA ROLE username: ${DDS_BACKOFFICE_MY_USERNAME:dds_demo_my} password: ${DDS_BACKOFFICE_MY_PASSWORD:${DDSUSER_MY_PASSWORD:}} + application-user-id: ${DDS_BACKOFFICE_MY_APPLICATION_USER_ID:101} + data-role: ${DDS_BACKOFFICE_MY_DATA_ROLE:dds_demo_my_role} pg: label: PG 전용 사용자 description: PostgreSQL 원본만 허용하는 DATA ROLE username: ${DDS_BACKOFFICE_PG_USERNAME:dds_demo_pg} password: ${DDS_BACKOFFICE_PG_PASSWORD:${DDSUSER_PG_PASSWORD:}} + application-user-id: ${DDS_BACKOFFICE_PG_APPLICATION_USER_ID:102} + data-role: ${DDS_BACKOFFICE_PG_DATA_ROLE:dds_demo_pg_role} both: label: 통합 사용자 description: 두 원본을 모두 허용하는 DATA ROLE username: ${DDS_BACKOFFICE_BOTH_USERNAME:dds_demo_both} password: ${DDS_BACKOFFICE_BOTH_PASSWORD:${DDSUSER_BOTH_PASSWORD:}} + application-user-id: ${DDS_BACKOFFICE_BOTH_APPLICATION_USER_ID:103} + data-role: ${DDS_BACKOFFICE_BOTH_DATA_ROLE:dds_demo_both_role} none: label: 차단 사용자 description: 접속만 가능하고 DATA GRANT가 없는 사용자 username: ${DDS_BACKOFFICE_NONE_USERNAME:dds_demo_none} password: ${DDS_BACKOFFICE_NONE_PASSWORD:${DDSUSER_NONE_PASSWORD:}} + application-user-id: ${DDS_BACKOFFICE_NONE_APPLICATION_USER_ID:0} + data-role: ${DDS_BACKOFFICE_NONE_DATA_ROLE:dds_demo_none_role} diff --git a/dds-backoffice/src/main/resources/templates/dds-home.html b/dds-backoffice/src/main/resources/templates/dds-home.html index 574edbc..8608d8f 100644 --- a/dds-backoffice/src/main/resources/templates/dds-home.html +++ b/dds-backoffice/src/main/resources/templates/dds-home.html @@ -7,7 +7,7 @@
DEEP DATA SECURITY · INDEPENDENT TRACK

같은 권한 기준을 DDS 방식으로 적용하고 확인합니다.

-

권한 설계 → DDS 보호 연결 → END USER 검증 → 지식 검색 결과 확인

+

권한 설계 → DDS 보호 연결 → 권한 반영 → END USER 검증 → 지식 검색 결과 확인

이 인스턴스의 역할 보기

8083 DDS 인스턴스는 8082 VPD 인스턴스와 별도로 실행됩니다. 두 화면은 사용자·그룹·역할·권한 규칙을 같은 관리 흐름으로 보여주지만, DDS는 END USER·DATA ROLE·DATA GRANT를 통해 데이터를 보호합니다.

@@ -20,8 +20,9 @@
1

1. 권한 설계

사용자·그룹·역할에 객체와 TAG 규칙을 연결합니다.

권한 규칙 만들기 →
2

2. DDS 보호 연결

공통 규칙을 DDS 전용 VIEW와 DATA GRANT에 연결합니다.

DDS 보호 객체 확인 →
- 3

3. END USER 검증

실제 DDS END USER로 접속해 허용·차단 결과를 확인합니다.

직접 조회 실행 →
- 4

4. 지식 검색

청크·임베딩·태그와 DDS 권한을 결합한 검색 시나리오를 확인합니다.

권한 기반 검색 →
+ 3

3. DDS 권한 반영

그룹 상속을 포함한 유효 권한을 DDS Grant로 게시합니다.

Grant 미리보기·게시 →
+ 4

4. END USER 검증

실제 DDS END USER로 접속해 허용·차단 결과를 확인합니다.

직접 조회 실행 →
+ 5

5. 지식 검색

청크·임베딩·태그와 DDS 권한을 결합한 검색 시나리오를 확인합니다.

권한 기반 검색 →
diff --git a/dds-backoffice/src/main/resources/templates/dds-provision.html b/dds-backoffice/src/main/resources/templates/dds-provision.html new file mode 100644 index 0000000..b1f90a1 --- /dev/null +++ b/dds-backoffice/src/main/resources/templates/dds-provision.html @@ -0,0 +1,99 @@ + + + + + +
+
+ DDS · PUBLISH +

애플리케이션 권한을 DDS에 반영

+

사용자·그룹·역할·테이블 권한을 계산한 뒤 DDS DATA GRANT로 게시합니다.

+
+ VPD와 DDS의 반영 차이 보기 +

VPD는 요청 때마다 권한 테이블을 읽습니다. DDS는 같은 권한체계를 배포 시점에 DATA GRANT로 컴파일합니다. 그래서 권한 저장 후 이 화면에서 변경 내용을 미리 확인하고 게시해야 합니다.

+

그룹 자체를 DDS 그룹으로 복사하지는 않습니다. 애플리케이션 그룹의 역할 상속을 계산해 매핑된 DDS DATA ROLE에 하나의 최종 predicate로 합칩니다.

+
+
+ +
+ +
+
+ VPD 권한과 DDS 객체의 매핑 기준 +
+ + + + + + + + +
공통 권한 관리DDS에서의 표현반영 시점
애플리케이션 사용자매핑된 DDS END USER + DATA ROLE게시할 때 매핑 확인
그룹과 그룹에 연결된 역할그룹을 복사하지 않고 최종 predicate로 합산미리보기·게시 때 계산
테이블·VIEW SELECT 권한보호 객체별 DATA GRANT ... WHERE ...권한 변경 후 게시
원문 표시 허용 컬럼AS SELECT 또는 ALL COLUMNS EXCEPT권한 변경 후 게시
+
+

VPD는 요청마다 권한 테이블을 평가하지만, DDS는 게시된 Grant가 바뀔 때까지 이전 선언을 계속 사용합니다. 이 화면의 게시 버튼이 두 모델을 동기화하는 경계입니다.

+
+
+ +
+
+
+ +
+
+
+ 1 · COMPILE +

현재 유효 권한 미리보기

+

직접 부여된 역할과 활성 그룹에서 상속된 역할을 합쳐 DDS Grant를 만듭니다.

+
+ 0명 매핑 +
+
+ 게시할 수 있는 ALLOW 권한이 없습니다. 보호 객체 매핑과 권한 규칙을 먼저 확인하세요. +
+
+ + +
+
+ + + + + + + + + + + + + +
애플리케이션 주체애플리케이션 객체DDS 주체/객체판정predicate컬럼 제외
사용자mappingCB_VECTOR_SEARCH_DOCUMENTSADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTSGRANT + 게시 가능 + 검토 필요 + 메모 + predicate없음
매핑된 유효 권한에서 DDS Grant 대상을 찾지 못했습니다.
+
+
+ +
+

반영 전 확인할 항목

지원되지 않는 객체나 매핑 누락은 게시하지 않고 경고로 남깁니다. DDS 객체 매핑이 없는 기존 Grant도 이 화면에서는 건드리지 않습니다.

+
    +
  • 경고
  • +
+
+ +
+
+ 게시되는 SQL 보기 +
+ GRANT +
CREATE OR REPLACE DATA GRANT ...
+
+
+
+
+ + diff --git a/dds-backoffice/src/main/resources/templates/fragments/layout.html b/dds-backoffice/src/main/resources/templates/fragments/layout.html index ee09eb0..ef77307 100644 --- a/dds-backoffice/src/main/resources/templates/fragments/layout.html +++ b/dds-backoffice/src/main/resources/templates/fragments/layout.html @@ -33,6 +33,7 @@
DB 보호 연결 + DDS 권한 반영 검증 세션 발급 권한 결과 확인 DDS 직접 조회 diff --git a/dds-backoffice/src/main/resources/templates/vector-knowledge.html b/dds-backoffice/src/main/resources/templates/vector-knowledge.html index 3475401..f06e952 100644 --- a/dds-backoffice/src/main/resources/templates/vector-knowledge.html +++ b/dds-backoffice/src/main/resources/templates/vector-knowledge.html @@ -74,7 +74,10 @@

권한 규칙은 한 곳에서 관리합니다.

사용자·그룹·역할·TAG 규칙은 VPD와 같은 권한 관리 화면에서 작성합니다.

- 권한 관리 열기 +

관리 관점

역할에 ALLOW TAG를 여러 개 등록하면 태그 중 하나라도 맞는 청크를 허용합니다. DENY TAG는 허용 후보에서 제외합니다.

diff --git a/dds-backoffice/src/main/resources/templates/vpd-policies.html b/dds-backoffice/src/main/resources/templates/vpd-policies.html index 8103304..303122a 100644 --- a/dds-backoffice/src/main/resources/templates/vpd-policies.html +++ b/dds-backoffice/src/main/resources/templates/vpd-policies.html @@ -27,7 +27,10 @@

VPD와 동일한 권한 관리 대상을 사용합니다.

사용자·그룹·역할·행 규칙·TAG 규칙은 한 권한 화면에서 관리합니다.

- 권한 규칙 열기 +
사용자·그룹·역할 diff --git a/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/config/DdsPropertiesTest.java b/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/config/DdsPropertiesTest.java index 02e501f..2a1aa0a 100644 --- a/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/config/DdsPropertiesTest.java +++ b/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/config/DdsPropertiesTest.java @@ -7,6 +7,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.Duration; import java.util.Map; import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; class DdsPropertiesTest { @@ -33,4 +36,24 @@ class DdsPropertiesTest { "jdbc:test", Duration.ofSeconds(5), "ADMIN.VIEW;DROP", "ADMIN.VIEW", Map.of() )); } + + @Test + void bindsApplicationUserAndDataRoleForDdsProvisioning() { + var source = new MapConfigurationPropertySource(Map.of( + "dds.users.my.label", "MY", + "dds.users.my.description", "source", + "dds.users.my.username", "ddsuser_my", + "dds.users.my.password", "secret", + "dds.users.my.application-user-id", "101", + "dds.users.my.data-role", "dds_demo_my_role" + )); + + var properties = new Binder(source) + .bind("dds", Bindable.of(DdsProperties.class)) + .orElseThrow(() -> new AssertionError("dds properties did not bind")); + + assertEquals(101L, properties.users().get("my").applicationUserId()); + assertEquals("dds_demo_my_role", properties.users().get("my").dataRole()); + assertTrue(properties.users().get("my").mapped()); + } } diff --git a/scripts/deploy-dds-backoffice-local.sh b/scripts/deploy-dds-backoffice-local.sh index 8c2adee..3691e14 100755 --- a/scripts/deploy-dds-backoffice-local.sh +++ b/scripts/deploy-dds-backoffice-local.sh @@ -114,7 +114,7 @@ for _ in {1..30}; do done "$APP_DIR/status.sh" curl -sS -o /tmp/dds-backoffice-login.html -w 'login=%{http_code}\n' "http://127.0.0.1:${PORT}/login" -for path in / /users /groups /roles /permissions /effective-matrix /vpd-policies /vector-knowledge /dds; do +for path in / /users /groups /roles /permissions /effective-matrix /vpd-policies /dds-provision /vector-knowledge /dds; do curl -sS --connect-timeout 3 --max-time 20 -u "${BACKOFFICE_ADMIN_USER:-}:${BACKOFFICE_ADMIN_PASSWORD:-}" \ -o "/tmp/dds-backoffice-check-${path#/}.html" \ -w "${path}=%{http_code}\n" "http://127.0.0.1:${PORT}${path}" || true diff --git a/sql/adb/32_dds_vector_tag_setup.sql b/sql/adb/32_dds_vector_tag_setup.sql index e571f6e..50e3754 100644 --- a/sql/adb/32_dds_vector_tag_setup.sql +++ b/sql/adb/32_dds_vector_tag_setup.sql @@ -42,6 +42,21 @@ PROMPT === 2. Replacing DATA GRANTs for the four demo identities === -- The tag expression is deliberately an OR expression. Adding another -- allowed tag means replacing the corresponding grant expression, not -- writing a second conflicting policy. +BEGIN + EXECUTE IMMEDIATE 'DROP DATA GRANT admin.dds_demo_my_vector_grant'; +EXCEPTION WHEN OTHERS THEN NULL; +END; +/ +BEGIN + EXECUTE IMMEDIATE 'DROP DATA GRANT admin.dds_demo_pg_vector_grant'; +EXCEPTION WHEN OTHERS THEN NULL; +END; +/ +BEGIN + EXECUTE IMMEDIATE 'DROP DATA GRANT admin.dds_demo_both_vector_grant'; +EXCEPTION WHEN OTHERS THEN NULL; +END; +/ CREATE OR REPLACE DATA GRANT admin.dds_demo_my_vector_grant AS SELECT ON admin.cb_dds_vector_search_documents @@ -62,7 +77,71 @@ CREATE OR REPLACE DATA GRANT admin.dds_demo_both_vector_grant -- dds_demo_none_role intentionally receives no grant (default deny). -PROMPT === 3. Vector object inventory === +PROMPT === 3. Registering the vector object in the common application permission model === +-- The application permission tables remain the source of truth. These rows +-- make the standalone DDS example use the same role/TAG scenario as VPD: +-- one ALLOW permission per role and multiple TAG rules combined with OR. +-- The backoffice's DDS publish screen compiles these rows into the grants +-- above (and excludes the protected EMBEDDING column by default). +DECLARE + PROCEDURE ensure_vector_permission( + p_role_id IN NUMBER, + p_tag_list IN SYS.ODCIVARCHAR2LIST + ) IS + v_perm_id NUMBER; + v_rule_id NUMBER; + v_tag VARCHAR2(100); + BEGIN + BEGIN + SELECT perm_id + INTO v_perm_id + FROM cb_permission + WHERE role_id = p_role_id + AND target_name = 'CB_VECTOR_SEARCH_DOCUMENTS' + AND action_name = 'SELECT' + AND permission_effect = 'ALLOW' + AND ROWNUM = 1; + EXCEPTION + WHEN NO_DATA_FOUND THEN + SELECT NVL(MAX(perm_id), 0) + 1 INTO v_perm_id FROM cb_permission; + INSERT INTO cb_permission ( + perm_id, role_id, target_name, action_name, permission_effect + ) VALUES ( + v_perm_id, p_role_id, 'CB_VECTOR_SEARCH_DOCUMENTS', 'SELECT', 'ALLOW' + ); + END; + + FOR i IN 1 .. p_tag_list.COUNT LOOP + v_tag := UPPER(TRIM(p_tag_list(i))); + BEGIN + SELECT rule_id + INTO v_rule_id + FROM cb_permission_rule + WHERE perm_id = v_perm_id + AND rule_column = 'TECH_TAG' + AND rule_type = 'TAG' + AND rule_value = v_tag + AND ROWNUM = 1; + EXCEPTION + WHEN NO_DATA_FOUND THEN + SELECT NVL(MAX(rule_id), 0) + 1 INTO v_rule_id FROM cb_permission_rule; + INSERT INTO cb_permission_rule ( + rule_id, perm_id, rule_column, rule_type, rule_value + ) VALUES ( + v_rule_id, v_perm_id, 'TECH_TAG', 'TAG', v_tag + ); + END; + END LOOP; + END; +BEGIN + ensure_vector_permission(10, SYS.ODCIVARCHAR2LIST('SPRING_BOOT', 'ORDS')); + ensure_vector_permission(20, SYS.ODCIVARCHAR2LIST('ORACLE_VPD', 'ORACLE_DDS')); + ensure_vector_permission(30, SYS.ODCIVARCHAR2LIST( + 'SPRING_BOOT', 'ORDS', 'ORACLE_VPD', 'ORACLE_DDS', 'MCP')); +END; +/ + +PROMPT === 4. Vector object inventory === SELECT 'DDS_VECTOR_OBJECT' AS item, 'ADMIN.CB_DDS_VECTOR_SEARCH_DOCUMENTS' AS value FROM dual;