461 lines
18 KiB
Java
461 lines
18 KiB
Java
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<DdsGrantPlan> grants = new ArrayList<>();
|
|
List<String> warnings = new ArrayList<>();
|
|
int mappedUsers = 0;
|
|
|
|
for (Map.Entry<String, DdsProperties.User> 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<Long, PermissionEntry> 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<String> 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<PermissionEntry> 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<Long, PermissionEntry> 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<Long, PermissionEntry> result = new LinkedHashMap<>();
|
|
List<RawPermissionRow> 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<String> 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<AppIdentity> 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<ObjectInfo> 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<String> 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<PermissionEntry> permissions,
|
|
AppIdentity identity,
|
|
ObjectInfo object
|
|
) {
|
|
List<String> allow = new ArrayList<>();
|
|
List<String> deny = new ArrayList<>();
|
|
List<String> warnings = new ArrayList<>();
|
|
for (PermissionEntry permission : permissions) {
|
|
List<String> 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<PermissionEntry> permissions,
|
|
long objectId
|
|
) {
|
|
Set<String> 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<Rule> rules,
|
|
List<String> visibleColumns
|
|
) {
|
|
}
|
|
|
|
private record PredicateBuild(String allow, String expression, List<String> warnings) {
|
|
}
|
|
}
|