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. * * This service is the explicit publish boundary for the direct END USER * comparison path: 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. * * The token-driven vector path is intentionally separate. Its object-level * DATA GRANT calls a definer-rights predicate function that evaluates the * common CB_* tables at query time after CB_AGENT_CTX has been initialized by * the bearer token. */ @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) { } }