fix #557: guide permission-driven VPD flow

This commit is contained in:
devmrko
2026-06-29 12:11:25 +09:00
parent fbe3d4682b
commit 908ac9a386
57 changed files with 1952 additions and 312 deletions

View File

@@ -114,7 +114,8 @@ public class BackofficeSchemaService {
owner VARCHAR2(128) NOT NULL,
object_name VARCHAR2(128) NOT NULL UNIQUE,
ords_path VARCHAR2(300) NOT NULL,
enabled_yn CHAR(1) DEFAULT 'Y' CHECK (enabled_yn IN ('Y','N')) NOT NULL
enabled_yn CHAR(1) DEFAULT 'Y' CHECK (enabled_yn IN ('Y','N')) NOT NULL,
description VARCHAR2(200)
)
"""),
new TableDefinition("CB_PROTECTED_COLUMN", """
@@ -148,6 +149,25 @@ public class BackofficeSchemaService {
setting_value VARCHAR2(1000),
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL
)
"""),
new TableDefinition("CB_VPD_POLICY_NOTE", """
CREATE TABLE cb_vpd_policy_note (
object_owner VARCHAR2(128) NOT NULL,
object_name VARCHAR2(128) NOT NULL,
policy_name VARCHAR2(128) NOT NULL,
description VARCHAR2(500) NOT NULL,
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT cb_vpd_policy_note_pk PRIMARY KEY (object_owner, object_name, policy_name)
)
"""),
new TableDefinition("CB_VPD_FILTER_NOTE", """
CREATE TABLE cb_vpd_filter_note (
function_owner VARCHAR2(128) NOT NULL,
function_name VARCHAR2(128) NOT NULL,
description VARCHAR2(500) NOT NULL,
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT cb_vpd_filter_note_pk PRIMARY KEY (function_owner, function_name)
)
""")
);
@@ -160,6 +180,8 @@ public class BackofficeSchemaService {
"ALTER TABLE cb_permission_rule ADD (rule_column VARCHAR2(128))"),
new ColumnDefinition("CB_AGENT_BEARER_KEY", "DESCRIPTION",
"ALTER TABLE cb_agent_bearer_key ADD (description VARCHAR2(200))"),
new ColumnDefinition("CB_PROTECTED_OBJECT", "DESCRIPTION",
"ALTER TABLE cb_protected_object ADD (description VARCHAR2(200))"),
new ColumnDefinition("CB_PROTECTED_COLUMN", "SENSITIVITY_LEVEL",
"ALTER TABLE cb_protected_column ADD (sensitivity_level VARCHAR2(20) DEFAULT 'PUBLIC' NOT NULL)"),
new ColumnDefinition("CB_PROTECTED_COLUMN", "REDACTION_METHOD",

View File

@@ -14,6 +14,7 @@ import com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper;
import com.cloudhandson.vpdbackoffice.mapper.GroupMapper;
import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
@@ -59,11 +60,15 @@ public class BearerTokenService {
}
public List<BearerTokenRecord> findAll() {
return tokenMapper.findAll();
return findAll(false);
}
public List<BearerTokenRecord> findAll(boolean includeInactive) {
return tokenMapper.findAll(includeInactive ? 1 : 0);
}
public List<TokenContextView> findTokenContextOptions() {
List<BearerTokenRecord> tokens = tokenMapper.findAll();
List<BearerTokenRecord> tokens = findAll(true);
List<GroupUserView> groupUsers = groupMapper.findGroupUsers();
Map<Long, List<String>> directRolesByUser = directRolesByUser();
Map<Long, List<String>> groupsByUser = groupsByUser(groupUsers);
@@ -196,6 +201,13 @@ public class BearerTokenService {
return new IssuedToken(keyId, prefix, plainToken, command.expiresAt());
}
@Transactional
public IssuedToken issueTemporaryToken(long userId, String purpose) {
OffsetDateTime expiresAt = OffsetDateTime.now(clock).plus(Duration.ofMinutes(10));
return issueToken(new TokenIssueCommand(userId, expiresAt,
purpose == null || purpose.isBlank() ? "임시 검증 토큰" : purpose));
}
@Transactional
public void revokeToken(long keyId, String reason) {
int updated = tokenMapper.revokeToken(

View File

@@ -6,10 +6,11 @@ import com.cloudhandson.vpdbackoffice.domain.mcp.McpToolView;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeResult;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeStatus;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import org.springframework.stereotype.Service;
@Service
@@ -17,20 +18,17 @@ public class McpReasoningService {
private static final int MAX_EVIDENCE_ROWS = 20;
private final ProtectedObjectService protectedObjectService;
private final OrdsProbeService ordsProbeService;
private final McpToolRegistry toolRegistry;
private final OpenAiCompatibleClient aiClient;
private final ObjectMapper objectMapper;
public McpReasoningService(
ProtectedObjectService protectedObjectService,
OrdsProbeService ordsProbeService,
McpToolRegistry toolRegistry,
OpenAiCompatibleClient aiClient,
ObjectMapper objectMapper
) {
this.protectedObjectService = protectedObjectService;
this.ordsProbeService = ordsProbeService;
this.toolRegistry = toolRegistry;
this.aiClient = aiClient;
@@ -38,11 +36,10 @@ public class McpReasoningService {
}
public McpReasoningResult reason(McpReasoningCommand command) {
ProtectedObject object = protectedObjectService.assertEnabled(command.objectId());
McpToolView tool = toolRegistry.toolFor(object);
McpToolView tool = selectTool(command.question());
ProbeResult probeResult = ordsProbeService.runProbe(new ProbeCommand(
command.tokenKeyId(),
command.objectId(),
tool.objectId(),
command.bearerToken(),
normalizeLimit(command.limit())
));
@@ -75,6 +72,28 @@ public class McpReasoningService {
}
}
private McpToolView selectTool(String question) {
List<McpToolView> tools = toolRegistry.listTools();
if (tools.isEmpty()) {
throw new AppException("사용 가능한 MCP tool이 없습니다. ORDS 조회 대상을 먼저 등록하세요.");
}
String normalized = question == null ? "" : question.toLowerCase(Locale.ROOT);
return tools.stream()
.max(Comparator.comparingInt(tool -> score(tool, normalized)))
.orElseThrow();
}
private int score(McpToolView tool, String question) {
int score = 0;
for (String token : (tool.name() + " " + tool.displayName() + " " + tool.description())
.toLowerCase(Locale.ROOT).split("[^a-z0-9가-힣]+")) {
if (token.length() >= 2 && question.contains(token)) {
score += 1;
}
}
return score;
}
private int normalizeLimit(int limit) {
if (limit < 1) {
return 50;

View File

@@ -73,14 +73,14 @@ public class McpSseService {
ObjectNode item = objectMapper.createObjectNode();
item.put("name", tool.name());
item.put("description", tool.description());
item.set("inputSchema", inputSchema());
item.set("inputSchema", inputSchema(tool));
tools.add(item);
}
result.set("tools", tools);
return result;
}
private ObjectNode inputSchema() {
private ObjectNode inputSchema(McpToolView tool) {
ObjectNode schema = objectMapper.createObjectNode();
schema.put("type", "object");
ObjectNode properties = objectMapper.createObjectNode();
@@ -100,6 +100,17 @@ public class McpSseService {
schema.set("properties", properties);
ArrayNode required = objectMapper.createArrayNode();
required.add("bearerToken");
if (isVectorTool(tool)) {
ObjectNode embedding = objectMapper.createObjectNode();
embedding.put("type", "array");
embedding.put("description", "외부 임베딩 모델이 만든 검색 벡터. 이 예제 fixture는 4차원입니다.");
ObjectNode items = objectMapper.createObjectNode();
items.put("type", "number");
embedding.set("items", items);
embedding.put("minItems", 1);
properties.set("embedding", embedding);
required.add("embedding");
}
schema.set("required", required);
schema.put("additionalProperties", false);
return schema;
@@ -111,7 +122,18 @@ public class McpSseService {
McpToolView tool = findTool(toolName);
String bearerToken = arguments.path("bearerToken").asText("");
int limit = normalizeLimit(arguments.path("limit").asInt(50));
ProbeResult probeResult = ordsProbeService.runProbe(new ProbeCommand(null, tool.objectId(), bearerToken, limit));
String requestBody = null;
if (isVectorTool(tool)) {
JsonNode embedding = arguments.get("embedding");
if (embedding == null || !embedding.isArray() || embedding.isEmpty()) {
throw new AppException("벡터 검색 tool에는 embedding 배열이 필요합니다.");
}
ObjectNode body = objectMapper.createObjectNode();
body.set("embedding", embedding);
requestBody = body.toString();
}
ProbeResult probeResult = ordsProbeService.runProbe(
new ProbeCommand(null, tool.objectId(), bearerToken, limit, requestBody));
ObjectNode payload = objectMapper.createObjectNode();
payload.put("toolName", tool.name());
@@ -149,6 +171,10 @@ public class McpSseService {
.orElseThrow(() -> new AppException("MCP tool을 찾을 수 없습니다: " + toolName));
}
private boolean isVectorTool(McpToolView tool) {
return tool != null && tool.displayName().toUpperCase().endsWith("CB_VECTOR_SEARCH_DOCUMENTS");
}
private int normalizeLimit(int limit) {
if (limit < 1) {
return 50;

View File

@@ -27,9 +27,13 @@ public class McpToolRegistry {
private McpToolView toTool(ProtectedObject object) {
String name = "ords.query." + safeName(object.owner()) + "." + safeName(object.objectName());
String vectorHint = "CB_VECTOR_SEARCH_DOCUMENTS".equalsIgnoreCase(object.objectName())
? " embedding[]을 입력으로 받는 전용 벡터 검색 Handler입니다."
: "";
return new McpToolView(
name,
object.displayName() + " 보호 객체를 Bearer Token으로 ORDS 호출해 VPD/Redaction 적용 결과를 조회합니다.",
object.descriptionOrDefault() + " · "
+ object.displayName() + "을 Bearer Token으로 ORDS 호출해 VPD/표시 보호 결과를 조회합니다." + vectorHint,
object.objectId(),
object.displayName(),
object.ordsPath()

View File

@@ -20,6 +20,8 @@ import org.springframework.transaction.annotation.Transactional;
@Service
public class OrdsMetadataService {
private static final String VECTOR_SEARCH_OBJECT = "CB_VECTOR_SEARCH_DOCUMENTS";
private final OrdsMetadataMapper mapper;
private final JdbcTemplate jdbcTemplate;
private final JdbcTemplate ordsMetadataJdbcTemplate;
@@ -77,6 +79,7 @@ public class OrdsMetadataService {
public String objectQueryHandlerSource(long objectId) {
ProtectedObject object = protectedObjectService.assertEnabled(objectId);
rejectGenericVectorHandler(object);
List<String> columns = protectedObjectService.findColumns(objectId).stream()
.map(ProtectedColumn::columnName)
.toList();
@@ -138,6 +141,7 @@ public class OrdsMetadataService {
@Transactional
public OrdsObjectHandlerResult createObjectQueryHandler(long objectId) {
ProtectedObject object = protectedObjectService.assertEnabled(objectId);
rejectGenericVectorHandler(object);
List<String> columns = protectedObjectService.findColumns(objectId).stream()
.map(ProtectedColumn::columnName)
.toList();
@@ -263,6 +267,14 @@ public class OrdsMetadataService {
""".formatted(selectColumns, object.owner(), object.objectName());
}
private void rejectGenericVectorHandler(ProtectedObject object) {
if (VECTOR_SEARCH_OBJECT.equalsIgnoreCase(object.objectName())) {
throw new AppException(
"벡터 검색 객체는 일반 Handler로 등록할 수 없습니다. "
+ "29_agent_ords_vector_search_ords.sql의 전용 Handler를 사용하세요(EMBEDDING 미노출).");
}
}
private String handlerSourceTypeCode(String sourceType) {
String normalized = sourceType == null ? "" : sourceType.toLowerCase(Locale.ROOT);
if (normalized.contains("query")) {

View File

@@ -23,6 +23,7 @@ import java.util.Set;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpStatusCodeException;
@@ -114,10 +115,14 @@ public class OrdsProbeService {
URI uri = buildUri(ordsBaseUrl, object.ordsPath(), command.limit());
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(command.bearerToken());
headers.setContentType(MediaType.APPLICATION_JSON);
String requestBody = command.requestBody() == null || command.requestBody().isBlank()
? "{}"
: command.requestBody().trim();
requestHeaders = prettyHeaders(maskedRequestHeaders(headers));
requestPayload = prettyJson("{}");
requestPayload = prettyJson(requestBody);
ResponseEntity<String> response = ordsRestTemplate.exchange(
uri, HttpMethod.POST, new HttpEntity<>(headers), String.class);
uri, HttpMethod.POST, new HttpEntity<>(requestBody, headers), String.class);
ProbeResult result = parseSuccess(
response.getBody(),
object.objectId(),

View File

@@ -19,9 +19,12 @@ import org.springframework.transaction.annotation.Transactional;
@Service
public class PermissionService {
private static final Set<String> RULE_TYPES = Set.of("ALL", "=", "!=", "MY_DEPT", "SELF", "DEPT", "EMP_NO");
private static final Set<String> VALUE_REQUIRED_RULE_TYPES = Set.of("=", "!=", "DEPT", "EMP_NO");
private static final Set<String> DEFAULT_COLUMN_RULE_TYPES = Set.of("MY_DEPT", "SELF", "DEPT", "EMP_NO");
private static final Set<String> RULE_TYPES = Set.of(
"ALL", "=", "!=", "MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG");
private static final Set<String> VALUE_REQUIRED_RULE_TYPES = Set.of(
"=", "!=", "DEPT", "EMP_NO", "TAG");
private static final Set<String> DEFAULT_COLUMN_RULE_TYPES = Set.of(
"MY_DEPT", "SELF", "DEPT", "EMP_NO", "TAG");
private static final Set<String> PERMISSION_EFFECTS = Set.of("ALLOW", "DENY");
private static final Set<String> SENSITIVITY_LEVELS = Set.of(
"PUBLIC", "INTERNAL", "CONFIDENTIAL", "RESTRICTED");
@@ -124,12 +127,13 @@ public class PermissionService {
permissionMapper.deleteRules(permissionId);
for (RuleCommand rule : command.rules()) {
String type = normalize(rule.ruleType());
permissionMapper.insertRule(new PermissionRule(
permissionMapper.nextRuleId(),
permissionId,
normalizeNullable(rule.ruleColumn()),
normalize(rule.ruleType()),
clean(rule.ruleValue())
type,
normalizeRuleValue(type, rule.ruleValue())
));
}
@@ -191,16 +195,23 @@ public class PermissionService {
if (column != null && !allowedColumns.contains(column)) {
throw new AppException("행 규칙 컬럼은 보호 객체 컬럼이어야 합니다: " + column);
}
if (!seen.add(column + ":" + type + ":" + clean(rule.ruleValue()))) {
String ruleValue = normalizeRuleValue(type, rule.ruleValue());
if (!seen.add(column + ":" + type + ":" + ruleValue)) {
throw new AppException("중복된 행 규칙이 있습니다.");
}
hasAll = hasAll || "ALL".equals(type);
if (!"ALL".equals(type) && column == null && !DEFAULT_COLUMN_RULE_TYPES.contains(type)) {
throw new AppException(type + " 규칙에는 컬럼이 필요합니다.");
}
if (VALUE_REQUIRED_RULE_TYPES.contains(type) && clean(rule.ruleValue()).isBlank()) {
if ("TAG".equals(type) && column == null && !allowedColumns.contains("TECH_TAG")) {
throw new AppException("TAG 규칙의 기본 컬럼 TECH_TAG가 보호 객체에 없습니다. 컬럼을 지정하세요.");
}
if (VALUE_REQUIRED_RULE_TYPES.contains(type) && ruleValue.isBlank()) {
throw new AppException(type + " 규칙에는 값이 필요합니다.");
}
if ("TAG".equals(type) && !ruleValue.matches("[A-Z0-9_-]+")) {
throw new AppException("TAG 값은 영문 대문자, 숫자, '_' 또는 '-'만 사용할 수 있습니다.");
}
}
if (hasAll && rules.size() > 1) {
throw new AppException("ALL 규칙은 다른 규칙과 함께 저장할 수 없습니다.");
@@ -255,6 +266,11 @@ public class PermissionService {
return cleaned.isBlank() ? null : cleaned.toUpperCase(Locale.ROOT);
}
private String normalizeRuleValue(String type, String value) {
String cleaned = clean(value);
return "TAG".equals(type) ? cleaned.toUpperCase(Locale.ROOT) : cleaned;
}
private String clean(String value) {
return value == null ? "" : value.trim();
}

View File

@@ -137,7 +137,8 @@ public class ProtectedObjectService {
normalizedObjectName,
defaultOrdsPath(normalizedOwner, normalizedObjectName),
String.join(",", columns),
""
"",
"DB 객체를 한 개의 ORDS 조회 Handler로 노출하는 예제 대상"
);
long objectId = mapper.nextObjectId();
mapper.insertObject(objectId, command);
@@ -170,13 +171,20 @@ public class ProtectedObjectService {
String objectName = command.objectName().trim().toUpperCase(Locale.ROOT);
String ordsPath = command.ordsPath();
if (ordsPath == null || ordsPath.isBlank()) {
throw new AppException("ORDS Path는 실제 ORDS module/template 경로를 입력해야 합니다.");
ordsPath = defaultOrdsPath(owner, objectName);
}
String columns = command.columns();
if (columns == null || columns.isBlank()) {
columns = String.join(",", mapper.findDatabaseColumns(owner, objectName));
}
return new ProtectedObjectCreateCommand(owner, objectName, ordsPath.trim(), columns, command.sensitiveColumns());
return new ProtectedObjectCreateCommand(
owner,
objectName,
ordsPath.trim(),
columns,
command.sensitiveColumns(),
command.description()
);
}
@Transactional
@@ -192,6 +200,21 @@ public class ProtectedObjectService {
ordsPath.trim()));
}
@Transactional
public void updateDescription(long objectId, String description) {
String normalized = description == null ? "" : description.trim();
if (normalized.length() > 200) {
throw new AppException("조회 대상 설명은 200자 이내여야 합니다.");
}
int updated = mapper.updateDescription(objectId, normalized.isBlank() ? null : normalized);
if (updated == 0) {
throw new AppException("설명을 수정할 조회 대상을 찾을 수 없습니다.");
}
databaseObjectsCache = null;
auditService.record(new AuditEvent("PROTECTED_OBJECT_DESCRIPTION_UPDATED", null, objectId, "SUCCESS", null, null,
normalized));
}
@Transactional
public void updateColumnPolicy(long columnId, String sensitivityLevel, String redactionMethod) {
String normalizedLevel = normalizeOption(sensitivityLevel, "PUBLIC", SENSITIVITY_LEVELS, "민감도 등급");

View File

@@ -20,6 +20,8 @@ import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@@ -32,6 +34,9 @@ public class VpdPolicyService {
private static final long CATALOG_CACHE_MILLIS = 60_000L;
private static final String COMMON_POLICY_NAME = "CB_PERMISSION_SELECT_POLICY";
private static final String DEFAULT_PERMISSION_FILTER_FUNCTION = "CB_AGENT_DOC_VPD_FILTER";
private static final Pattern RETURN_LITERAL = Pattern.compile(
"(?is)\\bRETURN\\s+'((?:''|[^'])*)'\\s*;"
);
private final VpdPolicyMapper mapper;
private final JdbcTemplate jdbcTemplate;
@@ -94,6 +99,10 @@ public class VpdPolicyService {
);
}
public String currentUser() {
return jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class);
}
@Transactional
public void saveFilterFunction(String functionOwnerValue, String functionNameValue, String filterPredicateValue) {
String functionName = requiredIdentifier(functionNameValue, "Function name");
@@ -275,6 +284,75 @@ public class VpdPolicyService {
return new VpdPolicyDetail(policy, buildAddPolicyBlock(policy));
}
public String findPolicyDescription(String objectOwner, String objectName, String policyName) {
String description;
try {
description = mapper.findPolicyDescription(objectOwner, objectName, policyName);
} catch (DataAccessException ignored) {
description = null;
}
return description == null || description.isBlank()
? objectOwner + "." + objectName + "에 요청마다 현재 권한체계의 행 접근 조건을 적용하는 " + policyName + " policy입니다."
: description;
}
public String findFilterDescription(String functionOwner, String functionName) {
String description;
try {
description = mapper.findFilterDescription(functionOwner, functionName);
} catch (DataAccessException ignored) {
description = null;
}
return description == null || description.isBlank()
? (DEFAULT_PERMISSION_FILTER_FUNCTION.equalsIgnoreCase(functionName)
? "사용자·그룹·역할·TAG 권한을 동적으로 합쳐 VPD predicate를 반환합니다."
: "이 Filter function이 반환하는 predicate로 조회 행을 제한합니다.")
: description;
}
/**
* Returns the literal predicate used by a simple standalone Filter function created by this UI.
* Packaged or system-managed functions intentionally return an empty string because their
* source is not a single editable literal.
*/
public String findFilterPredicate(String owner, String packageName, String functionName) {
if (packageName != null && !packageName.isBlank()
|| DEFAULT_PERMISSION_FILTER_FUNCTION.equalsIgnoreCase(functionName)) {
return "";
}
try {
VpdFunctionSource source = findFunctionSource(owner, null, functionName);
if (source.source() == null) {
return "";
}
Matcher matcher = RETURN_LITERAL.matcher(source.source());
return matcher.find() ? matcher.group(1).replace("''", "'") : "";
} catch (DataAccessException | AppException ignored) {
return "";
}
}
@Transactional
public void savePolicyDescription(String objectOwner, String objectName, String policyName, String description) {
String normalized = normalizeDescription(description);
mapper.upsertPolicyDescription(
requiredIdentifier(objectOwner, "Object owner"),
requiredIdentifier(objectName, "Object name"),
requiredIdentifier(policyName, "Policy name"),
normalized
);
}
@Transactional
public void saveFilterDescription(String functionOwner, String functionName, String description) {
String normalized = normalizeDescription(description);
mapper.upsertFilterDescription(
requiredIdentifier(functionOwner, "Function owner"),
requiredIdentifier(functionName, "Function name"),
normalized
);
}
public VpdObjectFilterDetail findObjectFilterDetail(String objectOwner, String objectName) {
String normalizedOwner = requiredIdentifier(objectOwner, "Object owner");
String normalizedObject = requiredIdentifier(objectName, "Object name");
@@ -611,6 +689,17 @@ public class VpdPolicyService {
return String.join(",", statements);
}
private String normalizeDescription(String value) {
String normalized = value == null ? "" : value.trim();
if (normalized.isBlank()) {
throw new AppException("정책/필터 설명은 필수입니다.");
}
if (normalized.length() > 500) {
throw new AppException("정책/필터 설명은 500자 이내여야 합니다.");
}
return normalized;
}
private String escapeSqlLiteral(String value) {
return value.replace("'", "''");
}