diff --git a/gui/admin-gui/src/main/java/com/evolveum/midpoint/web/boot/AbstractSpringBootApplication.java b/gui/admin-gui/src/main/java/com/evolveum/midpoint/web/boot/AbstractSpringBootApplication.java index 038673199bf..bf5a1e52352 100644 --- a/gui/admin-gui/src/main/java/com/evolveum/midpoint/web/boot/AbstractSpringBootApplication.java +++ b/gui/admin-gui/src/main/java/com/evolveum/midpoint/web/boot/AbstractSpringBootApplication.java @@ -90,6 +90,7 @@ public FilterRegistrationBean midPointProfilingS FilterRegistrationBean registration = new FilterRegistrationBean<>(); registration.setFilter(new MidPointProfilingServletFilter()); registration.addUrlPatterns("/*"); + registration.setAsyncSupported(true); return registration; } @@ -100,6 +101,7 @@ public FilterRegistrationBean browserWindowIdenti registration.addUrlPatterns("/*"); // Ensure it runs before Wicket filter by giving it a higher precedence (lower order value) registration.setOrder(-100); + registration.setAsyncSupported(true); return registration; } @@ -116,6 +118,7 @@ public FilterRegistrationBean wicket() { registration.addInitParameter(Application.CONFIGURATION, "deployment"); registration.addInitParameter("applicationBean", "midpointApplication"); registration.addInitParameter(WicketFilter.APP_FACT_PARAM, "org.apache.wicket.spring.SpringWebApplicationFactory"); + registration.setAsyncSupported(true); return registration; } @@ -128,6 +131,7 @@ public FilterRegistrationBean springSecurityFilterChain() FilterRegistrationBean registration = new FilterRegistrationBean<>(); registration.setFilter(new DelegatingFilterProxy()); registration.addUrlPatterns("/*"); + registration.setAsyncSupported(true); return registration; } diff --git a/gui/admin-gui/src/main/java/com/evolveum/midpoint/web/boot/MidPointSpringApplication.java b/gui/admin-gui/src/main/java/com/evolveum/midpoint/web/boot/MidPointSpringApplication.java index 67cc070acdd..05d05472abd 100644 --- a/gui/admin-gui/src/main/java/com/evolveum/midpoint/web/boot/MidPointSpringApplication.java +++ b/gui/admin-gui/src/main/java/com/evolveum/midpoint/web/boot/MidPointSpringApplication.java @@ -73,6 +73,7 @@ "classpath:ctx-security-enforcer.xml", "classpath:ctx-model.xml", "classpath:ctx-model-common.xml", + "classpath:ctx-mcp.xml", "classpath:ctx-authentication.xml", "classpath:ctx-report.xml", "classpath:ctx-smart-integration.xml", diff --git a/gui/admin-gui/src/main/java/com/evolveum/midpoint/web/boot/NodeIdHeaderValve.java b/gui/admin-gui/src/main/java/com/evolveum/midpoint/web/boot/NodeIdHeaderValve.java index fdab90c8dbf..5e9c8f3832d 100644 --- a/gui/admin-gui/src/main/java/com/evolveum/midpoint/web/boot/NodeIdHeaderValve.java +++ b/gui/admin-gui/src/main/java/com/evolveum/midpoint/web/boot/NodeIdHeaderValve.java @@ -25,7 +25,9 @@ public class NodeIdHeaderValve extends ValveBase { private TaskManager taskManager; public NodeIdHeaderValve(TaskManager taskManager) { - super(); + // MCP stream transport (SSE) requires Servlet async I/O. + // Tomcat checks async support per valve/filter/servlet chain element. + super(true); this.taskManager = taskManager; } diff --git a/gui/admin-gui/src/main/java/com/evolveum/midpoint/web/boot/TomcatRootValve.java b/gui/admin-gui/src/main/java/com/evolveum/midpoint/web/boot/TomcatRootValve.java index f8c54b4029a..938139bfb6b 100644 --- a/gui/admin-gui/src/main/java/com/evolveum/midpoint/web/boot/TomcatRootValve.java +++ b/gui/admin-gui/src/main/java/com/evolveum/midpoint/web/boot/TomcatRootValve.java @@ -36,7 +36,9 @@ public class TomcatRootValve extends ValveBase { private String servletPath; public TomcatRootValve(String servletPath) { - super(); + // MCP stream transport (SSE) requires Servlet async I/O. + // Tomcat checks async support per valve/filter/servlet chain element. + super(true); this.servletPath = servletPath == null ? "" : servletPath; } diff --git a/gui/admin-gui/src/test/java/com/evolveum/midpoint/gui/test/TestMidPointSpringApplication.java b/gui/admin-gui/src/test/java/com/evolveum/midpoint/gui/test/TestMidPointSpringApplication.java index 0f1328b97b1..9b6747a5dbe 100644 --- a/gui/admin-gui/src/test/java/com/evolveum/midpoint/gui/test/TestMidPointSpringApplication.java +++ b/gui/admin-gui/src/test/java/com/evolveum/midpoint/gui/test/TestMidPointSpringApplication.java @@ -50,6 +50,7 @@ "classpath:ctx-model.xml", "classpath:ctx-model-test.xml", "classpath:ctx-model-common.xml", + "classpath:ctx-mcp.xml", "classpath:ctx-init.xml", "classpath:ctx-authentication.xml", "classpath:ctx-report.xml", diff --git a/gui/midpoint-jar/pom.xml b/gui/midpoint-jar/pom.xml index 677a5857916..01dd7228a0d 100644 --- a/gui/midpoint-jar/pom.xml +++ b/gui/midpoint-jar/pom.xml @@ -29,6 +29,12 @@ ${project.version} runtime + + com.evolveum.midpoint.model + mcp-impl + ${project.version} + runtime + net.bytebuddy byte-buddy diff --git a/model/authentication-api/src/main/java/com/evolveum/midpoint/authentication/api/authorization/EndPointsUrlMapping.java b/model/authentication-api/src/main/java/com/evolveum/midpoint/authentication/api/authorization/EndPointsUrlMapping.java index cdcbac2101f..9ccd498428c 100644 --- a/model/authentication-api/src/main/java/com/evolveum/midpoint/authentication/api/authorization/EndPointsUrlMapping.java +++ b/model/authentication-api/src/main/java/com/evolveum/midpoint/authentication/api/authorization/EndPointsUrlMapping.java @@ -234,6 +234,11 @@ public enum EndPointsUrlMapping { * This is the authorization that provides access to all the methods. However, it is possible to authorize selected * REST actions individually; see {@link RestAuthorizationAction} enum. */ + MCP("/ws/mcp/**", + new AuthorizationActionValue(RestAuthorizationAction.GET_OBJECT.getUri(), + "RestEndpoint.authRest.getObject.label", "RestEndpoint.authRest.getObject.description"), + new AuthorizationActionValue(RestAuthorizationAction.SEARCH_OBJECTS.getUri(), + "RestEndpoint.authRest.searchObjects.label", "RestEndpoint.authRest.searchObjects.description")), REST("/ws/**", new AuthorizationActionValue(AUTZ_REST_ALL_URL, "RestEndpoint.authRest.all.label", "RestEndpoint.authRest.all.description")), diff --git a/model/mcp-api/pom.xml b/model/mcp-api/pom.xml new file mode 100644 index 00000000000..1dcaa97ab1b --- /dev/null +++ b/model/mcp-api/pom.xml @@ -0,0 +1,38 @@ + + + + 4.0.0 + + + model + com.evolveum.midpoint.model + 4.11-SNAPSHOT + + + mcp-api + jar + + midPoint MCP Layer - api + + + + com.evolveum.midpoint.infra + schema + ${project.version} + + + com.evolveum.midpoint.repo + task-api + ${project.version} + + + com.fasterxml.jackson.core + jackson-annotations + + + diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/McpPublicErrorMessages.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/McpPublicErrorMessages.java new file mode 100644 index 00000000000..9f4817b799e --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/McpPublicErrorMessages.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +/** + * Stable, client-facing error text (no internal exception details). + */ +public final class McpPublicErrorMessages { + + public static final String NOT_FOUND = "The requested object was not found."; + + public static final String ACCESS_DENIED = "Access denied."; + + public static final String INTERNAL_ERROR = "An error occurred while processing the request."; + + public static final String UNEXPECTED_TOOL_FAILURE = "An unexpected error occurred while handling the tool request."; + + public static final String SERIALIZATION_FAILED = "Response serialization failed."; + + private McpPublicErrorMessages() {} +} diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpActivationView.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpActivationView.java new file mode 100644 index 00000000000..c1ffc8c7f0d --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpActivationView.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +/** + * JSON-friendly snapshot of midPoint {@code ActivationType} for MCP explain/search responses. + * All fields are optional; {@code null} means unknown or not set in the source object. + */ +public class MidpointMcpActivationView { + + private String administrativeStatus; + private String effectiveStatus; + private String validityStatus; + private String validFrom; + private String validTo; + private String lockoutStatus; + private String disableReason; + private String enableTimestamp; + private String disableTimestamp; + private String archiveTimestamp; + private String validityChangeTimestamp; + private String lockoutExpirationTimestamp; + + public String getAdministrativeStatus() { + return administrativeStatus; + } + + public void setAdministrativeStatus(String administrativeStatus) { + this.administrativeStatus = administrativeStatus; + } + + public String getEffectiveStatus() { + return effectiveStatus; + } + + public void setEffectiveStatus(String effectiveStatus) { + this.effectiveStatus = effectiveStatus; + } + + public String getValidityStatus() { + return validityStatus; + } + + public void setValidityStatus(String validityStatus) { + this.validityStatus = validityStatus; + } + + public String getValidFrom() { + return validFrom; + } + + public void setValidFrom(String validFrom) { + this.validFrom = validFrom; + } + + public String getValidTo() { + return validTo; + } + + public void setValidTo(String validTo) { + this.validTo = validTo; + } + + public String getLockoutStatus() { + return lockoutStatus; + } + + public void setLockoutStatus(String lockoutStatus) { + this.lockoutStatus = lockoutStatus; + } + + public String getDisableReason() { + return disableReason; + } + + public void setDisableReason(String disableReason) { + this.disableReason = disableReason; + } + + public String getEnableTimestamp() { + return enableTimestamp; + } + + public void setEnableTimestamp(String enableTimestamp) { + this.enableTimestamp = enableTimestamp; + } + + public String getDisableTimestamp() { + return disableTimestamp; + } + + public void setDisableTimestamp(String disableTimestamp) { + this.disableTimestamp = disableTimestamp; + } + + public String getArchiveTimestamp() { + return archiveTimestamp; + } + + public void setArchiveTimestamp(String archiveTimestamp) { + this.archiveTimestamp = archiveTimestamp; + } + + public String getValidityChangeTimestamp() { + return validityChangeTimestamp; + } + + public void setValidityChangeTimestamp(String validityChangeTimestamp) { + this.validityChangeTimestamp = validityChangeTimestamp; + } + + public String getLockoutExpirationTimestamp() { + return lockoutExpirationTimestamp; + } + + public void setLockoutExpirationTimestamp(String lockoutExpirationTimestamp) { + this.lockoutExpirationTimestamp = lockoutExpirationTimestamp; + } +} diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAdvancedFilterSpec.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAdvancedFilterSpec.java new file mode 100644 index 00000000000..176fc0d9427 --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAdvancedFilterSpec.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +/** + * One filter in {@link MidpointMcpAdvancedQuerySpec#getFilters()}. + * {@code value} is JSON-driven: string, number, boolean, or array of strings (for {@code in}). + */ +public class MidpointMcpAdvancedFilterSpec { + + private String path; + /** + * One of: {@code eq}, {@code neq}, {@code startsWith}, {@code contains}, {@code gt}, {@code gte}, + * {@code lt}, {@code lte}, {@code exists}, {@code in}. + */ + private String op; + /** Omitted for {@code exists}. */ + private Object value; + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getOp() { + return op; + } + + public void setOp(String op) { + this.op = op; + } + + public Object getValue() { + return value; + } + + public void setValue(Object value) { + this.value = value; + } +} diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAdvancedOrderBySpec.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAdvancedOrderBySpec.java new file mode 100644 index 00000000000..4904673e154 --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAdvancedOrderBySpec.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +/** + * Sort instruction for {@link MidpointMcpAdvancedQuerySpec}; path uses MCP dot notation. + */ +public class MidpointMcpAdvancedOrderBySpec { + + private String path; + /** {@code asc} or {@code desc}; default ascending when omitted. */ + private String direction; + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getDirection() { + return direction; + } + + public void setDirection(String direction) { + this.direction = direction; + } +} diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAdvancedPagingSpec.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAdvancedPagingSpec.java new file mode 100644 index 00000000000..5bda4108300 --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAdvancedPagingSpec.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +/** + * Optional paging inside {@link MidpointMcpAdvancedQuerySpec}; when present, overrides top-level + * {@link MidpointMcpSearchRequest#getLimit()}/{@link MidpointMcpSearchRequest#getOffset()}. + */ +public class MidpointMcpAdvancedPagingSpec { + + private Integer offset; + private Integer limit; + + public Integer getOffset() { + return offset; + } + + public void setOffset(Integer offset) { + this.offset = offset; + } + + public Integer getLimit() { + return limit; + } + + public void setLimit(Integer limit) { + this.limit = limit; + } +} diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAdvancedQuerySpec.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAdvancedQuerySpec.java new file mode 100644 index 00000000000..d2a51068f00 --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAdvancedQuerySpec.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +import java.util.ArrayList; +import java.util.List; + +/** + * Structured advanced query shape used by object search (paths from {@code midpoint_describe_object_type_schema}) and + * by audit search (separate audit path vocabulary; see MCP audit tool documentation). The server interprets {@code path} + * values according to the active operation. + */ +public class MidpointMcpAdvancedQuerySpec { + + /** {@code and} or {@code or}; defaults to {@code and}. */ + private String combine; + private List filters = new ArrayList<>(); + private List orderBy = new ArrayList<>(); + private MidpointMcpAdvancedPagingSpec paging; + + public String getCombine() { + return combine; + } + + public void setCombine(String combine) { + this.combine = combine; + } + + public List getFilters() { + return filters; + } + + public void setFilters(List filters) { + this.filters = filters != null ? filters : new ArrayList<>(); + } + + public List getOrderBy() { + return orderBy; + } + + public void setOrderBy(List orderBy) { + this.orderBy = orderBy != null ? orderBy : new ArrayList<>(); + } + + public MidpointMcpAdvancedPagingSpec getPaging() { + return paging; + } + + public void setPaging(MidpointMcpAdvancedPagingSpec paging) { + this.paging = paging; + } +} diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditEffectiveWindow.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditEffectiveWindow.java new file mode 100644 index 00000000000..3bf07a114ea --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditEffectiveWindow.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +/** Effective timestamp bounds applied to the audit query (ISO-8601, UTC). */ +public class MidpointMcpAuditEffectiveWindow { + + private String from; + private String to; + + public MidpointMcpAuditEffectiveWindow() { + } + + public MidpointMcpAuditEffectiveWindow(String from, String to) { + this.from = from; + this.to = to; + } + + public String getFrom() { + return from; + } + + public void setFrom(String from) { + this.from = from; + } + + public String getTo() { + return to; + } + + public void setTo(String to) { + this.to = to; + } +} diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditExplainRequest.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditExplainRequest.java new file mode 100644 index 00000000000..817efab4c2b --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditExplainRequest.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +/** + * Explain a single audit record. {@code id} is normally {@code eventIdentifier}; numeric-only ids are resolved as + * repository {@code repoId}. + */ +public class MidpointMcpAuditExplainRequest { + + private String id; + private Boolean includeDelta; + private Boolean includeResult; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Boolean getIncludeDelta() { + return includeDelta; + } + + public void setIncludeDelta(Boolean includeDelta) { + this.includeDelta = includeDelta; + } + + public Boolean getIncludeResult() { + return includeResult; + } + + public void setIncludeResult(Boolean includeResult) { + this.includeResult = includeResult; + } +} diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditExplainResult.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditExplainResult.java new file mode 100644 index 00000000000..2ce3406e5f7 --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditExplainResult.java @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class MidpointMcpAuditExplainResult { + + private String id; + private String timestamp; + private String eventType; + private String eventStage; + private String outcome; + private MidpointMcpAuditInitiatorView initiator; + private MidpointMcpAuditTargetView target; + private String channel; + private MidpointMcpAuditTaskView task; + private String node; + private String message; + private String summary; + private String explanation; + /** Present when {@link MidpointMcpAuditExplainRequest#getIncludeDelta()} is true. */ + private Map delta; + /** Present when {@link MidpointMcpAuditExplainRequest#getIncludeResult()} is true. */ + private Map result; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getTimestamp() { + return timestamp; + } + + public void setTimestamp(String timestamp) { + this.timestamp = timestamp; + } + + public String getEventType() { + return eventType; + } + + public void setEventType(String eventType) { + this.eventType = eventType; + } + + public String getEventStage() { + return eventStage; + } + + public void setEventStage(String eventStage) { + this.eventStage = eventStage; + } + + public String getOutcome() { + return outcome; + } + + public void setOutcome(String outcome) { + this.outcome = outcome; + } + + public MidpointMcpAuditInitiatorView getInitiator() { + return initiator; + } + + public void setInitiator(MidpointMcpAuditInitiatorView initiator) { + this.initiator = initiator; + } + + public MidpointMcpAuditTargetView getTarget() { + return target; + } + + public void setTarget(MidpointMcpAuditTargetView target) { + this.target = target; + } + + public String getChannel() { + return channel; + } + + public void setChannel(String channel) { + this.channel = channel; + } + + public MidpointMcpAuditTaskView getTask() { + return task; + } + + public void setTask(MidpointMcpAuditTaskView task) { + this.task = task; + } + + public String getNode() { + return node; + } + + public void setNode(String node) { + this.node = node; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getSummary() { + return summary; + } + + public void setSummary(String summary) { + this.summary = summary; + } + + public String getExplanation() { + return explanation; + } + + public void setExplanation(String explanation) { + this.explanation = explanation; + } + + public Map getDelta() { + return delta; + } + + public void setDelta(Map delta) { + this.delta = delta; + } + + public Map getResult() { + return result; + } + + public void setResult(Map result) { + this.result = result; + } +} diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditInitiatorView.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditInitiatorView.java new file mode 100644 index 00000000000..9125dc92bdd --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditInitiatorView.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +public class MidpointMcpAuditInitiatorView { + + private String oid; + private String name; + + public String getOid() { + return oid; + } + + public void setOid(String oid) { + this.oid = oid; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditRecordSummary.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditRecordSummary.java new file mode 100644 index 00000000000..7961005ecd2 --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditRecordSummary.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * Compact audit row for {@link MidpointMcpAuditSearchResult}. {@code id} is the midPoint {@code eventIdentifier}. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class MidpointMcpAuditRecordSummary { + + private String id; + private String timestamp; + private String eventType; + private String eventStage; + private String outcome; + private MidpointMcpAuditInitiatorView initiator; + private MidpointMcpAuditTargetView target; + private String channel; + private MidpointMcpAuditTaskView task; + private String node; + private String message; + private String summary; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getTimestamp() { + return timestamp; + } + + public void setTimestamp(String timestamp) { + this.timestamp = timestamp; + } + + public String getEventType() { + return eventType; + } + + public void setEventType(String eventType) { + this.eventType = eventType; + } + + public String getEventStage() { + return eventStage; + } + + public void setEventStage(String eventStage) { + this.eventStage = eventStage; + } + + public String getOutcome() { + return outcome; + } + + public void setOutcome(String outcome) { + this.outcome = outcome; + } + + public MidpointMcpAuditInitiatorView getInitiator() { + return initiator; + } + + public void setInitiator(MidpointMcpAuditInitiatorView initiator) { + this.initiator = initiator; + } + + public MidpointMcpAuditTargetView getTarget() { + return target; + } + + public void setTarget(MidpointMcpAuditTargetView target) { + this.target = target; + } + + public String getChannel() { + return channel; + } + + public void setChannel(String channel) { + this.channel = channel; + } + + public MidpointMcpAuditTaskView getTask() { + return task; + } + + public void setTask(MidpointMcpAuditTaskView task) { + this.task = task; + } + + public String getNode() { + return node; + } + + public void setNode(String node) { + this.node = node; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getSummary() { + return summary; + } + + public void setSummary(String summary) { + this.summary = summary; + } +} diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditSearchRequest.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditSearchRequest.java new file mode 100644 index 00000000000..a3b0820071c --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditSearchRequest.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +/** + * Audit search for {@link MidpointMcpService#searchAudit}. At most one of {@link #query} or + * {@link #advancedQuery} may be set. + */ +public class MidpointMcpAuditSearchRequest { + + /** Inclusive lower bound (ISO-8601). Optional if {@code to} is set; default window applies if both absent. */ + private String from; + /** Inclusive upper bound (ISO-8601). Optional if {@code from} is set; default window applies if both absent. */ + private String to; + + /** Simple free-text search; mutually exclusive with {@link #advancedQuery}. */ + private String query; + + /** Structured filters over the MCP audit path vocabulary; mutually exclusive with {@link #query}. */ + private MidpointMcpAdvancedQuerySpec advancedQuery; + + public String getFrom() { + return from; + } + + public void setFrom(String from) { + this.from = from; + } + + public String getTo() { + return to; + } + + public void setTo(String to) { + this.to = to; + } + + public String getQuery() { + return query; + } + + public void setQuery(String query) { + this.query = query; + } + + public MidpointMcpAdvancedQuerySpec getAdvancedQuery() { + return advancedQuery; + } + + public void setAdvancedQuery(MidpointMcpAdvancedQuerySpec advancedQuery) { + this.advancedQuery = advancedQuery; + } +} diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditSearchResult.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditSearchResult.java new file mode 100644 index 00000000000..511e369fb44 --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditSearchResult.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +import java.util.ArrayList; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class MidpointMcpAuditSearchResult { + + /** {@code simple} or {@code advancedQuery}. */ + private String usedQueryMode; + private MidpointMcpAuditEffectiveWindow effectiveWindow; + private int totalCount; + /** Number of records in this page ({@link #records}.size()). */ + private int count; + private List records = new ArrayList<>(); + /** MQL filter from advanced mode (excluding mandatory time window), for debugging. */ + private String translatedQuery; + private int limit; + private int offset; + + public String getUsedQueryMode() { + return usedQueryMode; + } + + public void setUsedQueryMode(String usedQueryMode) { + this.usedQueryMode = usedQueryMode; + } + + public MidpointMcpAuditEffectiveWindow getEffectiveWindow() { + return effectiveWindow; + } + + public void setEffectiveWindow(MidpointMcpAuditEffectiveWindow effectiveWindow) { + this.effectiveWindow = effectiveWindow; + } + + public int getTotalCount() { + return totalCount; + } + + public void setTotalCount(int totalCount) { + this.totalCount = totalCount; + } + + public int getCount() { + return count; + } + + public void setCount(int count) { + this.count = count; + } + + public List getRecords() { + return records; + } + + public void setRecords(List records) { + this.records = records != null ? records : new ArrayList<>(); + } + + public String getTranslatedQuery() { + return translatedQuery; + } + + public void setTranslatedQuery(String translatedQuery) { + this.translatedQuery = translatedQuery; + } + + public int getLimit() { + return limit; + } + + public void setLimit(int limit) { + this.limit = limit; + } + + public int getOffset() { + return offset; + } + + public void setOffset(int offset) { + this.offset = offset; + } +} diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditTargetView.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditTargetView.java new file mode 100644 index 00000000000..d810e61ee05 --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditTargetView.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +/** Target object reference; {@code type} is the MCP REST collection name when known (e.g. {@code users}). */ +public class MidpointMcpAuditTargetView { + + private String oid; + private String type; + private String name; + + public String getOid() { + return oid; + } + + public void setOid(String oid) { + this.oid = oid; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditTaskView.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditTaskView.java new file mode 100644 index 00000000000..8ef122c00c0 --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpAuditTaskView.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +/** + * Task context. {@code name} is the persisted task identifier string ({@code taskIdentifier}), not a resolved + * display name. + */ +public class MidpointMcpAuditTaskView { + + private String oid; + private String name; + + public String getOid() { + return oid; + } + + public void setOid(String oid) { + this.oid = oid; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpException.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpException.java new file mode 100644 index 00000000000..d2f6c975d50 --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpException.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +public class MidpointMcpException extends RuntimeException { + + private final String code; + private final int status; + /** Optional short guidance for MCP clients (examples, next steps). */ + private final String hint; + + public MidpointMcpException(String code, int status, String message) { + this(code, status, message, null, null); + } + + public MidpointMcpException(String code, int status, String message, Throwable cause) { + this(code, status, message, null, cause); + } + + public MidpointMcpException(String code, int status, String message, String hint) { + this(code, status, message, hint, null); + } + + public MidpointMcpException(String code, int status, String message, String hint, Throwable cause) { + super(message, cause); + this.code = code; + this.status = status; + this.hint = hint; + } + + public String getCode() { + return code; + } + + public int getStatus() { + return status; + } + + public String getHint() { + return hint; + } +} diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpObjectView.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpObjectView.java new file mode 100644 index 00000000000..d4f654656dc --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpObjectView.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Explain-tool payload: flat dot-path keys (aligned with {@code midpoint_describe_object_type_schema}) to JSON values. + */ +public class MidpointMcpObjectView { + + /** For shadows: {@code repository} or {@code resource}. */ + private String source; + + /** For shadows: whether live resource data was fetched. */ + private Boolean fetched; + + private Map values = new LinkedHashMap<>(); + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public Boolean getFetched() { + return fetched; + } + + public void setFetched(Boolean fetched) { + this.fetched = fetched; + } + + public Map getValues() { + return values; + } + + public void setValues(Map values) { + this.values = values != null ? values : new LinkedHashMap<>(); + } +} diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpReference.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpReference.java new file mode 100644 index 00000000000..181f0602343 --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpReference.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +public class MidpointMcpReference { + + private String oid; + private String type; + private String name; + private String relation; + + public MidpointMcpReference() { + } + + public MidpointMcpReference(String oid, String type, String name, String relation) { + this.oid = oid; + this.type = type; + this.name = name; + this.relation = relation; + } + + public String getOid() { + return oid; + } + + public void setOid(String oid) { + this.oid = oid; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getRelation() { + return relation; + } + + public void setRelation(String relation) { + this.relation = relation; + } +} diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpSchemaAttribute.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpSchemaAttribute.java new file mode 100644 index 00000000000..ba806e364f4 --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpSchemaAttribute.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * One flattened attribute path for an MCP object-type schema (property or reference). + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class MidpointMcpSchemaAttribute { + + /** Dot-separated path, e.g. {@code activation.effectiveStatus} or {@code extension.employeeBadge}. */ + private String path; + /** Simplified type: {@code string}, {@code integer}, {@code boolean}, {@code datetime}, {@code reference}. */ + private String type; + /** For references: MCP REST collection or union (e.g. {@code roles|orgs|services}) or {@code objects}. */ + private String target; + /** When the Prism definition exposes allowed values (e.g. XSD enumeration), serialized as JSON {@code "enum"}. */ + private List enumValues; + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getTarget() { + return target; + } + + public void setTarget(String target) { + this.target = target; + } + + @JsonProperty("enum") + public List getEnum() { + return enumValues; + } + + public void setEnum(List enumValues) { + this.enumValues = enumValues; + } +} diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpSearchItem.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpSearchItem.java new file mode 100644 index 00000000000..2ce156de0aa --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpSearchItem.java @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * One search hit: same {@link #values} shape as {@link MidpointMcpObjectView} for the same {@code returnAttributes}. + */ +public class MidpointMcpSearchItem { + + private Map values = new LinkedHashMap<>(); + + public Map getValues() { + return values; + } + + public void setValues(Map values) { + this.values = values != null ? values : new LinkedHashMap<>(); + } +} diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpSearchRequest.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpSearchRequest.java new file mode 100644 index 00000000000..0d0becbd1c6 --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpSearchRequest.java @@ -0,0 +1,182 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +import java.util.List; + +/** + * Request for {@link MidpointMcpService#searchObjects}. + * At most one of {@link #getQuery()}, {@link #getAdvancedQuery()}, or {@link #getMql()} may be set (non-blank / non-null). + */ +public class MidpointMcpSearchRequest { + + /** REST collection name (required). */ + private String type; + /** + * Simple search: for most REST types, prefix match on {@code name}; for {@code cases}, substring match on name, + * description, or work item names (OR). + */ + private String query; + /** Structured query translated to MQL. */ + private MidpointMcpAdvancedQuerySpec advancedQuery; + /** Expert raw MQL filter (no translation). */ + private String mql; + private Integer limit; + private Integer offset; + private List returnAttributes; + + /** + * Shadow-only with repository search: when {@code true}, refreshes each hit from the resource (very expensive—one + * connector read per result). Use only when explicitly needed; omit or {@code false} for repository snapshot data + * (e.g. resolving a user's {@code linkRef} to a shadow). Ignored for {@code searchMode == resource} (already live). + * Not part of MQL. + */ + private Boolean fetch; + + /** + * Shadow-only: {@code repository} (default) searches stored shadows; {@code resource} searches the resource backend. + * Not part of MQL. + */ + private String searchMode; + + /** Required when {@code searchMode == resource}: resource OID to scope the search. */ + private String resourceOid; + + /** + * Shadow resource search: {@link com.evolveum.midpoint.xml.ns._public.common.common_3.ShadowKindType} name + * (e.g. {@code ACCOUNT}). Use with {@code resourceOid}; either this or {@link #objectClass} should be set. + */ + private String shadowKind; + + /** Optional intent when {@link #shadowKind} is set. */ + private String shadowIntent; + + /** + * Shadow resource search: object class QName string (e.g. URI or {@code ns:local}). Alternative to {@link #shadowKind}. + */ + private String objectClass; + + /** + * Shadow repository search only: when {@code true} with {@link #resourceOid}, builds an OR of all + * {@code schemaHandling} object types on that resource (kind/intent) and ANDs it with the rest of the query. + * Mutually exclusive with {@link #shadowKind}, {@link #shadowIntent}, and {@link #objectClass}. Not used with + * {@code searchMode == resource}. + */ + private Boolean expandResourceObjectTypes; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getQuery() { + return query; + } + + public void setQuery(String query) { + this.query = query; + } + + public MidpointMcpAdvancedQuerySpec getAdvancedQuery() { + return advancedQuery; + } + + public void setAdvancedQuery(MidpointMcpAdvancedQuerySpec advancedQuery) { + this.advancedQuery = advancedQuery; + } + + public String getMql() { + return mql; + } + + public void setMql(String mql) { + this.mql = mql; + } + + public Integer getLimit() { + return limit; + } + + public void setLimit(Integer limit) { + this.limit = limit; + } + + public Integer getOffset() { + return offset; + } + + public void setOffset(Integer offset) { + this.offset = offset; + } + + public List getReturnAttributes() { + return returnAttributes; + } + + public void setReturnAttributes(List returnAttributes) { + this.returnAttributes = returnAttributes; + } + + public Boolean getFetch() { + return fetch; + } + + public void setFetch(Boolean fetch) { + this.fetch = fetch; + } + + public String getSearchMode() { + return searchMode; + } + + public void setSearchMode(String searchMode) { + this.searchMode = searchMode; + } + + public String getResourceOid() { + return resourceOid; + } + + public void setResourceOid(String resourceOid) { + this.resourceOid = resourceOid; + } + + public String getShadowKind() { + return shadowKind; + } + + public void setShadowKind(String shadowKind) { + this.shadowKind = shadowKind; + } + + public String getShadowIntent() { + return shadowIntent; + } + + public void setShadowIntent(String shadowIntent) { + this.shadowIntent = shadowIntent; + } + + public String getObjectClass() { + return objectClass; + } + + public void setObjectClass(String objectClass) { + this.objectClass = objectClass; + } + + public Boolean getExpandResourceObjectTypes() { + return expandResourceObjectTypes; + } + + public void setExpandResourceObjectTypes(Boolean expandResourceObjectTypes) { + this.expandResourceObjectTypes = expandResourceObjectTypes; + } +} diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpSearchResult.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpSearchResult.java new file mode 100644 index 00000000000..5d987667ab1 --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpSearchResult.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +import java.util.ArrayList; +import java.util.List; + +public class MidpointMcpSearchResult { + + private String type; + private String query; + /** {@code simple}, {@code advancedQuery}, or {@code mql}. */ + private String usedQueryMode; + /** MQL filter used or generated; null for simple name-prefix mode. */ + private String translatedMql; + private int limit; + private int offset; + private int totalCount; + private List items = new ArrayList<>(); + + /** Shadow searches: {@code repository} or {@code resource}. */ + private String source; + + /** Shadow searches: whether results include live resource-fetched data. */ + private Boolean fetched; + + /** Echo of shadow {@code searchMode} when applicable. */ + private String searchMode; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getQuery() { + return query; + } + + public void setQuery(String query) { + this.query = query; + } + + public String getUsedQueryMode() { + return usedQueryMode; + } + + public void setUsedQueryMode(String usedQueryMode) { + this.usedQueryMode = usedQueryMode; + } + + public String getTranslatedMql() { + return translatedMql; + } + + public void setTranslatedMql(String translatedMql) { + this.translatedMql = translatedMql; + } + + public int getLimit() { + return limit; + } + + public void setLimit(int limit) { + this.limit = limit; + } + + public int getOffset() { + return offset; + } + + public void setOffset(int offset) { + this.offset = offset; + } + + public int getTotalCount() { + return totalCount; + } + + public void setTotalCount(int totalCount) { + this.totalCount = totalCount; + } + + public List getItems() { + return items; + } + + public void setItems(List items) { + this.items = items; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public Boolean getFetched() { + return fetched; + } + + public void setFetched(Boolean fetched) { + this.fetched = fetched; + } + + public String getSearchMode() { + return searchMode; + } + + public void setSearchMode(String searchMode) { + this.searchMode = searchMode; + } +} diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpService.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpService.java new file mode 100644 index 00000000000..95e6112f6b0 --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpService.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +import java.util.List; + +import com.evolveum.midpoint.schema.result.OperationResult; +import com.evolveum.midpoint.task.api.Task; + +public interface MidpointMcpService { + + /** + * Read-only explain view for a single object. {@code objectType} is the REST collection name + * ({@code users}, {@code roles}, {@code orgs}, {@code archetypes}, {@code connectors}, + * {@code resources}, {@code services}, {@code shadows}, {@code tasks}, {@code nodes}, {@code cases}). + * + * @param returnAttributes optional dot paths (same vocabulary as {@link #describeObjectTypeSchema}); + * {@code null} or empty uses curated explain defaults per type (richer than search defaults); + * {@code ["*"]} returns all schema paths plus virtual fields (large JSON). + * @param fetch shadows only: {@code true} triggers a live connector read (very expensive; use only when explicitly + * needed). {@code null} or {@code false} uses the repository shadow (default)—preferred when + * resolving e.g. a user's {@code linkRef} to a shadow. May fail if the resource is down. + * For non-shadow types, {@code fetch=true} is rejected with {@code 400} ({@code bad_request}). + */ + MidpointMcpObjectView explainObject( + String objectType, + String oid, + List returnAttributes, + Boolean fetch, + Task task, + OperationResult result); + + /** + * Search objects of the given REST collection. Use {@link MidpointMcpSearchRequest} for: + *
    + *
  • simple {@code query} — for most types: prefix match on {@code name}; for {@code cases}: substring match on + * case name, description, or work item names (OR)
  • + *
  • {@code advancedQuery} — structured filters translated to MQL
  • + *
  • {@code mql} — raw MQL filter (expert)
  • + *
+ * At most one of those three may be set per request. + * + * @param request {@link MidpointMcpSearchRequest#getReturnAttributes()} same path vocabulary as {@link #explainObject}; + * when omitted, search uses lighter defaults than explain (fewer fields, no extension.* by default). + */ + MidpointMcpSearchResult searchObjects(MidpointMcpSearchRequest request, Task task, OperationResult result); + + /** + * Returns a flattened attribute-path schema for the given REST collection type (including extension definitions + * from the schema registry). Requires {@code authorization-model-3#read} for the corresponding object type. + * Does not load repository objects. + * For {@code shadows}, only the static {@code ShadowType} definition is returned (no resource-specific attributes). + *

+ * Callers should pass {@code null} for {@code maxDepth} (default {@code 2}) unless a deeper or full flatten is + * required; {@code 0} means unlimited and can be very large. + * + * @param maxDepth {@code null} uses default {@code 2}; {@code 0} means unlimited path depth; {@code >= 1} caps + * dot-segment depth of emitted paths. + */ + MidpointMcpTypeSchemaView describeObjectTypeSchema( + String objectType, Integer maxDepth, Task task, OperationResult result); + + /** + * Search audit records (separate from {@link #searchObjects}). Enforces midPoint audit read authorization on the model layer. + *

+ * At most one of {@link MidpointMcpAuditSearchRequest#getQuery()} or {@link MidpointMcpAuditSearchRequest#getAdvancedQuery()} + * may be set. A time window is always applied (explicit {@code from}/{@code to} or default last 24 hours in UTC). + */ + MidpointMcpAuditSearchResult searchAudit(MidpointMcpAuditSearchRequest request, Task task, OperationResult result); + + /** + * Load one audit record by {@code eventIdentifier} or numeric repository id and return a normalized explanation. + */ + MidpointMcpAuditExplainResult explainAuditRecord(MidpointMcpAuditExplainRequest request, Task task, OperationResult result); +} diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpToolError.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpToolError.java new file mode 100644 index 00000000000..d98a689173e --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpToolError.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +public class MidpointMcpToolError { + + private String code; + private int status; + private String message; + + public MidpointMcpToolError() { + } + + public MidpointMcpToolError(String code, int status, String message) { + this.code = code; + this.status = status; + this.message = message; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public int getStatus() { + return status; + } + + public void setStatus(int status) { + this.status = status; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpTypeSchemaView.java b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpTypeSchemaView.java new file mode 100644 index 00000000000..6910a4fe603 --- /dev/null +++ b/model/mcp-api/src/main/java/com/evolveum/midpoint/mcp/api/MidpointMcpTypeSchemaView.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.api; + +import java.util.ArrayList; +import java.util.List; + +/** + * Flattened schema for a midPoint REST collection type (from the schema registry), including extension items. + */ +public class MidpointMcpTypeSchemaView { + + /** REST collection name, e.g. {@code users}. */ + private String type; + private List attributes = new ArrayList<>(); + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public List getAttributes() { + return attributes; + } + + public void setAttributes(List attributes) { + this.attributes = attributes != null ? attributes : new ArrayList<>(); + } +} diff --git a/model/mcp-impl/pom.xml b/model/mcp-impl/pom.xml new file mode 100644 index 00000000000..2b0688465b5 --- /dev/null +++ b/model/mcp-impl/pom.xml @@ -0,0 +1,93 @@ + + + + 4.0.0 + + + model + com.evolveum.midpoint.model + 4.11-SNAPSHOT + + + mcp-impl + jar + + midPoint MCP Layer - impl + + + + com.evolveum.commons + util + + + com.evolveum.midpoint.model + mcp-api + ${project.version} + + + com.evolveum.midpoint.model + model-api + ${project.version} + + + com.evolveum.midpoint.infra + schema + ${project.version} + + + com.evolveum.prism + prism-api + + + com.evolveum.midpoint.repo + security-api + ${project.version} + + + com.evolveum.midpoint.repo + security-enforcer-api + ${project.version} + + + org.springframework + spring-context + + + org.springframework + spring-beans + + + org.apache.commons + commons-lang3 + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.core + jackson-core + + + com.evolveum.midpoint.repo + task-api + ${project.version} + + + + org.testng + testng + test + + + com.evolveum.commons + test-ng + test + + + diff --git a/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpAdvancedQueryTranslator.java b/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpAdvancedQueryTranslator.java new file mode 100644 index 00000000000..4086c4dbf01 --- /dev/null +++ b/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpAdvancedQueryTranslator.java @@ -0,0 +1,371 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.impl; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +import javax.xml.namespace.QName; + +import com.evolveum.midpoint.mcp.api.MidpointMcpAdvancedFilterSpec; +import com.evolveum.midpoint.mcp.api.MidpointMcpAdvancedQuerySpec; +import com.evolveum.midpoint.mcp.api.MidpointMcpSchemaAttribute; +import com.evolveum.midpoint.prism.ComplexTypeDefinition; +import com.evolveum.midpoint.prism.ItemDefinition; +import com.evolveum.midpoint.prism.PrismContainerDefinition; +import com.evolveum.midpoint.prism.PrismObjectDefinition; +import com.evolveum.midpoint.prism.path.ItemPath; + +import org.apache.commons.lang3.StringUtils; + +/** + * Validates {@link MidpointMcpAdvancedQuerySpec} against flattened MCP schema paths and builds an MQL filter string. + */ +final class MidpointMcpAdvancedQueryTranslator { + + private static final Pattern UUID_PATTERN = Pattern.compile( + "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"); + + private MidpointMcpAdvancedQueryTranslator() {} + + static String translateToMqlFilter(MidpointMcpAdvancedQuerySpec spec, Map schemaByPath) { + if (spec == null) { + throw new IllegalArgumentException("advancedQuery is required"); + } + String combine = spec.getCombine(); + if (StringUtils.isBlank(combine)) { + combine = "and"; + } else { + combine = combine.trim().toLowerCase(Locale.ROOT); + if (!combine.equals("and") && !combine.equals("or")) { + throw new IllegalArgumentException("advancedQuery.combine must be 'and' or 'or'"); + } + } + List filters = spec.getFilters(); + if (filters == null || filters.isEmpty()) { + return ""; + } + List parts = new ArrayList<>(); + for (MidpointMcpAdvancedFilterSpec f : filters) { + if (f == null) { + throw new IllegalArgumentException("advancedQuery.filters contains a null entry"); + } + parts.add(translateOneFilter(f, schemaByPath)); + } + String sep = " and "; + if ("or".equals(combine)) { + sep = " or "; + List wrapped = new ArrayList<>(); + for (String p : parts) { + wrapped.add("(" + p + ")"); + } + return String.join(sep, wrapped); + } + return String.join(sep, parts); + } + + static Map schemaMapForValidation(PrismObjectDefinition objDef) { + List flat = MidpointMcpSchemaFlattener.flatten(objDef, Integer.MAX_VALUE); + Map map = new LinkedHashMap<>(); + for (MidpointMcpSchemaAttribute a : flat) { + map.put(a.getPath(), a); + } + return map; + } + + /** + * Resolves a dot path from the MCP schema to an {@link ItemPath} for sorting. + */ + static ItemPath dotPathToItemPath(PrismObjectDefinition objDef, String dotPath) { + if (StringUtils.isBlank(dotPath)) { + throw new IllegalArgumentException("orderBy.path is required"); + } + String trimmed = dotPath.trim(); + String[] segments = trimmed.split("\\."); + if (segments.length == 0) { + throw new IllegalArgumentException("orderBy.path is empty"); + } + List names = new ArrayList<>(); + ComplexTypeDefinition ctd = objDef.getComplexTypeDefinition(); + if (ctd == null) { + throw new IllegalArgumentException("Cannot resolve path '" + trimmed + "': no complex type on object definition"); + } + for (int i = 0; i < segments.length; i++) { + String seg = segments[i].trim(); + if (seg.isEmpty()) { + throw new IllegalArgumentException("Invalid path '" + trimmed + "'"); + } + ItemDefinition def = findChildByLocalPart(ctd, seg); + names.add(def.getItemName()); + if (i < segments.length - 1) { + if (!(def instanceof PrismContainerDefinition cont)) { + throw new IllegalArgumentException("Path '" + trimmed + "': '" + seg + "' is not a container"); + } + ctd = cont.getComplexTypeDefinition(); + if (ctd == null) { + throw new IllegalArgumentException("Path '" + trimmed + "': missing complex type under '" + seg + "'"); + } + } + } + return ItemPath.create(names); + } + + private static ItemDefinition findChildByLocalPart(ComplexTypeDefinition ctd, String local) { + for (ItemDefinition d : ctd.getDefinitions()) { + if (local.equals(d.getItemName().getLocalPart())) { + return d; + } + } + throw new IllegalArgumentException("Unknown item '" + local + "' for type " + ctd.getTypeName()); + } + + private static String translateOneFilter(MidpointMcpAdvancedFilterSpec f, Map schemaByPath) { + String path = f.getPath(); + if (StringUtils.isBlank(path)) { + throw new IllegalArgumentException("filter.path is required"); + } + path = path.trim(); + MidpointMcpSchemaAttribute attr = schemaByPath.get(path); + if (attr == null) { + throw new IllegalArgumentException("Unknown path '" + path + "' for this object type (use midpoint_describe_object_type_schema)"); + } + String op = f.getOp(); + if (StringUtils.isBlank(op)) { + throw new IllegalArgumentException("filter.op is required for path '" + path + "'"); + } + op = op.trim().toLowerCase(Locale.ROOT); + String type = attr.getType(); + if (type == null) { + type = "string"; + } + String mqlPath = dotPathToMqlPath(path); + return switch (op) { + case "exists" -> { + if (f.getValue() != null) { + throw new IllegalArgumentException("filter.value must not be set for op 'exists' (path '" + path + "')"); + } + yield mqlPath + " exists"; + } + case "eq", "neq", "startswith", "contains", "gt", "gte", "lt", "lte", "in" -> { + assertOpAllowedForType(path, op, type); + yield buildValueFilter(mqlPath, op, f.getValue(), type, attr); + } + default -> throw new IllegalArgumentException("Unsupported filter.op '" + f.getOp() + "' for path '" + path + "'"); + }; + } + + private static void assertOpAllowedForType(String path, String op, String type) { + switch (type) { + case "string" -> { + if (!List.of("eq", "neq", "startswith", "contains", "gt", "gte", "lt", "lte", "in").contains(op)) { + throw new IllegalArgumentException("Op '" + op + "' is not allowed for string path '" + path + "'"); + } + } + case "integer", "datetime" -> { + if (!List.of("eq", "neq", "gt", "gte", "lt", "lte", "in").contains(op)) { + throw new IllegalArgumentException("Op '" + op + "' is not allowed for " + type + " path '" + path + "'"); + } + } + case "boolean" -> { + if (!List.of("eq", "neq").contains(op)) { + throw new IllegalArgumentException("Op '" + op + "' is not allowed for boolean path '" + path + "'"); + } + } + case "reference" -> { + if (!List.of("eq", "in").contains(op)) { + throw new IllegalArgumentException( + "Op '" + op + "' is not supported for reference path '" + path + "'; use 'exists', 'eq' (target OID), or 'in' (OID list)"); + } + } + default -> throw new IllegalArgumentException("Unsupported schema type '" + type + "' for path '" + path + "'"); + } + } + + private static String buildValueFilter( + String mqlPath, String op, Object value, String type, MidpointMcpSchemaAttribute attr) { + + if ("reference".equals(type)) { + return buildReferenceFilter(mqlPath, op, value); + } + if ("in".equals(op)) { + List list = expectList(value, "in", mqlPath); + if (list.isEmpty()) { + throw new IllegalArgumentException("filter.value for 'in' must be a non-empty array (path '" + mqlPath + "')"); + } + if ("integer".equals(type)) { + List parts = new ArrayList<>(); + for (Object o : list) { + parts.add(formatInteger(o, mqlPath)); + } + return mqlPath + " = (" + String.join(", ", parts) + ")"; + } + if ("datetime".equals(type)) { + List parts = new ArrayList<>(); + for (Object o : list) { + parts.add(mqlStringLiteral(coerceStringForEnum(o, attr, mqlPath))); + } + return mqlPath + " = (" + String.join(", ", parts) + ")"; + } + List strings = new ArrayList<>(); + for (Object o : list) { + strings.add(coerceStringForEnum(o, attr, mqlPath)); + } + return mqlPath + " = " + parenthesizedStrings(strings); + } + if (value == null) { + throw new IllegalArgumentException("filter.value is required for op '" + op + "' on path '" + mqlPath + "'"); + } + if ("exists".equals(op)) { + throw new IllegalStateException(); + } + validateEnumIfNeeded(value, attr, mqlPath); + + return switch (op) { + case "startswith" -> { + String s = coerceStringForEnum(value, attr, mqlPath); + yield mqlPath + " startsWith " + mqlStringLiteral(s); + } + case "contains" -> { + String s = coerceStringForEnum(value, attr, mqlPath); + yield mqlPath + " contains " + mqlStringLiteral(s); + } + case "eq" -> mqlPath + " = " + formatScalarValue(value, type, attr, mqlPath); + case "neq" -> mqlPath + " != " + formatScalarValue(value, type, attr, mqlPath); + case "gt" -> mqlPath + " > " + formatScalarValue(value, type, attr, mqlPath); + case "gte" -> mqlPath + " >= " + formatScalarValue(value, type, attr, mqlPath); + case "lt" -> mqlPath + " < " + formatScalarValue(value, type, attr, mqlPath); + case "lte" -> mqlPath + " <= " + formatScalarValue(value, type, attr, mqlPath); + default -> throw new IllegalArgumentException("Unexpected op '" + op + "'"); + }; + } + + private static String buildReferenceFilter(String mqlPath, String op, Object value) { + if ("eq".equals(op)) { + String oid = expectOidString(value, mqlPath); + return mqlPath + " matches (oid = " + mqlStringLiteral(oid) + ")"; + } + List list = expectList(value, "in", mqlPath); + if (list.isEmpty()) { + throw new IllegalArgumentException("filter.value for 'in' must be a non-empty OID array (path '" + mqlPath + "')"); + } + List parts = new ArrayList<>(); + for (Object o : list) { + String oid = expectOidString(o, mqlPath); + parts.add(mqlPath + " matches (oid = " + mqlStringLiteral(oid) + ")"); + } + if (parts.size() == 1) { + return parts.getFirst(); + } + return "(" + String.join(" or ", parts) + ")"; + } + + private static String expectOidString(Object value, String mqlPath) { + if (!(value instanceof String s) || StringUtils.isBlank(s)) { + throw new IllegalArgumentException("Reference filter on '" + mqlPath + "' requires a string OID value"); + } + s = s.trim(); + if (!UUID_PATTERN.matcher(s).matches()) { + throw new IllegalArgumentException("Reference OID must be a UUID string for path '" + mqlPath + "'"); + } + return s; + } + + private static List expectList(Object value, String op, String mqlPath) { + if (value instanceof List list) { + return list; + } + throw new IllegalArgumentException("filter.value for op '" + op + "' on path '" + mqlPath + "' must be a JSON array"); + } + + private static void validateEnumIfNeeded(Object value, MidpointMcpSchemaAttribute attr, String mqlPath) { + List allowed = attr.getEnum(); + if (allowed == null || allowed.isEmpty()) { + return; + } + String s = String.valueOf(value).trim(); + if (!allowed.contains(s)) { + throw new IllegalArgumentException( + "Value '" + s + "' is not allowed for path '" + mqlPath + "'; allowed: " + String.join(", ", allowed)); + } + } + + private static String coerceStringForEnum(Object value, MidpointMcpSchemaAttribute attr, String mqlPath) { + if (value == null) { + throw new IllegalArgumentException("filter.value is required for path '" + mqlPath + "'"); + } + String s = value instanceof String str ? str : String.valueOf(value); + validateEnumIfNeeded(s, attr, mqlPath); + return s; + } + + private static String formatScalarValue(Object value, String type, MidpointMcpSchemaAttribute attr, String mqlPath) { + return switch (type) { + case "string" -> mqlStringLiteral(coerceStringForEnum(value, attr, mqlPath)); + case "integer" -> formatInteger(value, mqlPath); + case "datetime" -> mqlStringLiteral(coerceStringForEnum(value, attr, mqlPath)); + case "boolean" -> { + if (value instanceof Boolean b) { + yield b ? "true" : "false"; + } + if ("true".equalsIgnoreCase(String.valueOf(value).trim())) { + yield "true"; + } + if ("false".equalsIgnoreCase(String.valueOf(value).trim())) { + yield "false"; + } + throw new IllegalArgumentException("Boolean value expected for path '" + mqlPath + "'"); + } + default -> mqlStringLiteral(coerceStringForEnum(value, attr, mqlPath)); + }; + } + + private static String formatInteger(Object value, String mqlPath) { + if (value instanceof Number n) { + long v = n.longValue(); + if (v != n.doubleValue()) { + throw new IllegalArgumentException("Integer value expected for path '" + mqlPath + "'"); + } + return Long.toString(v); + } + if (value instanceof String s) { + try { + return Long.toString(Long.parseLong(s.trim())); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Integer value expected for path '" + mqlPath + "'"); + } + } + throw new IllegalArgumentException("Integer value expected for path '" + mqlPath + "'"); + } + + private static String parenthesizedStrings(List values) { + StringBuilder sb = new StringBuilder("("); + for (int i = 0; i < values.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(mqlStringLiteral(values.get(i))); + } + sb.append(")"); + return sb.toString(); + } + + static String dotPathToMqlPath(String dotPath) { + return dotPath.trim().replace('.', '/'); + } + + static String mqlStringLiteral(String raw) { + if (raw.chars().anyMatch(ch -> ch < 0x20)) { + throw new IllegalArgumentException("String values must not contain control characters"); + } + String escaped = raw.replace("\\", "\\\\").replace("\"", "\\\""); + return "\"" + escaped + "\""; + } +} diff --git a/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpAttributeProjector.java b/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpAttributeProjector.java new file mode 100644 index 00000000000..e37ddf4fb31 --- /dev/null +++ b/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpAttributeProjector.java @@ -0,0 +1,661 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.impl; + +import java.util.ArrayList; +import java.util.Collection; +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 javax.xml.datatype.XMLGregorianCalendar; +import javax.xml.namespace.QName; + +import com.evolveum.midpoint.mcp.api.MidpointMcpSchemaAttribute; +import com.evolveum.midpoint.model.api.ModelService; +import com.evolveum.midpoint.model.api.expr.OrgStructFunctions; +import com.evolveum.midpoint.prism.Item; +import com.evolveum.midpoint.prism.PrismContainer; +import com.evolveum.midpoint.prism.PrismContainerValue; +import com.evolveum.midpoint.prism.PrismObject; +import com.evolveum.midpoint.prism.PrismObjectDefinition; +import com.evolveum.midpoint.prism.PrismProperty; +import com.evolveum.midpoint.prism.PrismReference; +import com.evolveum.midpoint.prism.PrismReferenceValue; +import com.evolveum.midpoint.prism.path.ItemPath; +import com.evolveum.midpoint.prism.query.ObjectQuery; +import com.evolveum.midpoint.schema.CapabilityUtil; +import com.evolveum.midpoint.schema.constants.ObjectTypes; +import com.evolveum.midpoint.schema.result.OperationResult; +import com.evolveum.midpoint.schema.util.ResourceTypeUtil; +import com.evolveum.midpoint.task.api.Task; +import com.evolveum.midpoint.util.exception.CommunicationException; +import com.evolveum.midpoint.util.exception.ConfigurationException; +import com.evolveum.midpoint.util.exception.ExpressionEvaluationException; +import com.evolveum.midpoint.util.exception.ObjectNotFoundException; +import com.evolveum.midpoint.util.exception.SchemaException; +import com.evolveum.midpoint.util.exception.SecurityViolationException; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ActivationType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.AssignmentType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.CaseType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.FocusType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ObjectType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.OrgType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ResourceType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.RoleType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ServiceType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ShadowType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.UserType; +import com.evolveum.midpoint.xml.ns._public.resource.capabilities_3.CapabilityType; +import com.evolveum.midpoint.prism.polystring.PolyString; +import com.evolveum.prism.xml.ns._public.types_3.PolyStringType; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.lang3.StringUtils; + +/** + * Projects {@link PrismObject} fields into a flat string-keyed map (dot paths), matching MCP schema tool paths. + */ +final class MidpointMcpAttributeProjector { + + private static final Set FULL_STRUCTURE_ROOTS = Set.of("assignment", "inducement", "roleMembershipRef"); + + private final com.evolveum.midpoint.prism.PrismContext prismContext; + private final ModelService modelService; + private final OrgStructFunctions orgStructFunctions; + private final ObjectMapper objectMapper = new ObjectMapper(); + + MidpointMcpAttributeProjector( + com.evolveum.midpoint.prism.PrismContext prismContext, + ModelService modelService, + OrgStructFunctions orgStructFunctions) { + this.prismContext = prismContext; + this.modelService = modelService; + this.orgStructFunctions = orgStructFunctions; + } + + /** + * @param useSearchDefaults when true and returnAttributes omitted, use lightweight search defaults; otherwise explain defaults. + */ + static List resolveRequestedPaths( + List returnAttributes, + String restType, + PrismObjectDefinition objDef, + boolean useSearchDefaults) { + if (returnAttributes == null || returnAttributes.isEmpty()) { + return useSearchDefaults + ? MidpointMcpDefaultAttributes.searchDefaultsForRestType(restType) + : MidpointMcpDefaultAttributes.explainDefaultsForRestType(restType); + } + List raw = new ArrayList<>(); + for (String s : returnAttributes) { + if (s == null || s.isBlank()) { + continue; + } + raw.add(s.trim()); + } + if (raw.isEmpty()) { + return useSearchDefaults + ? MidpointMcpDefaultAttributes.searchDefaultsForRestType(restType) + : MidpointMcpDefaultAttributes.explainDefaultsForRestType(restType); + } + if (raw.size() == 1 && MidpointMcpDefaultAttributes.STAR.equals(raw.getFirst())) { + return expandStarPaths(restType, objDef); + } + validateExplicitPaths(raw, restType, objDef); + return raw; + } + + private static List expandStarPaths(String restType, PrismObjectDefinition objDef) { + LinkedHashSet paths = new LinkedHashSet<>(); + for (MidpointMcpSchemaAttribute a : MidpointMcpSchemaFlattener.flatten(objDef, Integer.MAX_VALUE)) { + paths.add(a.getPath()); + } + for (String v : MidpointMcpDefaultAttributes.VIRTUAL_PATHS) { + if (virtualApplicable(v, restType)) { + paths.add(v); + } + } + for (String root : FULL_STRUCTURE_ROOTS) { + paths.add(root); + } + paths.add(MidpointMcpDefaultAttributes.EXTENSION_STAR); + return new ArrayList<>(paths); + } + + private static void validateExplicitPaths(List paths, String restType, PrismObjectDefinition objDef) { + Set flat = new LinkedHashSet<>(); + for (MidpointMcpSchemaAttribute a : MidpointMcpSchemaFlattener.flatten(objDef, Integer.MAX_VALUE)) { + flat.add(a.getPath()); + } + for (String p : paths) { + if (MidpointMcpDefaultAttributes.STAR.equals(p)) { + throw new IllegalArgumentException("returnAttributes: '*' cannot be combined with other paths"); + } + if (MidpointMcpDefaultAttributes.EXTENSION_STAR.equals(p)) { + continue; + } + if (MidpointMcpDefaultAttributes.VIRTUAL_PATHS.contains(p)) { + if (!virtualApplicable(p, restType)) { + throw new IllegalArgumentException("returnAttributes: path '" + p + "' is not valid for type " + restType); + } + continue; + } + if (FULL_STRUCTURE_ROOTS.contains(p)) { + continue; + } + if (p.startsWith("extension.")) { + String rest = p.substring("extension.".length()); + if (rest.isEmpty() || rest.contains(".")) { + if (!flat.contains(p)) { + throw new IllegalArgumentException("returnAttributes: unknown path '" + p + "'"); + } + } else if (!flat.contains(p)) { + throw new IllegalArgumentException("returnAttributes: unknown path '" + p + "'"); + } + continue; + } + if (!flat.contains(p)) { + throw new IllegalArgumentException("returnAttributes: unknown path '" + p + "'"); + } + } + } + + private static boolean virtualApplicable(String path, String restType) { + String t = restType.toLowerCase(Locale.ROOT); + return switch (path) { + case "oid", "type", "summary" -> true; + case "memberCount" -> "roles".equals(t) || "orgs".equals(t) || "services".equals(t); + case "childOrgCount" -> "orgs".equals(t); + case "capabilitiesSummary", "kindIntentSummary", "configured" -> "resources".equals(t); + case "workItemCount", + "activeWorkItemCount", + "currentStep", + "request", + "workItems", + "decisionHistory", + "childCases", + "explanation" -> "cases".equals(t); + default -> false; + }; + } + + /** + * @param resolveRefTargets when false, reference values omit live {@code modelService.getObject} enrichment (search performance). + */ + LinkedHashMap project( + PrismObject object, + String restType, + List requestedPaths, + String summaryText, + Task task, + OperationResult result, + boolean resolveRefTargets) { + + LinkedHashMap out = new LinkedHashMap<>(); + ObjectType bean = object.asObjectable(); + + for (String path : requestedPaths) { + if (MidpointMcpDefaultAttributes.EXTENSION_STAR.equals(path)) { + putExtensionWildcard(object, out); + continue; + } + Object value = + projectPath(path, object, bean, restType, summaryText, task, result, resolveRefTargets); + if (value != null) { + out.put(path, value); + } + } + return out; + } + + private void putExtensionWildcard(PrismObject object, Map out) { + Item ext = object.findItem(ObjectType.F_EXTENSION); + if (!(ext instanceof PrismContainer extCont) || extCont.isEmpty()) { + return; + } + for (PrismContainerValue extVal : extCont.getValues()) { + for (Item item : extVal.getItems()) { + String local = item.getElementName().getLocalPart(); + String flatKey = "extension." + local; + Object serialized = serializeItemShallow(item); + if (serialized != null) { + out.putIfAbsent(flatKey, serialized); + } + } + } + } + + private Object projectPath( + String path, + PrismObject object, + ObjectType bean, + String restType, + String summaryText, + Task task, + OperationResult result, + boolean resolveRefTargets) { + + return switch (path) { + case "oid" -> bean.getOid(); + case "type" -> restType; + case "summary" -> summaryText; + case "memberCount" -> resolveRefTargets ? memberCount(bean, task, result) : null; + case "childOrgCount" -> resolveRefTargets ? childOrgCount(bean, task, result) : null; + case "capabilitiesSummary" -> bean instanceof ResourceType r ? capabilitiesSummary(r) : null; + case "kindIntentSummary" -> bean instanceof ResourceType r ? kindIntentSummary(r) : null; + case "configured" -> bean instanceof ResourceType r ? resourceConfigured(r) : null; + case "workItemCount" -> bean instanceof CaseType c ? countCaseWorkItems(c) : null; + case "activeWorkItemCount" -> bean instanceof CaseType c ? countOpenCaseWorkItems(c) : null; + case "currentStep" -> bean instanceof CaseType c ? MidpointMcpCaseProjector.compactCurrentStepForSearch(c) : null; + case "request", "workItems", "decisionHistory", "childCases", "explanation" -> null; + default -> projectPrismPath(path, object, bean, task, result, resolveRefTargets); + }; + } + + private static int countCaseWorkItems(CaseType c) { + return c.getWorkItem() != null ? c.getWorkItem().size() : 0; + } + + private static int countOpenCaseWorkItems(CaseType c) { + if (c.getWorkItem() == null) { + return 0; + } + return (int) c.getWorkItem().stream().filter(wi -> wi.getCloseTimestamp() == null).count(); + } + + private Object projectPrismPath( + String path, + PrismObject object, + ObjectType bean, + Task task, + OperationResult result, + boolean resolveRefTargets) { + if (FULL_STRUCTURE_ROOTS.contains(path)) { + return fullStructureContainer(object, path); + } + if (path.startsWith("activation.")) { + Object fromBean = activationFieldFromBean(path, bean); + if (fromBean != null) { + return fromBean; + } + } + ItemPath itemPath = ItemPath.fromString(path); + Item item = object.findItem(itemPath); + if (item == null) { + return null; + } + if (item instanceof PrismReference ref) { + return resolveRefTargets ? referenceList(ref, task, result) : referenceListNoResolve(ref); + } + if (item instanceof PrismProperty prop) { + return propertyToJson(prop); + } + if (item instanceof PrismContainer cont) { + return containerValuesToJson(cont); + } + return null; + } + + private static Object activationFieldFromBean(String path, ObjectType bean) { + ActivationType act = null; + if (bean instanceof FocusType focus) { + act = focus.getActivation(); + } else if (bean instanceof ShadowType shadow) { + act = shadow.getActivation(); + } + if (act == null) { + return null; + } + String tail = path.substring("activation.".length()); + return switch (tail) { + case "administrativeStatus" -> enumName(act.getAdministrativeStatus()); + case "effectiveStatus" -> enumName(act.getEffectiveStatus()); + case "validFrom" -> xmlCal(act.getValidFrom()); + case "validTo" -> xmlCal(act.getValidTo()); + case "validityStatus" -> enumName(act.getValidityStatus()); + case "lockoutStatus" -> enumName(act.getLockoutStatus()); + default -> null; + }; + } + + private static String enumName(Enum e) { + return e != null ? e.name() : null; + } + + private static String xmlCal(XMLGregorianCalendar cal) { + return cal != null ? cal.toXMLFormat() : null; + } + + private Object fullStructureContainer(PrismObject object, String path) { + ItemPath itemPath = ItemPath.fromString(path); + Item item = object.findItem(itemPath); + if (!(item instanceof PrismContainer cont) || cont.isEmpty()) { + return null; + } + List elements = new ArrayList<>(); + for (PrismContainerValue pcv : cont.getValues()) { + try { + String json = prismContext.jsonSerializer().serialize(pcv); + elements.add(objectMapper.readValue(json, Object.class)); + } catch (SchemaException | JsonProcessingException e) { + // skip broken value + } + } + return elements.isEmpty() ? null : elements; + } + + private Object serializeItemShallow(Item item) { + if (item instanceof PrismProperty prop) { + return propertyToJson(prop); + } + if (item instanceof PrismReference ref) { + return referenceListNoResolve(ref); + } + if (item instanceof PrismContainer cont) { + try { + String json = prismContext.jsonSerializer().serialize(cont); + return objectMapper.readValue(json, Object.class); + } catch (SchemaException | JsonProcessingException e) { + return null; + } + } + return null; + } + + private List> referenceList(PrismReference ref, Task task, OperationResult result) { + List> list = new ArrayList<>(); + for (PrismReferenceValue rv : ref.getValues()) { + list.add(referenceMap(rv, task, result)); + } + return list.isEmpty() ? null : list; + } + + private List> referenceListNoResolve(PrismReference ref) { + List> list = new ArrayList<>(); + for (PrismReferenceValue rv : ref.getValues()) { + list.add(referenceMap(rv, null, null)); + } + return list.isEmpty() ? null : list; + } + + private Map referenceMap(PrismReferenceValue rv, Task task, OperationResult result) { + Map m = new LinkedHashMap<>(); + m.put("oid", rv.getOid()); + QName targetType = rv.getTargetType(); + if (targetType != null) { + ObjectTypes ot = ObjectTypes.getObjectTypeFromTypeQNameIfKnown(targetType); + m.put("type", ot != null ? ot.getRestType() : targetType.getLocalPart()); + } + if (rv.getRelation() != null) { + m.put("relation", rv.getRelation().getLocalPart()); + } + String tn = targetNameFromRefValue(rv); + if (tn != null) { + m.put("targetName", tn); + } + if (task != null && result != null && StringUtils.isNotBlank(rv.getOid()) && StringUtils.isBlank(tn)) { + try { + Class clazz = targetType != null + ? ObjectTypes.getObjectTypeClassIfKnown(targetType) + : ObjectType.class; + if (clazz == null) { + clazz = ObjectType.class; + } + PrismObject target = modelService.getObject(clazz, rv.getOid(), null, task, result); + m.put("targetName", nameOf(target.asObjectable())); + ObjectTypes ot = ObjectTypes.getObjectTypeIfKnown(target.getCompileTimeClass()); + if (ot != null) { + m.put("type", ot.getRestType()); + } + } catch (ObjectNotFoundException + | SecurityViolationException + | SchemaException + | CommunicationException + | ConfigurationException + | ExpressionEvaluationException e) { + // best-effort + } + } + return m; + } + + private static String targetNameFromRefValue(PrismReferenceValue rv) { + Object tn = rv.getTargetName(); + if (tn == null) { + return null; + } + if (tn instanceof PolyStringType pst) { + return pst.getOrig(); + } + if (tn instanceof PolyString ps) { + return ps.getOrig(); + } + return String.valueOf(tn); + } + + private Object propertyToJson(PrismProperty prop) { + Collection values = prop.getRealValues(); + if (values == null || values.isEmpty()) { + return null; + } + List raw = new ArrayList<>(values); + if (raw.size() == 1) { + return simplifyRealValue(raw.getFirst()); + } + List list = new ArrayList<>(); + for (Object v : raw) { + list.add(simplifyRealValue(v)); + } + return list; + } + + private Object containerValuesToJson(PrismContainer cont) { + if (cont.isEmpty()) { + return null; + } + List list = new ArrayList<>(); + for (PrismContainerValue pcv : cont.getValues()) { + try { + String json = prismContext.jsonSerializer().serialize(pcv); + list.add(objectMapper.readValue(json, Object.class)); + } catch (SchemaException | JsonProcessingException e) { + // skip + } + } + return list.size() == 1 ? list.getFirst() : list; + } + + private static Object simplifyRealValue(Object v) { + if (v == null) { + return null; + } + if (v instanceof PolyStringType p) { + return p.getOrig(); + } + if (v instanceof PolyString ps) { + return ps.getOrig(); + } + if (v instanceof Enum e) { + return e.name(); + } + if (v instanceof XMLGregorianCalendar cal) { + return cal.toXMLFormat(); + } + if (v instanceof QName q) { + return q.toString(); + } + return v; + } + + private Integer memberCount(ObjectType bean, Task task, OperationResult result) { + try { + if (bean instanceof RoleType role) { + return countUsersWithAssignmentTo(role.getOid(), task, result); + } + if (bean instanceof OrgType org) { + return countOrgMembers(org.getOid(), task, result); + } + if (bean instanceof ServiceType svc) { + return countUsersWithAssignmentTo(svc.getOid(), task, result); + } + } catch (SchemaException + | ObjectNotFoundException + | SecurityViolationException + | CommunicationException + | ConfigurationException + | ExpressionEvaluationException e) { + return null; + } + return null; + } + + private Integer childOrgCount(ObjectType bean, Task task, OperationResult result) { + if (!(bean instanceof OrgType org) || StringUtils.isBlank(org.getOid())) { + return null; + } + try { + ObjectQuery query = prismContext.queryFor(OrgType.class) + .item(OrgType.F_PARENT_ORG_REF) + .ref(org.getOid()) + .build(); + return modelService.countObjects(OrgType.class, query, null, task, result); + } catch (SchemaException + | ObjectNotFoundException + | SecurityViolationException + | CommunicationException + | ConfigurationException + | ExpressionEvaluationException e) { + return null; + } + } + + private int countUsersWithAssignmentTo(String abstractRoleOid, Task task, OperationResult result) + throws SchemaException, ObjectNotFoundException, SecurityViolationException, CommunicationException, + ConfigurationException, ExpressionEvaluationException { + ObjectQuery query = prismContext.queryFor(UserType.class) + .item(UserType.F_ASSIGNMENT, AssignmentType.F_TARGET_REF) + .ref(abstractRoleOid) + .build(); + return modelService.countObjects(UserType.class, query, null, task, result); + } + + private int countOrgMembers(String orgOid, Task task, OperationResult result) + throws SchemaException, ObjectNotFoundException, SecurityViolationException, CommunicationException, + ConfigurationException, ExpressionEvaluationException { + ObjectQuery query = prismContext.queryFor(UserType.class) + .item(UserType.F_PARENT_ORG_REF) + .ref(orgOid) + .build(); + return modelService.countObjects(UserType.class, query, null, task, result); + } + + private static String capabilitiesSummary(ResourceType resource) { + var cap = ResourceTypeUtil.getNativeCapabilitiesCollection(resource); + if (cap == null) { + return null; + } + List caps = CapabilityUtil.getAllCapabilities(cap); + if (caps.isEmpty()) { + return "none"; + } + return caps.size() + " capability(es)"; + } + + private static String kindIntentSummary(ResourceType resource) { + if (resource.getSchemaHandling() == null || resource.getSchemaHandling().getObjectType() == null) { + return null; + } + return resource.getSchemaHandling().getObjectType().size() + " object type(s)"; + } + + private static Boolean resourceConfigured(ResourceType resource) { + return resource.getConnectorRef() != null && StringUtils.isNotBlank(resource.getConnectorRef().getOid()); + } + + private static String nameOf(ObjectType object) { + return object.getName() != null ? object.getName().getOrig() : null; + } + + private static String poly(PolyStringType value) { + return value != null ? value.getOrig() : null; + } + + static String explainSummary(ObjectType object, String restType) { + String type = ObjectTypes.getRestTypeFromClass(object.getClass()); + String name = object.getName() != null ? object.getName().getOrig() : object.getOid(); + return switch (restType) { + case "users" -> { + int a = object instanceof UserType u && u.getAssignment() != null ? u.getAssignment().size() : 0; + int p = object instanceof UserType u && u.getParentOrgRef() != null ? u.getParentOrgRef().size() : 0; + yield "User '" + name + "' is a focus object with " + a + " assignment(s) and " + p + " parent org ref(s)."; + } + case "roles" -> { + int ind = object instanceof RoleType r && r.getInducement() != null ? r.getInducement().size() : 0; + yield "Role '" + name + "' with " + ind + " inducement(s)."; + } + case "orgs" -> { + int po = + object instanceof OrgType o && o.getParentOrgRef() != null ? o.getParentOrgRef().size() : 0; + yield "Organization '" + name + "' with " + po + " parent org ref(s)."; + } + case "services" -> { + int ind = object instanceof ServiceType s && s.getInducement() != null ? s.getInducement().size() : 0; + yield "Service '" + name + "' with " + ind + " inducement(s)."; + } + case "archetypes" -> { + int ind = + object instanceof com.evolveum.midpoint.xml.ns._public.common.common_3.ArchetypeType a + && a.getInducement() != null + ? a.getInducement().size() + : 0; + yield "Archetype '" + name + "' with " + ind + " inducement(s)."; + } + case "resources" -> "Resource '" + name + "' — connected system."; + case "shadows" -> "Shadow '" + name + "' — resource projection."; + case "tasks" -> "Task '" + name + "'."; + case "connectors" -> "Connector '" + name + "'."; + case "nodes" -> "Node '" + name + "'."; + case "cases" -> { + if (object instanceof CaseType c) { + int wi = countCaseWorkItems(c); + int open = countOpenCaseWorkItems(c); + String state = c.getState() != null ? String.valueOf(c.getState()) : "unknown state"; + yield "Case '" + name + "' (" + state + ") with " + open + " open / " + wi + " total work item(s)."; + } + yield type + " '" + name + "'."; + } + default -> type + " '" + name + "'."; + }; + } + + static String searchSummary(ObjectType object) { + if (object instanceof CaseType c) { + int wi = countCaseWorkItems(c); + int open = countOpenCaseWorkItems(c); + String state = c.getState() != null ? String.valueOf(c.getState()) : "unknown"; + return "cases: state " + state + ", " + open + " open work item(s), " + wi + " total"; + } + String type = ObjectTypes.getRestTypeFromClass(object.getClass()); + String lifecycle = + object instanceof com.evolveum.midpoint.xml.ns._public.common.common_3.AssignmentHolderType ah + ? StringUtils.defaultIfBlank(ah.getLifecycleState(), "no lifecycle state") + : "no lifecycle state"; + List subtypes = + object instanceof com.evolveum.midpoint.xml.ns._public.common.common_3.AssignmentHolderType ah2 + ? ah2.getSubtype() + : List.of(); + if (subtypes.isEmpty()) { + return type + " with " + lifecycle; + } + return type + " with subtypes " + String.join(", ", subtypes); + } +} diff --git a/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpAuditDeltaDetailBuilder.java b/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpAuditDeltaDetailBuilder.java new file mode 100644 index 00000000000..17d1699916c --- /dev/null +++ b/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpAuditDeltaDetailBuilder.java @@ -0,0 +1,295 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.impl; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import com.evolveum.midpoint.prism.Containerable; +import com.evolveum.midpoint.prism.Item; +import com.evolveum.midpoint.prism.PrismContainer; +import com.evolveum.midpoint.prism.PrismProperty; +import com.evolveum.midpoint.prism.PrismReference; +import com.evolveum.midpoint.prism.PrismValue; +import com.evolveum.midpoint.prism.path.ItemPath; +import com.evolveum.midpoint.util.PrettyPrinter; +import com.evolveum.midpoint.xml.ns._public.common.common_3.AssignmentHolderType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.MetadataType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ObjectType; +import com.evolveum.prism.xml.ns._public.types_3.ChangeTypeType; +import com.evolveum.prism.xml.ns._public.types_3.ItemDeltaType; +import com.evolveum.prism.xml.ns._public.types_3.ModificationTypeType; +import com.evolveum.prism.xml.ns._public.types_3.ObjectDeltaType; +import com.evolveum.prism.xml.ns._public.types_3.RawType; + +import org.apache.commons.lang3.StringUtils; + +import com.evolveum.midpoint.util.logging.Trace; +import com.evolveum.midpoint.util.logging.TraceManager; + +/** + * Builds structured attribute-level summaries from {@link ObjectDeltaType} beans stored in audit records. + */ +final class MidpointMcpAuditDeltaDetailBuilder { + + record AttributeChangesResult(List> rows, boolean truncated) {} + + private static final Trace LOGGER = TraceManager.getTrace(MidpointMcpAuditDeltaDetailBuilder.class); + + private static final String REDACTED = "***"; + + /** Max rows appended per object delta (item-level modifications + summarized add-object rows). */ + private static final int MAX_ROWS_PER_OBJECT_DELTA = 200; + + /** Max characters per old/new string (each side truncated independently). */ + private static final int MAX_VALUE_CHARS = 4000; + + private MidpointMcpAuditDeltaDetailBuilder() {} + + /** + * @return rows with keys path, modificationType, oldValue, newValue (string or null); truncated if capped + */ + static AttributeChangesResult buildAttributeChanges(ObjectDeltaType delta) { + List> rows = new ArrayList<>(); + if (delta == null) { + return new AttributeChangesResult(rows, false); + } + ChangeTypeType changeType = delta.getChangeType(); + if (changeType == ChangeTypeType.ADD && delta.getObjectToAdd() != null) { + return appendRowsForObjectToAdd(delta.getObjectToAdd(), rows); + } + if (changeType == ChangeTypeType.MODIFY) { + boolean truncated = false; + for (ItemDeltaType itemDelta : delta.getItemDelta()) { + if (rows.size() >= MAX_ROWS_PER_OBJECT_DELTA) { + truncated = true; + break; + } + appendRowForItemDelta(itemDelta, rows); + } + return new AttributeChangesResult(rows, truncated); + } + return new AttributeChangesResult(rows, false); + } + + private static AttributeChangesResult appendRowsForObjectToAdd( + com.evolveum.prism.xml.ns._public.types_3.ObjectType objectToAdd, List> rows) { + boolean truncated = false; + try { + var prism = objectToAdd.asPrismObject(); + for (Object element : prism.getValue().getItems()) { + if (!(element instanceof Item item)) { + continue; + } + if (rows.size() >= MAX_ROWS_PER_OBJECT_DELTA) { + truncated = true; + break; + } + ItemPath path = ItemPath.create(item.getElementName()); + if (isOmittedDeltaDetailPath(path) || isMetadataItemPath(path)) { + continue; + } + Map row = new LinkedHashMap<>(); + row.put("path", path.toString()); + row.put("modificationType", "ADD"); + row.put("oldValue", null); + if (isSensitiveItemPath(path)) { + row.put("newValue", REDACTED); + } else if (item instanceof PrismProperty prop) { + row.put("newValue", joinFormatted(prop.getRealValues(), path)); + } else if (item instanceof PrismReference ref) { + row.put("newValue", joinFormatted(ref.getValues(), path)); + } else if (item instanceof PrismContainer cont) { + row.put("newValue", summarizeContainer(cont)); + } else { + row.put("newValue", truncate(PrettyPrinter.prettyPrint(item))); + } + rows.add(row); + } + } catch (Exception e) { + LOGGER.debug("Could not summarize objectToAdd: {}", e.getMessage()); + Map row = new LinkedHashMap<>(); + row.put("path", "(object)"); + row.put("modificationType", "ADD"); + row.put("oldValue", null); + row.put("newValue", "[unavailable: " + e.getClass().getSimpleName() + "]"); + rows.add(row); + } + return new AttributeChangesResult(rows, truncated); + } + + private static String summarizeContainer(PrismContainer cont) { + int n = cont.size(); + return n + " container value(s)"; + } + + private static void appendRowForItemDelta(ItemDeltaType itemDelta, List> rows) { + if (itemDelta.getPath() == null) { + return; + } + ItemPath path = itemDelta.getPath().getItemPath(); + if (path == null || isOmittedDeltaDetailPath(path) || isMetadataItemPath(path)) { + return; + } + ModificationTypeType modType = itemDelta.getModificationType(); + Map row = new LinkedHashMap<>(); + row.put("path", path.toString()); + row.put("modificationType", modType != null ? modType.name() : null); + + boolean sensitive = isSensitiveItemPath(path); + if (sensitive) { + row.put("oldValue", REDACTED); + row.put("newValue", REDACTED); + rows.add(row); + return; + } + + String oldJoined = joinRawValues(itemDelta.getEstimatedOldValue(), path); + if (modType == ModificationTypeType.REPLACE) { + row.put("oldValue", StringUtils.isNotBlank(oldJoined) ? oldJoined : null); + row.put("newValue", joinRawValues(itemDelta.getValue(), path)); + } else if (modType == ModificationTypeType.ADD) { + row.put("oldValue", StringUtils.isNotBlank(oldJoined) ? oldJoined : null); + row.put("newValue", joinRawValues(itemDelta.getValue(), path)); + } else if (modType == ModificationTypeType.DELETE) { + row.put("oldValue", joinRawValues(itemDelta.getValue(), path)); + row.put("newValue", null); + } else { + row.put("oldValue", StringUtils.isNotBlank(oldJoined) ? oldJoined : null); + row.put("newValue", joinRawValues(itemDelta.getValue(), path)); + } + rows.add(row); + } + + private static String joinRawValues(Collection rawTypes, ItemPath itemPath) { + if (rawTypes == null || rawTypes.isEmpty()) { + return null; + } + List parts = new ArrayList<>(); + for (RawType raw : rawTypes) { + if (raw == null) { + continue; + } + String s = formatRawOrValue(itemPath, raw); + if (StringUtils.isNotBlank(s)) { + parts.add(s); + } + } + if (parts.isEmpty()) { + return null; + } + return truncate(String.join("; ", parts)); + } + + private static String joinFormatted(Collection values, ItemPath itemPath) { + if (values == null || values.isEmpty()) { + return null; + } + List parts = new ArrayList<>(); + for (Object value : values) { + String s = formatDeltaValue(itemPath, value); + if (StringUtils.isNotBlank(s)) { + parts.add(s); + } + } + if (parts.isEmpty()) { + return null; + } + return truncate(String.join("; ", parts)); + } + + private static String formatRawOrValue(ItemPath itemPath, Object value) { + return formatDeltaValue(itemPath, value); + } + + private static String formatDeltaValue(ItemPath itemPath, Object value) { + if (value instanceof PrismValue pv) { + value = pv.getRealValue(); + } + if (value instanceof MetadataType) { + return ""; + } + if (value instanceof RawType raw) { + try { + Object parsed = raw.getParsedRealValue(null, itemPath); + if (parsed instanceof Containerable c) { + return truncate(PrettyPrinter.prettyPrint(c.asPrismContainerValue())); + } + return truncate(PrettyPrinter.prettyPrint(parsed)); + } catch (Exception e) { + LOGGER.trace("Could not parse RawType at {}: {}", itemPath, e.getMessage()); + return truncate("[unparsed: " + e.getClass().getSimpleName() + "]"); + } + } + return truncate(PrettyPrinter.prettyPrint(value)); + } + + /** + * Focal iteration bookkeeping — omit from MCP audit delta rows (noise for human readers). + */ + private static boolean isOmittedDeltaDetailPath(ItemPath path) { + if (path == null || path.size() != 1) { + return false; + } + Object seg = path.first(); + if (!ItemPath.isName(seg)) { + return false; + } + var name = ItemPath.toName(seg); + return AssignmentHolderType.F_ITERATION.equals(name) + || AssignmentHolderType.F_ITERATION_TOKEN.equals(name); + } + + private static boolean isMetadataItemPath(ItemPath path) { + if (path == null) { + return false; + } + for (Object seg : path.getSegments()) { + if (ItemPath.isName(seg) + && ObjectType.F_METADATA.getLocalPart().equals(ItemPath.toName(seg).getLocalPart())) { + return true; + } + } + return false; + } + + private static boolean isSensitiveItemPath(ItemPath path) { + if (path == null) { + return false; + } + for (Object seg : path.getSegments()) { + if (!ItemPath.isName(seg)) { + continue; + } + String lp = ItemPath.toName(seg).getLocalPart().toLowerCase(Locale.ROOT); + if ("credentials".equals(lp) + || "password".equals(lp) + || "protectedstring".equals(lp) + || lp.contains("password") + || lp.contains("secret") + || lp.contains("passphrase") + || lp.endsWith("token")) { + return true; + } + } + return false; + } + + private static String truncate(String s) { + if (s == null) { + return null; + } + if (s.length() <= MAX_VALUE_CHARS) { + return s; + } + return s.substring(0, MAX_VALUE_CHARS) + "…"; + } +} diff --git a/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpAuditFilterBuilder.java b/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpAuditFilterBuilder.java new file mode 100644 index 00000000000..e33f1dafa5b --- /dev/null +++ b/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpAuditFilterBuilder.java @@ -0,0 +1,690 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.impl; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +import javax.xml.datatype.DatatypeConfigurationException; +import javax.xml.datatype.DatatypeFactory; +import javax.xml.datatype.XMLGregorianCalendar; + +import com.evolveum.midpoint.mcp.api.MidpointMcpAdvancedFilterSpec; +import com.evolveum.midpoint.mcp.api.MidpointMcpAdvancedQuerySpec; +import com.evolveum.midpoint.prism.PrismContext; +import com.evolveum.midpoint.prism.path.ItemName; +import com.evolveum.midpoint.prism.polystring.PolyString; +import com.evolveum.midpoint.prism.query.ObjectFilter; +import com.evolveum.midpoint.prism.query.ObjectQuery; +import com.evolveum.midpoint.schema.constants.ObjectTypes; +import com.evolveum.midpoint.xml.ns._public.common.audit_3.AuditEventRecordCustomColumnPropertyType; +import com.evolveum.midpoint.xml.ns._public.common.audit_3.AuditEventRecordType; +import com.evolveum.midpoint.xml.ns._public.common.audit_3.AuditEventStageType; +import com.evolveum.midpoint.xml.ns._public.common.audit_3.AuditEventTypeType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ObjectReferenceType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.OperationResultStatusType; + +import org.apache.commons.lang3.StringUtils; + +/** + * Builds {@link ObjectFilter} for {@link AuditEventRecordType} from MCP advanced query (audit path vocabulary). + */ +final class MidpointMcpAuditFilterBuilder { + + private static final Pattern UUID_PATTERN = Pattern.compile( + "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"); + + private MidpointMcpAuditFilterBuilder() {} + + static ObjectFilter buildAdvancedFilter(PrismContext prismContext, MidpointMcpAdvancedQuerySpec spec) { + if (spec == null) { + throw new IllegalArgumentException("advancedQuery is required"); + } + String combine = spec.getCombine(); + if (StringUtils.isBlank(combine)) { + combine = "and"; + } else { + combine = combine.trim().toLowerCase(Locale.ROOT); + if (!combine.equals("and") && !combine.equals("or")) { + throw new IllegalArgumentException("advancedQuery.combine must be 'and' or 'or'"); + } + } + List filters = spec.getFilters(); + if (filters == null || filters.isEmpty()) { + return null; + } + List parts = new ArrayList<>(); + for (MidpointMcpAdvancedFilterSpec f : filters) { + if (f == null) { + throw new IllegalArgumentException("advancedQuery.filters contains a null entry"); + } + parts.add(buildOneFilter(prismContext, f)); + } + if ("or".equals(combine)) { + return prismContext.queryFactory().createOr(parts); + } + return prismContext.queryFactory().createAnd(parts); + } + + private static ObjectFilter buildOneFilter(PrismContext pc, MidpointMcpAdvancedFilterSpec f) { + String path = f.getPath(); + if (StringUtils.isBlank(path)) { + throw new IllegalArgumentException("filter.path is required"); + } + path = path.trim(); + if (!MidpointMcpAuditSchema.isKnownPath(path)) { + throw new IllegalArgumentException("Unknown audit filter path '" + path + "'"); + } + String op = MidpointMcpAuditSchema.normalizeOp(f.getOp()); + String customKey = MidpointMcpAuditSchema.customColumnKey(path); + if (customKey != null) { + return buildCustomColumnFilter(pc, customKey, op, f.getValue()); + } + return switch (path) { + case "timestamp" -> buildTimestamp(pc, op, f.getValue()); + case "eventType" -> buildEventType(pc, op, f.getValue()); + case "eventStage" -> buildEventStage(pc, op, f.getValue()); + case "outcome" -> buildOutcome(pc, op, f.getValue()); + case "initiator.oid" -> buildInitiatorOid(pc, op, f.getValue()); + case "initiator.name" -> buildInitiatorName(pc, op, f.getValue()); + case "target.oid" -> buildTargetOid(pc, op, f.getValue()); + case "target.name" -> buildTargetName(pc, op, f.getValue()); + case "target.type" -> buildTargetType(pc, op, f.getValue()); + case "channel" -> buildStringItem(pc, op, f.getValue(), AuditEventRecordType.F_CHANNEL); + case "task.oid" -> buildStringItem(pc, op, f.getValue(), AuditEventRecordType.F_TASK_OID); + case "task.name" -> buildStringItem(pc, op, f.getValue(), AuditEventRecordType.F_TASK_IDENTIFIER); + case "node" -> buildStringItem(pc, op, f.getValue(), AuditEventRecordType.F_NODE_IDENTIFIER); + case "message" -> buildStringItem(pc, op, f.getValue(), AuditEventRecordType.F_MESSAGE); + case "sessionIdentifier" -> buildStringItem(pc, op, f.getValue(), AuditEventRecordType.F_SESSION_IDENTIFIER); + case "requestIdentifier" -> buildStringItem(pc, op, f.getValue(), AuditEventRecordType.F_REQUEST_IDENTIFIER); + case "delta" -> buildDeltaExists(pc, op, f.getValue()); + case "result" -> buildStringItem(pc, op, f.getValue(), AuditEventRecordType.F_RESULT); + default -> throw new IllegalArgumentException("Unsupported audit path '" + path + "'"); + }; + } + + private static ObjectFilter buildCustomColumnFilter(PrismContext pc, String key, String op, Object value) { + assertStringOpsOnly(key, op, List.of("eq", "neq", "contains", "startsWith", "in")); + var item = AuditEventRecordType.F_CUSTOM_COLUMN_PROPERTY; + return switch (op) { + case "eq" -> pc.queryFor(AuditEventRecordType.class) + .item(item) + .eq(new AuditEventRecordCustomColumnPropertyType().name(key).value(expectString(value, op, key))) + .build() + .getFilter(); + case "neq" -> pc.queryFor(AuditEventRecordType.class) + .not() + .item(item) + .eq(new AuditEventRecordCustomColumnPropertyType().name(key).value(expectString(value, op, key))) + .build() + .getFilter(); + case "contains" -> pc.queryFor(AuditEventRecordType.class) + .item(item) + .contains(new AuditEventRecordCustomColumnPropertyType().name(key).value(expectString(value, op, key))) + .matchingCaseIgnore() + .build() + .getFilter(); + case "startswith" -> pc.queryFor(AuditEventRecordType.class) + .item(item) + .startsWith(new AuditEventRecordCustomColumnPropertyType().name(key).value(expectString(value, op, key))) + .matchingCaseIgnore() + .build() + .getFilter(); + case "in" -> { + List list = expectNonEmptyList(value, op, key); + List ors = new ArrayList<>(); + for (Object o : list) { + ors.add(pc.queryFor(AuditEventRecordType.class) + .item(item) + .eq(new AuditEventRecordCustomColumnPropertyType().name(key).value(String.valueOf(o))) + .build() + .getFilter()); + } + yield pc.queryFactory().createOr(ors); + } + default -> throw new IllegalArgumentException("Unsupported op '" + op + "' for custom column '" + key + "'"); + }; + } + + private static ObjectFilter buildTimestamp(PrismContext pc, String op, Object value) { + assertOps(op, List.of("eq", "neq", "gt", "gte", "lt", "lte", "exists", "in"), "timestamp"); + var item = AuditEventRecordType.F_TIMESTAMP; + return switch (op) { + case "exists" -> { + requireNoValue(op, value, "timestamp"); + yield pc.queryFor(AuditEventRecordType.class).not().item(item).isNull().build().getFilter(); + } + case "eq" -> pc.queryFor(AuditEventRecordType.class) + .item(item) + .eq(parseDateTime(value, "timestamp")) + .build() + .getFilter(); + case "neq" -> pc.queryFor(AuditEventRecordType.class) + .not() + .item(item) + .eq(parseDateTime(value, "timestamp")) + .build() + .getFilter(); + case "gt" -> pc.queryFor(AuditEventRecordType.class) + .item(item) + .gt(parseDateTime(value, "timestamp")) + .build() + .getFilter(); + case "gte" -> pc.queryFor(AuditEventRecordType.class) + .item(item) + .ge(parseDateTime(value, "timestamp")) + .build() + .getFilter(); + case "lt" -> pc.queryFor(AuditEventRecordType.class) + .item(item) + .lt(parseDateTime(value, "timestamp")) + .build() + .getFilter(); + case "lte" -> pc.queryFor(AuditEventRecordType.class) + .item(item) + .le(parseDateTime(value, "timestamp")) + .build() + .getFilter(); + case "in" -> { + List list = expectNonEmptyList(value, op, "timestamp"); + List ors = new ArrayList<>(); + for (Object o : list) { + ors.add(pc.queryFor(AuditEventRecordType.class) + .item(item) + .eq(parseDateTime(o, "timestamp")) + .build() + .getFilter()); + } + yield pc.queryFactory().createOr(ors); + } + default -> throw new IllegalStateException(); + }; + } + + private static ObjectFilter buildEventType(PrismContext pc, String op, Object value) { + assertOps(op, List.of("eq", "neq", "exists", "in"), "eventType"); + var item = AuditEventRecordType.F_EVENT_TYPE; + return switch (op) { + case "exists" -> { + requireNoValue(op, value, "eventType"); + yield pc.queryFor(AuditEventRecordType.class).not().item(item).isNull().build().getFilter(); + } + case "eq" -> pc.queryFor(AuditEventRecordType.class) + .item(item) + .eq(parseEventType(value)) + .build() + .getFilter(); + case "neq" -> pc.queryFor(AuditEventRecordType.class) + .not() + .item(item) + .eq(parseEventType(value)) + .build() + .getFilter(); + case "in" -> { + List list = expectNonEmptyList(value, op, "eventType"); + List ors = new ArrayList<>(); + for (Object o : list) { + ors.add(pc.queryFor(AuditEventRecordType.class) + .item(item) + .eq(parseEventType(o)) + .build() + .getFilter()); + } + yield pc.queryFactory().createOr(ors); + } + default -> throw new IllegalStateException(); + }; + } + + private static ObjectFilter buildEventStage(PrismContext pc, String op, Object value) { + assertOps(op, List.of("eq", "neq", "exists", "in"), "eventStage"); + var item = AuditEventRecordType.F_EVENT_STAGE; + return switch (op) { + case "exists" -> { + requireNoValue(op, value, "eventStage"); + yield pc.queryFor(AuditEventRecordType.class).not().item(item).isNull().build().getFilter(); + } + case "eq" -> pc.queryFor(AuditEventRecordType.class) + .item(item) + .eq(parseEventStage(value)) + .build() + .getFilter(); + case "neq" -> pc.queryFor(AuditEventRecordType.class) + .not() + .item(item) + .eq(parseEventStage(value)) + .build() + .getFilter(); + case "in" -> { + List list = expectNonEmptyList(value, op, "eventStage"); + List ors = new ArrayList<>(); + for (Object o : list) { + ors.add(pc.queryFor(AuditEventRecordType.class) + .item(item) + .eq(parseEventStage(o)) + .build() + .getFilter()); + } + yield pc.queryFactory().createOr(ors); + } + default -> throw new IllegalStateException(); + }; + } + + private static ObjectFilter buildOutcome(PrismContext pc, String op, Object value) { + assertOps(op, List.of("eq", "neq", "exists", "in"), "outcome"); + var item = AuditEventRecordType.F_OUTCOME; + return switch (op) { + case "exists" -> { + requireNoValue(op, value, "outcome"); + yield pc.queryFor(AuditEventRecordType.class).not().item(item).isNull().build().getFilter(); + } + case "eq" -> pc.queryFor(AuditEventRecordType.class) + .item(item) + .eq(parseOutcome(value)) + .build() + .getFilter(); + case "neq" -> pc.queryFor(AuditEventRecordType.class) + .not() + .item(item) + .eq(parseOutcome(value)) + .build() + .getFilter(); + case "in" -> { + List list = expectNonEmptyList(value, op, "outcome"); + List ors = new ArrayList<>(); + for (Object o : list) { + ors.add(pc.queryFor(AuditEventRecordType.class) + .item(item) + .eq(parseOutcome(o)) + .build() + .getFilter()); + } + yield pc.queryFactory().createOr(ors); + } + default -> throw new IllegalStateException(); + }; + } + + private static ObjectFilter buildInitiatorOid(PrismContext pc, String op, Object value) { + return buildRefOid(pc, op, value, AuditEventRecordType.F_INITIATOR_REF, "initiator.oid"); + } + + private static ObjectFilter buildTargetOid(PrismContext pc, String op, Object value) { + return buildRefOid(pc, op, value, AuditEventRecordType.F_TARGET_REF, "target.oid"); + } + + private static ObjectFilter buildRefOid(PrismContext pc, String op, Object value, ItemName refItem, String pathLabel) { + assertOps(op, List.of("eq", "neq", "exists", "in"), pathLabel); + return switch (op) { + case "exists" -> { + requireNoValue(op, value, pathLabel); + yield pc.queryFor(AuditEventRecordType.class).not().item(refItem).isNull().build().getFilter(); + } + case "eq" -> pc.queryFor(AuditEventRecordType.class) + .item(refItem) + .ref(expectUuid(value, pathLabel)) + .build() + .getFilter(); + case "neq" -> pc.queryFor(AuditEventRecordType.class) + .not() + .item(refItem) + .ref(expectUuid(value, pathLabel)) + .build() + .getFilter(); + case "in" -> { + List list = expectNonEmptyList(value, op, pathLabel); + List ors = new ArrayList<>(); + for (Object o : list) { + ors.add(pc.queryFor(AuditEventRecordType.class) + .item(refItem) + .ref(expectUuid(o, pathLabel)) + .build() + .getFilter()); + } + yield pc.queryFactory().createOr(ors); + } + default -> throw new IllegalStateException(); + }; + } + + private static ObjectFilter buildInitiatorName(PrismContext pc, String op, Object value) { + return buildRefTargetName(pc, op, value, AuditEventRecordType.F_INITIATOR_REF, "initiator.name"); + } + + private static ObjectFilter buildTargetName(PrismContext pc, String op, Object value) { + return buildRefTargetName(pc, op, value, AuditEventRecordType.F_TARGET_REF, "target.name"); + } + + private static ObjectFilter buildRefTargetName(PrismContext pc, String op, Object value, ItemName refItem, String pathLabel) { + assertStringOpsOnly(pathLabel, op, List.of("eq", "neq", "contains", "startsWith", "exists", "in")); + var tn = ObjectReferenceType.F_TARGET_NAME; + return switch (op) { + case "exists" -> { + requireNoValue(op, value, pathLabel); + yield pc.queryFor(AuditEventRecordType.class).not().item(refItem, tn).isNull().build().getFilter(); + } + case "eq" -> pc.queryFor(AuditEventRecordType.class) + .item(refItem, tn) + .eq(PolyString.fromOrig(expectString(value, op, pathLabel))) + .matchingCaseIgnore() + .build() + .getFilter(); + case "neq" -> pc.queryFor(AuditEventRecordType.class) + .not() + .item(refItem, tn) + .eq(PolyString.fromOrig(expectString(value, op, pathLabel))) + .matchingCaseIgnore() + .build() + .getFilter(); + case "contains" -> pc.queryFor(AuditEventRecordType.class) + .item(refItem, tn) + .contains(PolyString.fromOrig(expectString(value, op, pathLabel))) + .matchingCaseIgnore() + .build() + .getFilter(); + case "startswith" -> pc.queryFor(AuditEventRecordType.class) + .item(refItem, tn) + .startsWith(PolyString.fromOrig(expectString(value, op, pathLabel))) + .matchingCaseIgnore() + .build() + .getFilter(); + case "in" -> { + List list = expectNonEmptyList(value, op, pathLabel); + List ors = new ArrayList<>(); + for (Object o : list) { + ors.add(pc.queryFor(AuditEventRecordType.class) + .item(refItem, tn) + .eq(PolyString.fromOrig(String.valueOf(o))) + .matchingCaseIgnore() + .build() + .getFilter()); + } + yield pc.queryFactory().createOr(ors); + } + default -> throw new IllegalStateException(); + }; + } + + private static ObjectFilter buildTargetType(PrismContext pc, String op, Object value) { + assertOps(op, List.of("eq", "neq", "exists", "in"), "target.type"); + var ref = AuditEventRecordType.F_TARGET_REF; + var typeItem = ObjectReferenceType.F_TYPE; + return switch (op) { + case "exists" -> { + requireNoValue(op, value, "target.type"); + yield pc.queryFor(AuditEventRecordType.class).not().item(ref, typeItem).isNull().build().getFilter(); + } + case "eq" -> pc.queryFor(AuditEventRecordType.class) + .item(ref, typeItem) + .eq(resolveTargetTypeQName(value)) + .build() + .getFilter(); + case "neq" -> pc.queryFor(AuditEventRecordType.class) + .not() + .item(ref, typeItem) + .eq(resolveTargetTypeQName(value)) + .build() + .getFilter(); + case "in" -> { + List list = expectNonEmptyList(value, op, "target.type"); + List ors = new ArrayList<>(); + for (Object o : list) { + ors.add(pc.queryFor(AuditEventRecordType.class) + .item(ref, typeItem) + .eq(resolveTargetTypeQName(o)) + .build() + .getFilter()); + } + yield pc.queryFactory().createOr(ors); + } + default -> throw new IllegalStateException(); + }; + } + + private static javax.xml.namespace.QName resolveTargetTypeQName(Object value) { + String s = expectString(value, "eq", "target.type").trim().toLowerCase(Locale.ROOT); + try { + return ObjectTypes.getTypeQNameFromRestType(s); + } catch (Exception e) { + throw new IllegalArgumentException("Unknown target.type alias '" + value + "'; use MCP REST collection names such as users, roles, orgs"); + } + } + + private static ObjectFilter buildStringItem(PrismContext pc, String op, Object value, ItemName item) { + String label = item.getLocalPart(); + assertStringOpsOnly(label, op, List.of("eq", "neq", "contains", "startsWith", "gt", "gte", "lt", "lte", "exists", "in")); + return switch (op) { + case "exists" -> { + requireNoValue(op, value, label); + yield pc.queryFor(AuditEventRecordType.class).not().item(item).isNull().build().getFilter(); + } + case "eq" -> pc.queryFor(AuditEventRecordType.class) + .item(item) + .eq(expectString(value, op, label)) + .build() + .getFilter(); + case "neq" -> pc.queryFor(AuditEventRecordType.class) + .not() + .item(item) + .eq(expectString(value, op, label)) + .build() + .getFilter(); + case "contains" -> pc.queryFor(AuditEventRecordType.class) + .item(item) + .contains(expectString(value, op, label)) + .matchingCaseIgnore() + .build() + .getFilter(); + case "startswith" -> pc.queryFor(AuditEventRecordType.class) + .item(item) + .startsWith(expectString(value, op, label)) + .matchingCaseIgnore() + .build() + .getFilter(); + case "gt" -> pc.queryFor(AuditEventRecordType.class) + .item(item) + .gt(expectString(value, op, label)) + .build() + .getFilter(); + case "gte" -> pc.queryFor(AuditEventRecordType.class) + .item(item) + .ge(expectString(value, op, label)) + .build() + .getFilter(); + case "lt" -> pc.queryFor(AuditEventRecordType.class) + .item(item) + .lt(expectString(value, op, label)) + .build() + .getFilter(); + case "lte" -> pc.queryFor(AuditEventRecordType.class) + .item(item) + .le(expectString(value, op, label)) + .build() + .getFilter(); + case "in" -> { + List list = expectNonEmptyList(value, op, label); + List ors = new ArrayList<>(); + for (Object o : list) { + ors.add(pc.queryFor(AuditEventRecordType.class) + .item(item) + .eq(String.valueOf(o)) + .build() + .getFilter()); + } + yield pc.queryFactory().createOr(ors); + } + default -> throw new IllegalStateException(); + }; + } + + private static ObjectFilter buildDeltaExists(PrismContext pc, String op, Object value) { + assertOps(op, List.of("exists"), "delta"); + requireNoValue(op, value, "delta"); + return pc.queryFor(AuditEventRecordType.class) + .exists(AuditEventRecordType.F_DELTA) + .build() + .getFilter(); + } + + private static void assertOps(String op, List allowed, String path) { + if (!allowed.contains(op)) { + throw new IllegalArgumentException("Unsupported op '" + op + "' for path '" + path + "'"); + } + } + + private static void assertStringOpsOnly(String path, String op, List allowed) { + assertOps(op, allowed, path); + } + + private static void requireNoValue(String op, Object value, String path) { + if (value != null) { + throw new IllegalArgumentException("filter.value must not be set for op '" + op + "' on path '" + path + "'"); + } + } + + private static String expectString(Object value, String op, String path) { + if (value == null) { + throw new IllegalArgumentException("filter.value is required for op '" + op + "' on path '" + path + "'"); + } + return value instanceof String s ? s : String.valueOf(value); + } + + private static List expectNonEmptyList(Object value, String op, String path) { + if (!(value instanceof List list)) { + throw new IllegalArgumentException("filter.value for op '" + op + "' on path '" + path + "' must be a JSON array"); + } + if (list.isEmpty()) { + throw new IllegalArgumentException("filter.value for op '" + op + "' on path '" + path + "' must be a non-empty array"); + } + return list; + } + + private static String expectUuid(Object value, String path) { + String s = expectString(value, "eq", path).trim(); + if (!UUID_PATTERN.matcher(s).matches()) { + throw new IllegalArgumentException("Value for path '" + path + "' must be a UUID string"); + } + return s; + } + + private static XMLGregorianCalendar parseDateTime(Object value, String path) { + String s = expectString(value, "eq", path).trim(); + try { + return DatatypeFactory.newInstance().newXMLGregorianCalendar(s); + } catch (DatatypeConfigurationException | IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid datetime for path '" + path + "': " + s); + } + } + + private static AuditEventTypeType parseEventType(Object value) { + String raw = expectString(value, "eq", "eventType").trim(); + String norm = raw.replace('-', '_').toUpperCase(Locale.ROOT); + try { + return AuditEventTypeType.valueOf(norm); + } catch (IllegalArgumentException ignored) { + for (AuditEventTypeType t : AuditEventTypeType.values()) { + if (t.value().equalsIgnoreCase(raw) || t.name().equalsIgnoreCase(raw)) { + return t; + } + } + } + throw new IllegalArgumentException("Unknown eventType '" + raw + "'"); + } + + private static AuditEventStageType parseEventStage(Object value) { + String raw = expectString(value, "eq", "eventStage").trim(); + try { + return AuditEventStageType.valueOf(raw.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ignored) { + for (AuditEventStageType t : AuditEventStageType.values()) { + if (t.value().equalsIgnoreCase(raw) || t.name().equalsIgnoreCase(raw)) { + return t; + } + } + } + throw new IllegalArgumentException("Unknown eventStage '" + raw + "'"); + } + + private static OperationResultStatusType parseOutcome(Object value) { + String raw = expectString(value, "eq", "outcome").trim(); + String alias = raw.toUpperCase(Locale.ROOT); + if ("FAILURE".equals(alias)) { + return OperationResultStatusType.FATAL_ERROR; + } + if ("PARTIAL_ERROR".equals(alias)) { + return OperationResultStatusType.PARTIAL_ERROR; + } + try { + return OperationResultStatusType.valueOf(alias); + } catch (IllegalArgumentException ignored) { + for (OperationResultStatusType t : OperationResultStatusType.values()) { + if (t.value().equalsIgnoreCase(raw) || t.name().equalsIgnoreCase(raw)) { + return t; + } + } + } + throw new IllegalArgumentException("Unknown outcome '" + raw + "'"); + } + + /** Human-oriented summary of advanced filters for {@link com.evolveum.midpoint.mcp.api.MidpointMcpAuditSearchResult#setTranslatedQuery}. */ + static String describeAdvancedFilter(ObjectFilter filter) { + if (filter == null) { + return null; + } + return filter.toString(); + } + + static ObjectQuery buildSimpleTextQuery(PrismContext pc, String queryText) { + if (StringUtils.isBlank(queryText)) { + return null; + } + String q = queryText.trim(); + List tokens = new ArrayList<>(); + for (String t : q.split("\\s+")) { + if (StringUtils.isNotBlank(t)) { + tokens.add(t.trim()); + } + } + if (tokens.isEmpty()) { + return null; + } + List tokenFilters = new ArrayList<>(); + for (String token : tokens) { + List ors = new ArrayList<>(); + ors.add(pc.queryFor(AuditEventRecordType.class) + .item(AuditEventRecordType.F_MESSAGE) + .contains(token) + .matchingCaseIgnore() + .build() + .getFilter()); + ors.add(pc.queryFor(AuditEventRecordType.class) + .item(AuditEventRecordType.F_TASK_IDENTIFIER) + .contains(token) + .matchingCaseIgnore() + .build() + .getFilter()); + if (UUID_PATTERN.matcher(token).matches()) { + ors.add(pc.queryFor(AuditEventRecordType.class) + .item(AuditEventRecordType.F_TARGET_REF) + .ref(token) + .build() + .getFilter()); + ors.add(pc.queryFor(AuditEventRecordType.class) + .item(AuditEventRecordType.F_INITIATOR_REF) + .ref(token) + .build() + .getFilter()); + } + tokenFilters.add(pc.queryFactory().createOr(ors)); + } + ObjectFilter combined = pc.queryFactory().createAnd(tokenFilters); + return pc.queryFactory().createQuery(combined); + } +} diff --git a/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpAuditNormalizer.java b/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpAuditNormalizer.java new file mode 100644 index 00000000000..de3193c44a3 --- /dev/null +++ b/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpAuditNormalizer.java @@ -0,0 +1,283 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.impl; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.Map; + +import javax.xml.datatype.XMLGregorianCalendar; + +import com.evolveum.midpoint.audit.api.AuditEventRecord; +import com.evolveum.midpoint.mcp.api.MidpointMcpAuditExplainResult; +import com.evolveum.midpoint.mcp.api.MidpointMcpAuditInitiatorView; +import com.evolveum.midpoint.mcp.api.MidpointMcpAuditRecordSummary; +import com.evolveum.midpoint.mcp.api.MidpointMcpAuditTargetView; +import com.evolveum.midpoint.mcp.api.MidpointMcpAuditTaskView; +import com.evolveum.midpoint.prism.PrismContext; +import com.evolveum.midpoint.schema.constants.ObjectTypes; +import com.evolveum.midpoint.xml.ns._public.common.audit_3.AuditEventRecordType; +import com.evolveum.midpoint.xml.ns._public.common.audit_3.AuditEventStageType; +import com.evolveum.midpoint.xml.ns._public.common.audit_3.AuditEventTypeType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ObjectDeltaOperationType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ObjectReferenceType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ObjectType; +import com.evolveum.midpoint.util.exception.SchemaException; +import com.evolveum.midpoint.util.logging.Trace; +import com.evolveum.midpoint.util.logging.TraceManager; + +import org.apache.commons.lang3.StringUtils; + +final class MidpointMcpAuditNormalizer { + + private static final Trace LOGGER = TraceManager.getTrace(MidpointMcpAuditNormalizer.class); + + private static final int MAX_DELTA_OPS = 50; + private static final int MAX_RESULT_CHARS = 8000; + + private MidpointMcpAuditNormalizer() {} + + static MidpointMcpAuditRecordSummary toSummary(AuditEventRecordType r) { + MidpointMcpAuditRecordSummary s = new MidpointMcpAuditRecordSummary(); + s.setId(r.getEventIdentifier()); + s.setTimestamp(formatTs(r.getTimestamp())); + if (r.getEventType() != null) { + s.setEventType(r.getEventType().name()); + } + if (r.getEventStage() != null) { + s.setEventStage(r.getEventStage().name()); + } + if (r.getOutcome() != null) { + s.setOutcome(r.getOutcome().name()); + } + s.setInitiator(initiatorView(r.getInitiatorRef())); + s.setTarget(targetView(r.getTargetRef())); + s.setChannel(r.getChannel()); + s.setTask(taskView(r)); + s.setNode(r.getNodeIdentifier()); + s.setMessage(r.getMessage()); + s.setSummary(buildSummary(r)); + return s; + } + + static MidpointMcpAuditExplainResult toExplain( + AuditEventRecordType r, boolean includeDelta, boolean includeResult, PrismContext prismContext) { + MidpointMcpAuditExplainResult e = new MidpointMcpAuditExplainResult(); + e.setId(r.getEventIdentifier()); + e.setTimestamp(formatTs(r.getTimestamp())); + if (r.getEventType() != null) { + e.setEventType(r.getEventType().name()); + } + if (r.getEventStage() != null) { + e.setEventStage(r.getEventStage().name()); + } + if (r.getOutcome() != null) { + e.setOutcome(r.getOutcome().name()); + } + e.setInitiator(initiatorView(r.getInitiatorRef())); + e.setTarget(targetView(r.getTargetRef())); + e.setChannel(r.getChannel()); + e.setTask(taskView(r)); + e.setNode(r.getNodeIdentifier()); + e.setMessage(r.getMessage()); + String summary = buildSummary(r); + e.setSummary(summary); + e.setExplanation(buildExplanation(r, summary)); + if (includeDelta) { + if (prismContext != null) { + try { + AuditEventRecord.adopt(r); + } catch (SchemaException ex) { + LOGGER.debug("Could not adopt audit record {} deltas for MCP explain: {}", r.getEventIdentifier(), ex.getMessage()); + } + } + e.setDelta(deltaPayload(r)); + } + if (includeResult) { + e.setResult(resultPayload(r)); + } + return e; + } + + private static MidpointMcpAuditInitiatorView initiatorView(ObjectReferenceType ref) { + if (ref == null || StringUtils.isBlank(ref.getOid())) { + return null; + } + MidpointMcpAuditInitiatorView v = new MidpointMcpAuditInitiatorView(); + v.setOid(ref.getOid()); + v.setName(refName(ref)); + return v; + } + + private static MidpointMcpAuditTargetView targetView(ObjectReferenceType ref) { + if (ref == null || StringUtils.isBlank(ref.getOid())) { + return null; + } + MidpointMcpAuditTargetView v = new MidpointMcpAuditTargetView(); + v.setOid(ref.getOid()); + v.setName(refName(ref)); + v.setType(restTypeFromRef(ref)); + return v; + } + + private static String refName(ObjectReferenceType ref) { + if (ref.getTargetName() != null && ref.getTargetName().getOrig() != null) { + return ref.getTargetName().getOrig(); + } + return ref.getDescription(); + } + + private static String restTypeFromRef(ObjectReferenceType ref) { + if (ref.getType() == null) { + return null; + } + try { + Class clazz = ObjectTypes.getObjectTypeClass(ref.getType()); + return ObjectTypes.getRestTypeFromClass(clazz); + } catch (Exception e) { + return ref.getType().getLocalPart(); + } + } + + private static MidpointMcpAuditTaskView taskView(AuditEventRecordType r) { + if (StringUtils.isBlank(r.getTaskOID()) && StringUtils.isBlank(r.getTaskIdentifier())) { + return null; + } + MidpointMcpAuditTaskView t = new MidpointMcpAuditTaskView(); + t.setOid(r.getTaskOID()); + t.setName(r.getTaskIdentifier()); + return t; + } + + private static String formatTs(XMLGregorianCalendar cal) { + if (cal == null) { + return null; + } + try { + Instant i = cal.toGregorianCalendar().toInstant(); + return DateTimeFormatter.ISO_INSTANT.format(i); + } catch (Exception e) { + return cal.toXMLFormat(); + } + } + + private static String buildSummary(AuditEventRecordType r) { + String who = refNameOrUnknown(r.getInitiatorRef()); + String target = refNameOrUnknown(r.getTargetRef()); + String verb = eventVerb(r.getEventType()); + String stage = r.getEventStage() != null ? r.getEventStage().name().toLowerCase(Locale.ROOT) : "unknown stage"; + String outcome = r.getOutcome() != null ? r.getOutcome().name().toLowerCase(Locale.ROOT) : "unknown outcome"; + return String.format(Locale.ROOT, "%s %s %s during %s (%s)", who, verb, target, stage, outcome); + } + + private static String refNameOrUnknown(ObjectReferenceType ref) { + if (ref == null) { + return "unknown actor"; + } + String n = refName(ref); + return StringUtils.isNotBlank(n) ? n : (StringUtils.isNotBlank(ref.getOid()) ? ref.getOid() : "unknown"); + } + + private static String eventVerb(AuditEventTypeType type) { + if (type == null) { + return "performed an operation on"; + } + return switch (type) { + case ADD_OBJECT -> "added"; + case MODIFY_OBJECT -> "modified"; + case DELETE_OBJECT -> "deleted"; + case GET_OBJECT -> "read"; + default -> "acted on"; + }; + } + + private static String buildExplanation(AuditEventRecordType r, String summary) { + StringBuilder sb = new StringBuilder(); + sb.append("This audit record shows "); + if (r.getEventStage() == AuditEventStageType.EXECUTION) { + sb.append("an execution-stage "); + } else if (r.getEventStage() == AuditEventStageType.REQUEST) { + sb.append("a request-stage "); + } else if (r.getEventStage() != null) { + sb.append(r.getEventStage().name().toLowerCase(Locale.ROOT)).append("-stage "); + } else { + sb.append("an "); + } + if (r.getEventType() != null) { + sb.append(r.getEventType().name().toLowerCase(Locale.ROOT).replace('_', ' ')); + } else { + sb.append("event"); + } + sb.append(" involving target ").append(refNameOrUnknown(r.getTargetRef())); + sb.append(" initiated by ").append(refNameOrUnknown(r.getInitiatorRef())); + if (StringUtils.isNotBlank(r.getChannel())) { + sb.append(" over channel ").append(r.getChannel()); + } + sb.append(". Outcome: ") + .append(r.getOutcome() != null ? r.getOutcome().name() : "unknown") + .append(". "); + sb.append(summary); + return sb.toString(); + } + + private static Map deltaPayload(AuditEventRecordType r) { + Map map = new LinkedHashMap<>(); + List deltas = r.getDelta(); + if (deltas == null || deltas.isEmpty()) { + map.put("operations", List.of()); + map.put("truncated", false); + return map; + } + boolean truncated = deltas.size() > MAX_DELTA_OPS; + List> ops = new ArrayList<>(); + int n = Math.min(deltas.size(), MAX_DELTA_OPS); + for (int i = 0; i < n; i++) { + ObjectDeltaOperationType d = deltas.get(i); + Map one = new LinkedHashMap<>(); + if (d.getObjectDelta() != null && d.getObjectDelta().getObjectType() != null) { + one.put("objectType", d.getObjectDelta().getObjectType().getLocalPart()); + } + if (d.getObjectDelta() != null && StringUtils.isNotBlank(d.getObjectDelta().getOid())) { + one.put("oid", d.getObjectDelta().getOid()); + } + if (d.getObjectDelta() != null && d.getObjectDelta().getChangeType() != null) { + one.put("changeType", String.valueOf(d.getObjectDelta().getChangeType())); + } + if (StringUtils.isNotBlank(d.getResourceOid())) { + one.put("resourceOid", d.getResourceOid()); + } + if (d.getObjectDelta() != null) { + MidpointMcpAuditDeltaDetailBuilder.AttributeChangesResult detail = + MidpointMcpAuditDeltaDetailBuilder.buildAttributeChanges(d.getObjectDelta()); + one.put("attributeChanges", detail.rows()); + one.put("attributeChangesTruncated", detail.truncated()); + } + ops.add(one); + } + map.put("operations", ops); + map.put("truncated", truncated); + return map; + } + + private static Map resultPayload(AuditEventRecordType r) { + Map map = new LinkedHashMap<>(); + String res = r.getResult(); + if (res == null) { + map.put("text", null); + map.put("truncated", false); + return map; + } + boolean truncated = res.length() > MAX_RESULT_CHARS; + map.put("text", truncated ? res.substring(0, MAX_RESULT_CHARS) : res); + map.put("truncated", truncated); + return map; + } +} diff --git a/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpAuditSchema.java b/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpAuditSchema.java new file mode 100644 index 00000000000..0d41ce5bc0d --- /dev/null +++ b/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpAuditSchema.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.impl; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +import com.evolveum.midpoint.mcp.api.MidpointMcpSchemaAttribute; +import com.evolveum.midpoint.prism.path.ItemPath; + +import org.apache.commons.lang3.StringUtils; + +/** + * Static MCP audit path vocabulary (not object schema). Used for validation, ordering, and filter translation. + */ +final class MidpointMcpAuditSchema { + + private static final Pattern CUSTOM_COLUMN_PATH = Pattern.compile("^customColumn\\.(.+)$"); + + private MidpointMcpAuditSchema() {} + + static Map attributesByPath() { + Map map = new LinkedHashMap<>(); + put(map, "timestamp", "datetime"); + put(map, "eventType", "string"); + put(map, "eventStage", "string"); + put(map, "outcome", "string"); + put(map, "initiator.oid", "string"); + put(map, "initiator.name", "string"); + put(map, "target.oid", "string"); + put(map, "target.name", "string"); + put(map, "target.type", "string"); + put(map, "channel", "string"); + put(map, "task.oid", "string"); + put(map, "task.name", "string"); + put(map, "node", "string"); + put(map, "message", "string"); + put(map, "delta", "string"); + put(map, "result", "string"); + put(map, "sessionIdentifier", "string"); + put(map, "requestIdentifier", "string"); + return map; + } + + private static void put(Map map, String path, String type) { + MidpointMcpSchemaAttribute a = new MidpointMcpSchemaAttribute(); + a.setPath(path); + a.setType(type); + map.put(path, a); + } + + /** Returns custom column property name or null if path is not {@code customColumn.}. */ + static String customColumnKey(String path) { + if (StringUtils.isBlank(path)) { + return null; + } + var m = CUSTOM_COLUMN_PATH.matcher(path.trim()); + return m.matches() ? m.group(1) : null; + } + + static boolean isKnownPath(String path) { + if (StringUtils.isBlank(path)) { + return false; + } + String p = path.trim(); + if (attributesByPath().containsKey(p)) { + return true; + } + return customColumnKey(p) != null; + } + + /** + * Item paths for {@code orderBy}; only single-segment Prism paths (audit top-level items). + */ + static ItemPath orderByItemPath(String mcpPath) { + if (StringUtils.isBlank(mcpPath)) { + throw new IllegalArgumentException("orderBy.path is required"); + } + String p = mcpPath.trim(); + return switch (p) { + case "timestamp" -> com.evolveum.midpoint.xml.ns._public.common.audit_3.AuditEventRecordType.F_TIMESTAMP; + case "eventType" -> com.evolveum.midpoint.xml.ns._public.common.audit_3.AuditEventRecordType.F_EVENT_TYPE; + case "eventStage" -> com.evolveum.midpoint.xml.ns._public.common.audit_3.AuditEventRecordType.F_EVENT_STAGE; + case "outcome" -> com.evolveum.midpoint.xml.ns._public.common.audit_3.AuditEventRecordType.F_OUTCOME; + case "channel" -> com.evolveum.midpoint.xml.ns._public.common.audit_3.AuditEventRecordType.F_CHANNEL; + case "message" -> com.evolveum.midpoint.xml.ns._public.common.audit_3.AuditEventRecordType.F_MESSAGE; + case "node" -> com.evolveum.midpoint.xml.ns._public.common.audit_3.AuditEventRecordType.F_NODE_IDENTIFIER; + case "task.name" -> com.evolveum.midpoint.xml.ns._public.common.audit_3.AuditEventRecordType.F_TASK_IDENTIFIER; + case "task.oid" -> com.evolveum.midpoint.xml.ns._public.common.audit_3.AuditEventRecordType.F_TASK_OID; + case "sessionIdentifier" -> com.evolveum.midpoint.xml.ns._public.common.audit_3.AuditEventRecordType.F_SESSION_IDENTIFIER; + case "requestIdentifier" -> com.evolveum.midpoint.xml.ns._public.common.audit_3.AuditEventRecordType.F_REQUEST_IDENTIFIER; + default -> throw new IllegalArgumentException( + "orderBy path '" + p + "' is not supported for audit (use timestamp, eventType, eventStage, outcome, " + + "channel, message, node, task.name, task.oid, sessionIdentifier, requestIdentifier)"); + }; + } + + static void validateOrderByPaths(List orderBy) { + if (orderBy == null || orderBy.isEmpty()) { + return; + } + for (var ob : orderBy) { + if (ob == null || StringUtils.isBlank(ob.getPath())) { + throw new IllegalArgumentException("orderBy.path is required for each entry"); + } + orderByItemPath(ob.getPath().trim()); + } + } + + static String normalizeOp(String op) { + if (StringUtils.isBlank(op)) { + throw new IllegalArgumentException("filter.op is required"); + } + return op.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpCaseProjector.java b/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpCaseProjector.java new file mode 100644 index 00000000000..a910222ea46 --- /dev/null +++ b/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpCaseProjector.java @@ -0,0 +1,534 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.impl; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.xml.namespace.QName; + +import com.evolveum.midpoint.model.api.ModelService; +import com.evolveum.midpoint.prism.PrismObject; +import com.evolveum.midpoint.schema.constants.ObjectTypes; +import com.evolveum.midpoint.schema.util.ValueMetadataTypeUtil; +import com.evolveum.midpoint.schema.util.cases.ApprovalContextUtil; +import com.evolveum.midpoint.schema.util.cases.CaseTypeUtil; +import com.evolveum.midpoint.schema.result.OperationResult; +import com.evolveum.midpoint.schema.SearchResultList; +import com.evolveum.midpoint.task.api.Task; +import com.evolveum.midpoint.util.exception.CommunicationException; +import com.evolveum.midpoint.util.exception.ConfigurationException; +import com.evolveum.midpoint.util.exception.ExpressionEvaluationException; +import com.evolveum.midpoint.util.exception.ObjectNotFoundException; +import com.evolveum.midpoint.util.exception.SchemaException; +import com.evolveum.midpoint.util.exception.SecurityViolationException; +import com.evolveum.midpoint.xml.ns._public.common.common_3.AbstractWorkItemOutputType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.CaseType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.CaseWorkItemType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ObjectReferenceType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ObjectTreeDeltasType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ObjectType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.WorkItemCompletionEventType; +import com.evolveum.midpoint.prism.query.ObjectQuery; +import com.evolveum.midpoint.prism.PrismContext; +import com.evolveum.prism.xml.ns._public.types_3.ChangeTypeType; +import com.evolveum.prism.xml.ns._public.types_3.PolyStringType; + +import org.apache.commons.lang3.StringUtils; + +/** + * Normalized workflow view for {@link CaseType} on top of {@link MidpointMcpAttributeProjector} output. + */ +final class MidpointMcpCaseProjector { + + private static final int MAX_CHILD_CASES = 20; + + private MidpointMcpCaseProjector() {} + + static void enrichExplain( + LinkedHashMap values, + PrismObject caseObject, + PrismContext prismContext, + ModelService modelService, + Task task, + OperationResult result) + throws SchemaException, ObjectNotFoundException, SecurityViolationException, CommunicationException, + ConfigurationException, ExpressionEvaluationException { + + CaseType aCase = caseObject.asObjectable(); + List workItems = aCase.getWorkItem() != null ? aCase.getWorkItem() : List.of(); + int totalWi = workItems.size(); + int activeWi = (int) workItems.stream().filter(wi -> wi.getCloseTimestamp() == null).count(); + + List> childSummaries = loadChildCaseSummaries(prismContext, modelService, aCase.getOid(), task, result); + int activeInChildren = + childSummaries.stream().mapToInt(c -> toInt(c.get("activeWorkItemCount"))).sum(); + + Map objectRefNorm = resolveSingleRef(aCase.getObjectRef(), modelService, task, result); + if (objectRefNorm != null) { + values.put("objectRef", objectRefNorm); + } + + values.put("request", buildRequestView(aCase)); + values.put("currentStep", buildCurrentStep(aCase, activeWi, activeInChildren)); + values.put("workItems", buildWorkItemViews(workItems, modelService, task, result)); + values.put("decisionHistory", buildDecisionHistory(aCase, workItems, modelService, task, result)); + values.put("childCases", childSummaries); + + String oneLine = buildOneLineSummary(aCase, activeWi, activeInChildren, childSummaries.size()); + String expl = buildExplanation(aCase, activeWi, activeInChildren, workItems, childSummaries); + values.put("summary", oneLine); + values.put("explanation", expl); + + values.put("workItemCount", totalWi); + values.put("activeWorkItemCount", activeWi); + if (ValueMetadataTypeUtil.getCreateTimestamp(aCase) != null) { + values.put( + "created", + ValueMetadataTypeUtil.getCreateTimestamp(aCase).toXMLFormat()); + } + if (aCase.getCloseTimestamp() != null) { + values.put("closed", aCase.getCloseTimestamp().toXMLFormat()); + } + } + + private static int toInt(Object o) { + if (o instanceof Number n) { + return n.intValue(); + } + return 0; + } + + private static Map buildRequestView(CaseType aCase) { + Map m = new LinkedHashMap<>(); + String type = deriveChangeTypeLabel(aCase); + if (type != null) { + m.put("type", type); + } + String summary = aCase.getName() != null ? aCase.getName().getOrig() : null; + if (summary != null) { + m.put("summary", summary); + } + return m.isEmpty() ? null : m; + } + + private static String deriveChangeTypeLabel(CaseType aCase) { + if (aCase.getApprovalContext() == null || aCase.getApprovalContext().getDeltasToApprove() == null) { + return null; + } + ObjectTreeDeltasType deltas = aCase.getApprovalContext().getDeltasToApprove(); + if (deltas.getFocusPrimaryDelta() != null) { + ChangeTypeType ct = deltas.getFocusPrimaryDelta().getChangeType(); + return ct != null ? ct.name() : null; + } + return null; + } + + private static Map buildCurrentStep(CaseType aCase, int activeWi, int activeInChildren) { + Map step = new LinkedHashMap<>(); + boolean closed = CaseTypeUtil.isClosed(aCase); + String state = aCase.getState() != null ? aCase.getState().toString() : null; + String stateLocal = state != null && state.contains("#") ? state.substring(state.lastIndexOf('#') + 1) : state; + String outcome = uriLastSegment(aCase.getOutcome()); + + if (closed) { + step.put("active", false); + if ("reject".equalsIgnoreCase(outcome) || "rejected".equalsIgnoreCase(outcome)) { + step.put("name", "Rejected"); + } else if ("approve".equalsIgnoreCase(outcome) || "approved".equalsIgnoreCase(outcome) + || "success".equalsIgnoreCase(outcome)) { + step.put("name", "Completed"); + } else { + step.put("name", outcome != null ? outcome : "Completed"); + } + return step; + } + + if ("executing".equalsIgnoreCase(stateLocal)) { + step.put("name", "Execution"); + step.put("active", true); + return step; + } + if ("closing".equalsIgnoreCase(stateLocal)) { + step.put("name", "Processing"); + step.put("active", true); + return step; + } + + if (activeWi > 0) { + String stage = ApprovalContextUtil.getStageName(aCase); + if (StringUtils.isBlank(stage)) { + stage = "Approval"; + } + step.put("name", stage); + step.put("active", true); + return step; + } + + if (activeInChildren > 0) { + step.put("name", "Approval (child case)"); + step.put("active", true); + return step; + } + + step.put("name", stateLocal != null ? StringUtils.capitalize(stateLocal) : "Processing"); + step.put("active", !closed); + return step; + } + + private static List> buildWorkItemViews( + List workItems, + ModelService modelService, + Task task, + OperationResult result) + throws SchemaException, ObjectNotFoundException, SecurityViolationException, CommunicationException, + ConfigurationException, ExpressionEvaluationException { + + List> out = new ArrayList<>(); + for (CaseWorkItemType wi : workItems) { + Map row = new LinkedHashMap<>(); + if (wi.getId() != null) { + row.put("id", String.valueOf(wi.getId())); + } + if (wi.getName() != null) { + row.put("name", wi.getName().getOrig()); + } + boolean open = wi.getCloseTimestamp() == null; + row.put("state", open ? "open" : "closed"); + if (wi.getCreateTimestamp() != null) { + row.put("created", wi.getCreateTimestamp().toXMLFormat()); + } + if (wi.getDeadline() != null) { + row.put("deadline", wi.getDeadline().toXMLFormat()); + } + if (wi.getCloseTimestamp() != null) { + row.put("completed", wi.getCloseTimestamp().toXMLFormat()); + } + ObjectReferenceType assignee = wi.getAssigneeRef() != null && !wi.getAssigneeRef().isEmpty() + ? wi.getAssigneeRef().getFirst() + : null; + Map assigneeView = resolveRef(assignee, modelService, task, result); + if (assigneeView != null) { + row.put("assignee", assigneeView); + } + List> cand = new ArrayList<>(); + if (wi.getCandidateRef() != null) { + for (ObjectReferenceType cr : wi.getCandidateRef()) { + Map cv = resolveRef(cr, modelService, task, result); + if (cv != null) { + cand.add(cv); + } + } + } + if (!cand.isEmpty()) { + row.put("candidates", cand); + } + if (wi.getOutput() != null) { + AbstractWorkItemOutputType o = wi.getOutput(); + if (o.getOutcome() != null) { + row.put("outcome", uriLastSegment(o.getOutcome())); + } + if (StringUtils.isNotBlank(o.getComment())) { + row.put("comment", o.getComment()); + } + } + out.add(row); + } + return out; + } + + private static List> buildDecisionHistory( + CaseType aCase, + List workItems, + ModelService modelService, + Task task, + OperationResult result) + throws SchemaException, ObjectNotFoundException, SecurityViolationException, CommunicationException, + ConfigurationException, ExpressionEvaluationException { + + List> rows = new ArrayList<>(); + for (CaseWorkItemType wi : workItems) { + if (wi.getCloseTimestamp() == null || wi.getOutput() == null) { + continue; + } + ObjectReferenceType actor = wi.getPerformerRef() != null ? wi.getPerformerRef() : firstAssignee(wi); + Map actorView = resolveRef(actor, modelService, task, result); + if (actorView == null && actor != null) { + actorView = Map.of("oid", actor.getOid()); + } + Map row = new LinkedHashMap<>(); + row.put("actor", actorView); + if (wi.getOutput().getOutcome() != null) { + row.put("decision", uriLastSegment(wi.getOutput().getOutcome())); + } + row.put("outcome", "SUCCESS"); + if (StringUtils.isNotBlank(wi.getOutput().getComment())) { + row.put("comment", wi.getOutput().getComment()); + } + row.put("timestamp", wi.getCloseTimestamp().toXMLFormat()); + rows.add(row); + } + + Set seenTimestamps = new LinkedHashSet<>(); + for (Map row : rows) { + Object t = row.get("timestamp"); + if (t != null) { + seenTimestamps.add(String.valueOf(t)); + } + } + + if (aCase.getEvent() != null) { + for (var ev : aCase.getEvent()) { + if (ev instanceof WorkItemCompletionEventType wce && wce.getOutput() != null) { + String tsXml = wce.getTimestamp().toXMLFormat(); + if (seenTimestamps.contains(tsXml)) { + continue; + } + Map row = new LinkedHashMap<>(); + ObjectReferenceType actor = wce.getAttorneyRef() != null ? wce.getAttorneyRef() : wce.getInitiatorRef(); + row.put("actor", resolveRef(actor, modelService, task, result)); + if (wce.getOutput().getOutcome() != null) { + row.put("decision", uriLastSegment(wce.getOutput().getOutcome())); + } + row.put("outcome", "SUCCESS"); + if (StringUtils.isNotBlank(wce.getOutput().getComment())) { + row.put("comment", wce.getOutput().getComment()); + } + row.put("timestamp", tsXml); + rows.add(row); + seenTimestamps.add(tsXml); + } + } + } + + rows.sort(Comparator.comparing(r -> String.valueOf(r.get("timestamp")))); + return rows; + } + + private static ObjectReferenceType firstAssignee(CaseWorkItemType wi) { + if (wi.getAssigneeRef() == null || wi.getAssigneeRef().isEmpty()) { + return null; + } + return wi.getAssigneeRef().getFirst(); + } + + private static List> loadChildCaseSummaries( + PrismContext prismContext, + ModelService modelService, + String parentOid, + Task task, + OperationResult result) + throws SchemaException, ObjectNotFoundException, SecurityViolationException, CommunicationException, + ConfigurationException, ExpressionEvaluationException { + + if (StringUtils.isBlank(parentOid)) { + return List.of(); + } + ObjectQuery query = prismContext.queryFor(CaseType.class) + .item(CaseType.F_PARENT_REF) + .ref(parentOid) + .maxSize(MAX_CHILD_CASES) + .build(); + SearchResultList> children = modelService.searchObjects(CaseType.class, query, null, task, result); + List> out = new ArrayList<>(); + for (PrismObject ch : children) { + CaseType c = ch.asObjectable(); + List wis = c.getWorkItem() != null ? c.getWorkItem() : List.of(); + int total = wis.size(); + int active = (int) wis.stream().filter(w -> w.getCloseTimestamp() == null).count(); + Map m = new LinkedHashMap<>(); + m.put("oid", c.getOid()); + if (c.getName() != null) { + m.put("name", c.getName().getOrig()); + } + if (c.getState() != null) { + m.put("state", uriLastSegment(c.getState().toString())); + } + m.put("workItemCount", total); + m.put("activeWorkItemCount", active); + out.add(m); + } + return out; + } + + private static String buildOneLineSummary( + CaseType aCase, int activeWi, int activeInChildren, int childCount) { + if (CaseTypeUtil.isClosed(aCase)) { + String o = uriLastSegment(aCase.getOutcome()); + if ("reject".equalsIgnoreCase(o) || "rejected".equalsIgnoreCase(o)) { + return "Case closed: rejected."; + } + return "Case closed: completed."; + } + if (activeWi > 0) { + return activeWi == 1 + ? "One approval work item is open." + : activeWi + " parallel approval work items are open."; + } + if (activeInChildren > 0) { + return "Approval is active in " + (childCount == 1 ? "a child case" : childCount + " child cases") + + " (" + activeInChildren + " open work item(s))."; + } + return "Case is open; no pending work items on this case object."; + } + + private static String buildExplanation( + CaseType aCase, + int activeWi, + int activeInChildren, + List workItems, + List> childSummaries) { + + StringBuilder sb = new StringBuilder(); + String req = aCase.getName() != null ? aCase.getName().getOrig() : "This case"; + sb.append(req).append(". "); + + if (CaseTypeUtil.isClosed(aCase)) { + sb.append("The workflow has finished with outcome ") + .append(uriLastSegment(aCase.getOutcome())) + .append("."); + return sb.toString(); + } + + String stage = ApprovalContextUtil.getStageName(aCase); + if (activeWi > 0) { + sb.append("Current stage: ") + .append(StringUtils.isNotBlank(stage) ? stage : "approval") + .append(". "); + sb.append(activeWi).append(" approver(s) must still act."); + } else if (activeInChildren > 0) { + sb.append("This case has no local work items; pending approvals are on ") + .append(childSummaries.size()) + .append(" child case(s) (") + .append(activeInChildren) + .append(" open work item(s) total)."); + } else { + sb.append("No open work items are attached to this case object."); + } + + long decided = workItems.stream().filter(w -> w.getCloseTimestamp() != null).count(); + if (decided > 0) { + sb.append(" ").append(decided).append(" work item(s) were already completed."); + } + return sb.toString().trim(); + } + + private static Map resolveSingleRef( + ObjectReferenceType ref, ModelService modelService, Task task, OperationResult result) + throws SchemaException, ObjectNotFoundException, SecurityViolationException, CommunicationException, + ConfigurationException, ExpressionEvaluationException { + if (ref == null || StringUtils.isBlank(ref.getOid())) { + return null; + } + return resolveRef(ref, modelService, task, result); + } + + private static Map resolveRef( + ObjectReferenceType ref, ModelService modelService, Task task, OperationResult result) + throws SchemaException, ObjectNotFoundException, SecurityViolationException, CommunicationException, + ConfigurationException, ExpressionEvaluationException { + + if (ref == null || StringUtils.isBlank(ref.getOid())) { + return null; + } + Map m = new LinkedHashMap<>(); + m.put("oid", ref.getOid()); + QName tt = ref.getType(); + if (tt != null) { + ObjectTypes ot = ObjectTypes.getObjectTypeFromTypeQNameIfKnown(tt); + m.put("type", ot != null ? ot.getRestType() : tt.getLocalPart()); + } + String nm = polyOrig(ref.getTargetName()); + if (StringUtils.isNotBlank(nm)) { + m.put("name", nm); + return m; + } + if (modelService != null && task != null && result != null) { + Class clazz = + tt != null ? ObjectTypes.getObjectTypeClassIfKnown(tt) : ObjectType.class; + if (clazz == null) { + clazz = ObjectType.class; + } + try { + PrismObject obj = modelService.getObject(clazz, ref.getOid(), null, task, result); + if (obj.getName() != null) { + m.put("name", obj.getName().getOrig()); + } + ObjectTypes ot = ObjectTypes.getObjectTypeIfKnown(obj.getCompileTimeClass()); + if (ot != null) { + m.put("type", ot.getRestType()); + } + } catch (ObjectNotFoundException | SecurityViolationException e) { + // keep oid only + } + } + return m; + } + + private static String polyOrig(PolyStringType ps) { + return ps != null ? ps.getOrig() : null; + } + + private static String uriLastSegment(Object uri) { + if (uri == null) { + return null; + } + String s = uri.toString(); + int hash = s.lastIndexOf('#'); + if (hash >= 0 && hash < s.length() - 1) { + return s.substring(hash + 1); + } + int slash = s.lastIndexOf('/'); + if (slash >= 0 && slash < s.length() - 1) { + return s.substring(slash + 1); + } + return s; + } + + /** + * Compact {@code currentStep} for search hits (no child-case query). Explain uses full {@link #buildCurrentStep} + * with child aggregation. + */ + static Map compactCurrentStepForSearch(CaseType aCase) { + List workItems = aCase.getWorkItem() != null ? aCase.getWorkItem() : List.of(); + int activeWi = (int) workItems.stream().filter(wi -> wi.getCloseTimestamp() == null).count(); + Map step = new LinkedHashMap<>(); + if (CaseTypeUtil.isClosed(aCase)) { + step.put("active", false); + step.put("name", "Completed"); + return step; + } + if (activeWi > 0) { + String stage = ApprovalContextUtil.getStageName(aCase); + step.put("name", StringUtils.isNotBlank(stage) ? stage : "Approval"); + step.put("active", true); + } else { + step.put("name", "Open"); + step.put("active", false); + } + return step; + } + + /** Paths allowed in filters but not supported for {@code orderBy} on cases. */ + static boolean isOrderByBlockedForCases(String path) { + if (path == null) { + return false; + } + String p = path.trim(); + return p.startsWith("request.") + || p.startsWith("currentStep.") + || p.startsWith("workItems.") + || p.startsWith("decisionHistory.") + || p.equals("created") + || p.equals("closed"); + } +} diff --git a/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpCaseSchema.java b/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpCaseSchema.java new file mode 100644 index 00000000000..a6c3fdd7e5e --- /dev/null +++ b/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpCaseSchema.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.impl; + +import java.util.ArrayList; +import java.util.List; + +import com.evolveum.midpoint.mcp.api.MidpointMcpSchemaAttribute; + +/** + * MCP-oriented synthetic schema paths for {@code cases} (in addition to flattened {@link CaseType}). + */ +final class MidpointMcpCaseSchema { + + private MidpointMcpCaseSchema() {} + + /** + * Paths merged into {@code midpoint_describe_object_type_schema} output for {@code cases}. + */ + static List syntheticDescribeAttributes() { + List out = new ArrayList<>(); + out.add(stringAttr("request.type")); + out.add(stringAttr("request.summary")); + out.add(stringAttr("currentStep.name")); + out.add(stringAttr("workItems.state")); + out.add(stringAttr("workItems.assignee.name")); + out.add(stringAttr("workItems.candidates.name")); + out.add(stringAttr("decisionHistory.actor.name")); + out.add(stringAttr("decisionHistory.decision")); + out.add(stringAttr("decisionHistory.timestamp")); + out.add(stringAttr("created")); + out.add(stringAttr("closed")); + return out; + } + + private static MidpointMcpSchemaAttribute stringAttr(String path) { + MidpointMcpSchemaAttribute a = new MidpointMcpSchemaAttribute(); + a.setPath(path); + a.setType("string"); + return a; + } +} diff --git a/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpDefaultAttributes.java b/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpDefaultAttributes.java new file mode 100644 index 00000000000..2a57a135f99 --- /dev/null +++ b/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpDefaultAttributes.java @@ -0,0 +1,425 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.impl; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Default {@code returnAttributes} when the client omits the parameter. + * Search uses lightweight defaults; explain uses a richer curated set per REST type. + * Paths use the same dot notation as {@code midpoint_describe_object_type_schema}. + */ +final class MidpointMcpDefaultAttributes { + + static final String EXTENSION_STAR = "extension.*"; + static final String STAR = "*"; + + private static final List COMMON = List.of( + "oid", + "type", + "name", + "displayName", + "description", + "lifecycleState", + "archetypeRef", + "summary"); + + /** Curated defaults for {@code midpoint_explain_object} (and explicit-path validation vocabulary). */ + private static final Map> EXPLAIN_BY_REST_TYPE = Map.ofEntries( + Map.entry( + "users", + List.of( + "name", + "description", + "subtype", + "archetypeRef", + "parentOrgRef", + "tenantRef", + "roleMembershipRef", + "linkRef", + "assignment", + "lifecycleState", + "activation.administrativeStatus", + "activation.effectiveStatus", + "activation.validFrom", + "activation.validTo", + "activation.validityStatus", + "activation.lockoutStatus", + "fullName", + "givenName", + "familyName", + "employeeNumber", + "personalNumber", + "emailAddress", + "telephoneNumber", + "organization", + "organizationalUnit", + "costCenter", + "locality", + "preferredLanguage", + "locale", + "timezone", + EXTENSION_STAR)), + Map.entry( + "roles", + List.of( + "name", + "description", + "subtype", + "archetypeRef", + "lifecycleState", + "assignment", + "inducement", + "roleMembershipRef", + "memberCount", + "activation.administrativeStatus", + "activation.effectiveStatus", + "requestable", + EXTENSION_STAR)), + Map.entry( + "orgs", + List.of( + "name", + "description", + "subtype", + "archetypeRef", + "parentOrgRef", + "tenantRef", + "lifecycleState", + "memberCount", + "childOrgCount", + "managerRef", + "activation.administrativeStatus", + "activation.effectiveStatus", + "identifier", + EXTENSION_STAR)), + Map.entry( + "archetypes", + List.of( + "name", + "description", + "lifecycleState", + "archetypeType", + "superArchetypeRef", + EXTENSION_STAR)), + Map.entry( + "connectors", + List.of( + "name", + "description", + "connectorType", + "framework", + "bundle", + "version", + "connectorHostRef", + EXTENSION_STAR)), + Map.entry( + "resources", + List.of( + "name", + "description", + "lifecycleState", + "connectorRef", + "resourceType", + "configured", + "activation.administrativeStatus", + "activation.effectiveStatus", + "capabilitiesSummary", + "kindIntentSummary", + EXTENSION_STAR)), + Map.entry( + "services", + List.of( + "name", + "description", + "subtype", + "archetypeRef", + "lifecycleState", + "ownerRef", + "managerRef", + "memberCount", + "serviceType", + "activation.administrativeStatus", + "activation.effectiveStatus", + EXTENSION_STAR)), + Map.entry( + "shadows", + List.of( + "name", + "description", + "resourceRef", + "kind", + "intent", + "objectClass", + "primaryIdentifier", + "ownerRef", + "exists", + "dead", + "synchronizationSituation", + "activation.administrativeStatus", + "activation.effectiveStatus", + EXTENSION_STAR)), + Map.entry( + "tasks", + List.of( + "name", + "description", + "lifecycleState", + "category", + "handlerUri", + "executionState", + "schedulingState", + "resultStatus", + "ownerRef", + "nodeRef", + "nextRun", + "lastRunStart", + "lastRunFinish", + "progress", + EXTENSION_STAR)), + Map.entry( + "nodes", + List.of( + "name", + "description", + "hostname", + "status", + "lastCheckIn", + "taskCount", + "currentTaskCount", + EXTENSION_STAR)), + Map.entry( + "cases", + List.of( + "state", + "outcome", + "closeTimestamp", + "stageNumber", + "parentRef", + "objectRef", + "targetRef", + "requestorRef", + "workItemCount", + "activeWorkItemCount", + "currentStep", + EXTENSION_STAR))); + + /** + * Lightweight defaults for {@code midpoint_search_objects}: no extension.*, no heavy containers, + * no virtual counts that trigger extra model queries. + */ + private static final Map> SEARCH_BY_REST_TYPE = Map.ofEntries( + Map.entry( + "users", + List.of( + "subtype", + "activation.administrativeStatus", + "activation.effectiveStatus", + "activation.validFrom", + "activation.validTo", + "activation.validityStatus", + "activation.lockoutStatus", + "fullName", + "givenName", + "familyName", + "employeeNumber", + "personalNumber", + "emailAddress", + "telephoneNumber", + "organization", + "organizationalUnit", + "costCenter", + "locality", + "preferredLanguage", + "locale", + "timezone")), + Map.entry( + "roles", + List.of( + "name", + "description", + "subtype", + "archetypeRef", + "lifecycleState", + "activation.administrativeStatus", + "activation.effectiveStatus", + "requestable")), + Map.entry( + "orgs", + List.of( + "name", + "description", + "subtype", + "archetypeRef", + "parentOrgRef", + "tenantRef", + "lifecycleState", + "managerRef", + "activation.administrativeStatus", + "activation.effectiveStatus", + "identifier")), + Map.entry( + "archetypes", + List.of( + "name", + "description", + "lifecycleState", + "archetypeType", + "superArchetypeRef")), + Map.entry( + "connectors", + List.of( + "name", + "description", + "connectorType", + "framework", + "bundle", + "version", + "connectorHostRef")), + Map.entry( + "resources", + List.of( + "name", + "description", + "lifecycleState", + "connectorRef", + "resourceType", + "activation.administrativeStatus", + "activation.effectiveStatus")), + Map.entry( + "services", + List.of( + "name", + "description", + "subtype", + "archetypeRef", + "lifecycleState", + "ownerRef", + "managerRef", + "serviceType", + "activation.administrativeStatus", + "activation.effectiveStatus")), + Map.entry( + "shadows", + List.of( + "name", + "description", + "resourceRef", + "kind", + "intent", + "objectClass", + "primaryIdentifier", + "ownerRef", + "exists", + "dead", + "synchronizationSituation", + "activation.administrativeStatus", + "activation.effectiveStatus")), + Map.entry( + "tasks", + List.of( + "name", + "description", + "lifecycleState", + "category", + "handlerUri", + "executionState", + "schedulingState", + "resultStatus", + "ownerRef", + "nodeRef", + "nextRun", + "lastRunStart", + "lastRunFinish", + "progress")), + Map.entry( + "nodes", + List.of( + "name", + "description", + "hostname", + "status", + "lastCheckIn", + "taskCount", + "currentTaskCount")), + Map.entry( + "cases", + List.of( + "state", + "outcome", + "closeTimestamp", + "stageNumber", + "parentRef", + "objectRef", + "targetRef", + "requestorRef", + "workItemCount", + "activeWorkItemCount", + "currentStep"))); + + /** Virtual paths not produced by {@link MidpointMcpSchemaFlattener} but valid for explicit {@code returnAttributes}. */ + static final Set VIRTUAL_PATHS = Set.of( + "oid", + "type", + "summary", + "memberCount", + "childOrgCount", + "capabilitiesSummary", + "kindIntentSummary", + "configured", + "workItemCount", + "activeWorkItemCount", + "currentStep", + "request", + "workItems", + "decisionHistory", + "childCases", + "explanation"); + + private MidpointMcpDefaultAttributes() {} + + static List searchDefaultsForRestType(String restType) { + return mergeWithCommon(restType, SEARCH_BY_REST_TYPE); + } + + static List explainDefaultsForRestType(String restType) { + return mergeWithCommon(restType, EXPLAIN_BY_REST_TYPE); + } + + private static List mergeWithCommon(String restType, Map> byType) { + String key = restType.trim().toLowerCase(Locale.ROOT); + List specific = byType.get(key); + if (specific == null) { + throw new IllegalArgumentException("unsupported type '" + restType + "'"); + } + LinkedHashSet merged = new LinkedHashSet<>(); + if ("cases".equals(key)) { + merged.addAll( + List.of("oid", "type", "name", "description", "lifecycleState", "archetypeRef", "summary")); + } else { + merged.addAll(COMMON); + } + merged.addAll(specific); + return new ArrayList<>(merged); + } + + /** Default {@code returnAttributes} for {@code midpoint_explain_object} on {@code cases} (search uses {@link #searchDefaultsForRestType}). */ + static List explainDefaultsForCases() { + LinkedHashSet merged = new LinkedHashSet<>(explainDefaultsForRestType("cases")); + merged.add("request"); + merged.add("workItems"); + merged.add("decisionHistory"); + merged.add("childCases"); + merged.add("explanation"); + return new ArrayList<>(merged); + } +} diff --git a/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpSchemaFlattener.java b/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpSchemaFlattener.java new file mode 100644 index 00000000000..347aa9d34c5 --- /dev/null +++ b/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpSchemaFlattener.java @@ -0,0 +1,288 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.impl; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.xml.XMLConstants; +import javax.xml.namespace.QName; + +import com.evolveum.midpoint.mcp.api.MidpointMcpSchemaAttribute; +import com.evolveum.midpoint.prism.ComplexTypeDefinition; +import com.evolveum.midpoint.prism.ItemDefinition; +import com.evolveum.midpoint.prism.PrismContainerDefinition; +import com.evolveum.midpoint.prism.PrismObjectDefinition; +import com.evolveum.midpoint.prism.PrismPropertyDefinition; +import com.evolveum.midpoint.prism.PrismReferenceDefinition; +import com.evolveum.midpoint.schema.constants.ObjectTypes; +import com.evolveum.midpoint.schema.constants.SchemaConstants; +import com.evolveum.midpoint.util.DisplayableValue; +import com.evolveum.midpoint.util.QNameUtil; +import com.evolveum.midpoint.xml.ns._public.common.common_3.AbstractRoleType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ObjectType; + +/** + * Flattens {@link PrismObjectDefinition} into dot-path attributes (cycle-safe on complex types). + */ +final class MidpointMcpSchemaFlattener { + + /** REST collections exposed by MCP tools; used to label reference targets. */ + private static final Set MCP_REST_TYPES = Set.of( + "users", + "roles", + "orgs", + "archetypes", + "connectors", + "resources", + "services", + "shadows", + "tasks", + "nodes", + "cases"); + + /** + * Skip entire subtrees when this item name is a container (or any definition). Omits engine/policy/diagnostic + * branches and SCIM-style structured address trees from the default schema. + */ + private static final Set SUBTREE_BLOCKED_LOCAL_NAMES = Set.of( + "operationExecution", + "fetchResult", + "metadata", + "lensContext", + "trigger", + "policyException", + "policyStatement", + "adminGuiConfiguration", + "physicalAddress", + "modelContext"); + + /** Leaf or reference paths to omit (exact match). */ + private static final Set EMIT_BLOCKED_EXACT_PATHS = Set.of( + "policySituation", + "triggeredPolicyRule", + "diagnosticInformation", + "effectiveMarkRef", + "iteration", + "iterationToken", + "delegatedRef", + "roleInfluenceRef", + "personaRef", + "jpegPhoto"); + + /** + * If path equals the string, or starts with it followed by '.', omit. Assignment/inducement noise and SCIM + * sub-fields (email/phone) use the same rule. + */ + private static final List EMIT_BLOCKED_PATH_PREFIXES = List.of( + "assignment.identifier", + "assignment.documentation", + "assignment.policySituation", + "assignment.triggeredPolicyRule", + "assignment.effectiveMarkRef", + "assignment.iteration", + "assignment.iterationToken", + "inducement.identifier", + "inducement.documentation", + "inducement.policySituation", + "inducement.triggeredPolicyRule", + "inducement.effectiveMarkRef", + "inducement.iteration", + "inducement.iterationToken", + "email.type", + "email.primary", + "phoneNumber.type", + "approvalContext.deltasToApprove", + "approvalContext.resultingDeltas", + "approvalContext.policyRules"); + + private MidpointMcpSchemaFlattener() {} + + static List flatten(PrismObjectDefinition objectDefinition, int maxPathDepth) { + LinkedHashMap byPath = new LinkedHashMap<>(); + Deque complexTypeStack = new ArrayDeque<>(); + for (ItemDefinition def : objectDefinition.getDefinitions()) { + walk(def, "", complexTypeStack, byPath, maxPathDepth); + } + return new ArrayList<>(byPath.values()); + } + + private static void walk( + ItemDefinition def, + String pathPrefix, + Deque complexTypeStack, + Map byPath, + int maxPathDepth) { + + String local = def.getItemName().getLocalPart(); + if (SUBTREE_BLOCKED_LOCAL_NAMES.contains(local)) { + return; + } + String path = pathPrefix.isEmpty() ? local : pathPrefix + "." + local; + + if (def instanceof PrismPropertyDefinition prop) { + if (pathDepth(path) <= maxPathDepth && shouldEmitPath(path)) { + putOnce( + byPath, + path, + simplifiedPropertyType(prop.getTypeName()), + null, + enumValuesForProperty(prop)); + } + } else if (def instanceof PrismReferenceDefinition ref) { + if (pathDepth(path) <= maxPathDepth && shouldEmitPath(path)) { + MidpointMcpSchemaAttribute attr = new MidpointMcpSchemaAttribute(); + attr.setPath(path); + attr.setType("reference"); + attr.setTarget(referenceTarget(ref.getTargetTypeName())); + byPath.putIfAbsent(path, attr); + } + } else if (def instanceof PrismContainerDefinition cont) { + ComplexTypeDefinition ctd = cont.getComplexTypeDefinition(); + if (ctd == null) { + return; + } + String typeKey = qNameToUri(ctd.getTypeName()); + if (typeKey != null && complexTypeStack.contains(typeKey)) { + return; + } + if (typeKey != null) { + complexTypeStack.push(typeKey); + } + try { + for (ItemDefinition sub : ctd.getDefinitions()) { + walk(sub, path, complexTypeStack, byPath, maxPathDepth); + } + } finally { + if (typeKey != null) { + complexTypeStack.pop(); + } + } + } + } + + private static void putOnce( + Map byPath, + String path, + String type, + String target, + List enumValues) { + byPath.putIfAbsent(path, attribute(path, type, target, enumValues)); + } + + private static MidpointMcpSchemaAttribute attribute( + String path, String type, String target, List enumValues) { + MidpointMcpSchemaAttribute a = new MidpointMcpSchemaAttribute(); + a.setPath(path); + a.setType(type); + a.setTarget(target); + if (enumValues != null && !enumValues.isEmpty()) { + a.setEnum(enumValues); + } + return a; + } + + /** + * Prism enumeration / value-policy allowed values, when present on the property definition. + */ + private static List enumValuesForProperty(PrismPropertyDefinition prop) { + Collection> allowed = prop.getAllowedValues(); + if (allowed == null || allowed.isEmpty()) { + return null; + } + List out = new ArrayList<>(allowed.size()); + for (DisplayableValue dv : allowed) { + if (dv == null || dv.getValue() == null) { + continue; + } + Object v = dv.getValue(); + out.add(v instanceof Enum e ? e.name() : String.valueOf(v)); + } + return out.isEmpty() ? null : List.copyOf(out); + } + + private static int pathDepth(String path) { + if (path == null || path.isEmpty()) { + return 0; + } + int n = 1; + for (int i = 0; i < path.length(); i++) { + if (path.charAt(i) == '.') { + n++; + } + } + return n; + } + + private static boolean shouldEmitPath(String path) { + if (EMIT_BLOCKED_EXACT_PATHS.contains(path)) { + return false; + } + for (String prefix : EMIT_BLOCKED_PATH_PREFIXES) { + if (path.equals(prefix) || path.startsWith(prefix + ".")) { + return false; + } + } + return true; + } + + /** + * Maps XSD / Prism value types to a small vocabulary. Non-numeric non-boolean non-date types become + * {@code string} (including decimal/double). + */ + private static String simplifiedPropertyType(QName type) { + if (type == null) { + return "string"; + } + if (QNameUtil.match(type, SchemaConstants.T_POLY_STRING_TYPE)) { + return "string"; + } + String ns = type.getNamespaceURI(); + String lp = type.getLocalPart(); + if (XMLConstants.W3C_XML_SCHEMA_NS_URI.equals(ns)) { + return switch (lp) { + case "int", "integer", "long", "short", "byte", + "unsignedInt", "unsignedLong", "unsignedShort", "unsignedByte" -> "integer"; + case "boolean" -> "boolean"; + case "dateTime", "date", "time" -> "datetime"; + default -> "string"; + }; + } + return "string"; + } + + private static String referenceTarget(QName targetTypeName) { + if (targetTypeName == null) { + return "objects"; + } + Class clazz = ObjectTypes.getObjectTypeClassIfKnown(targetTypeName); + if (clazz == null) { + return "objects"; + } + ObjectTypes ot = ObjectTypes.getObjectTypeIfKnown(clazz); + if (ot != null) { + String rest = ot.getRestType(); + if (MCP_REST_TYPES.contains(rest)) { + return rest; + } + if (AbstractRoleType.class.isAssignableFrom(clazz)) { + return "roles|orgs|services"; + } + } + return "objects"; + } + + private static String qNameToUri(QName q) { + return q != null ? QNameUtil.qNameToUri(q) : null; + } +} diff --git a/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpServiceImpl.java b/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpServiceImpl.java new file mode 100644 index 00000000000..b22241bdb53 --- /dev/null +++ b/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpServiceImpl.java @@ -0,0 +1,1195 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.impl; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import javax.xml.datatype.DatatypeConfigurationException; +import javax.xml.datatype.DatatypeFactory; +import javax.xml.datatype.XMLGregorianCalendar; +import javax.xml.namespace.QName; + +import com.evolveum.midpoint.mcp.api.MidpointMcpAdvancedOrderBySpec; +import com.evolveum.midpoint.mcp.api.MidpointMcpAdvancedPagingSpec; +import com.evolveum.midpoint.mcp.api.MidpointMcpAdvancedQuerySpec; +import com.evolveum.midpoint.mcp.api.MidpointMcpAuditEffectiveWindow; +import com.evolveum.midpoint.mcp.api.MidpointMcpAuditExplainRequest; +import com.evolveum.midpoint.mcp.api.MidpointMcpAuditExplainResult; +import com.evolveum.midpoint.mcp.api.MidpointMcpAuditRecordSummary; +import com.evolveum.midpoint.mcp.api.MidpointMcpAuditSearchRequest; +import com.evolveum.midpoint.mcp.api.MidpointMcpAuditSearchResult; +import com.evolveum.midpoint.mcp.api.MidpointMcpException; +import com.evolveum.midpoint.mcp.api.MidpointMcpObjectView; +import com.evolveum.midpoint.mcp.api.MidpointMcpSchemaAttribute; +import com.evolveum.midpoint.mcp.api.MidpointMcpSearchItem; +import com.evolveum.midpoint.mcp.api.MidpointMcpSearchRequest; +import com.evolveum.midpoint.mcp.api.MidpointMcpSearchResult; +import com.evolveum.midpoint.mcp.api.McpPublicErrorMessages; +import com.evolveum.midpoint.mcp.api.MidpointMcpService; +import com.evolveum.midpoint.mcp.api.MidpointMcpTypeSchemaView; +import com.evolveum.midpoint.model.api.ModelAuthorizationAction; +import com.evolveum.midpoint.model.api.ModelAuditService; +import com.evolveum.midpoint.model.api.ModelService; +import com.evolveum.midpoint.model.api.expr.OrgStructFunctions; +import com.evolveum.midpoint.prism.PrismContext; +import com.evolveum.midpoint.prism.PrismObject; +import com.evolveum.midpoint.prism.PrismObjectDefinition; +import com.evolveum.midpoint.prism.query.ObjectFilter; +import com.evolveum.midpoint.prism.query.ObjectPaging; +import com.evolveum.midpoint.prism.query.ObjectQuery; +import com.evolveum.midpoint.prism.query.OrderDirection; +import com.evolveum.midpoint.schema.GetOperationOptions; +import com.evolveum.midpoint.schema.ResourceOperationCoordinates; +import com.evolveum.midpoint.schema.SearchResultList; +import com.evolveum.midpoint.schema.SelectorOptions; +import com.evolveum.midpoint.schema.constants.ObjectTypes; +import com.evolveum.midpoint.schema.util.ObjectQueryUtil; +import com.evolveum.midpoint.schema.result.OperationResult; +import com.evolveum.midpoint.security.api.RestAuthorizationAction; +import com.evolveum.midpoint.security.enforcer.api.AuthorizationParameters; +import com.evolveum.midpoint.security.enforcer.api.SecurityEnforcer; +import com.evolveum.midpoint.task.api.Task; +import com.evolveum.midpoint.util.exception.CommunicationException; +import com.evolveum.midpoint.util.exception.ConfigurationException; +import com.evolveum.midpoint.util.exception.ExpressionEvaluationException; +import com.evolveum.midpoint.util.exception.ObjectNotFoundException; +import com.evolveum.midpoint.util.exception.SchemaException; +import com.evolveum.midpoint.util.MiscUtil; +import com.evolveum.midpoint.util.exception.SecurityViolationException; +import com.evolveum.midpoint.xml.ns._public.common.audit_3.AuditEventRecordType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.CaseType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.CaseWorkItemType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ObjectType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ResourceObjectTypeDefinitionType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ResourceType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.SchemaHandlingType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ShadowKindType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ShadowType; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class MidpointMcpServiceImpl implements MidpointMcpService { + + private static final int DEFAULT_LIMIT = 20; + private static final int MAX_LIMIT = 100; + private static final int AUDIT_DEFAULT_LIMIT = 100; + private static final int AUDIT_MAX_LIMIT = 500; + /** Max shadows loaded per schema branch when expandResourceObjectTypes is used (before merge, dedupe, and paging). */ + private static final int EXPAND_PER_BRANCH_MAX = 500; + + private static final Set MCP_OBJECT_REST_TYPES = Set.of( + "users", + "roles", + "orgs", + "archetypes", + "connectors", + "resources", + "services", + "shadows", + "tasks", + "nodes", + "cases"); + + @Autowired private ModelService modelService; + @Autowired private ModelAuditService modelAuditService; + @Autowired private OrgStructFunctions orgStructFunctions; + @Autowired private PrismContext prismContext; + @Autowired private SecurityEnforcer securityEnforcer; + + @Override + public MidpointMcpObjectView explainObject( + String objectType, + String oid, + List returnAttributes, + Boolean fetch, + Task task, + OperationResult result) { + return translateExceptions(() -> { + Class clazz = resolveMcpObjectClass(objectType); + if (Boolean.TRUE.equals(fetch) && !ShadowType.class.equals(clazz)) { + throw new IllegalArgumentException("fetch is supported only for type shadows"); + } + authorizeRest(RestAuthorizationAction.GET_OBJECT, task, result); + String rest = ObjectTypes.getRestTypeFromClass(clazz); + boolean shadow = ShadowType.class.equals(clazz); + boolean caseType = CaseType.class.equals(clazz); + boolean liveFetch = Boolean.TRUE.equals(fetch); + Collection> getOpts; + String source = null; + Boolean fetchedFlag = null; + if (shadow) { + if (liveFetch) { + getOpts = shadowLiveGetOptions(); + source = "resource"; + fetchedFlag = true; + } else { + getOpts = GetOperationOptions.noFetch(); + source = "repository"; + fetchedFlag = false; + } + } else { + getOpts = null; + } + + PrismObject object = modelService.getObject(clazz, oid, getOpts, task, result); + PrismObjectDefinition def = + prismContext.getSchemaRegistry().findObjectDefinitionByCompileTimeClass(clazz); + List paths = + CaseType.class.equals(clazz) && (returnAttributes == null || returnAttributes.isEmpty()) + ? resolvePathsOrBadRequest( + MidpointMcpDefaultAttributes.explainDefaultsForCases(), rest, def, false) + : resolvePathsOrBadRequest(returnAttributes, rest, def, false); + MidpointMcpAttributeProjector projector = + new MidpointMcpAttributeProjector(prismContext, modelService, orgStructFunctions); + ShadowType shadowBean = shadow ? (ShadowType) object.asObjectable() : null; + String summary = + shadow + ? MidpointMcpShadowProjector.explainShadowSummary( + shadowBean, source, Boolean.TRUE.equals(fetchedFlag)) + : MidpointMcpAttributeProjector.explainSummary(object.asObjectable(), rest); + var values = projector.project(object, rest, paths, summary, task, result, true); + if (caseType) { + @SuppressWarnings("unchecked") + PrismObject caseObject = (PrismObject) object; + MidpointMcpCaseProjector.enrichExplain( + values, caseObject, prismContext, modelService, task, result); + } + if (shadow) { + MidpointMcpShadowProjector.putShadowPayload(values, shadowBean); + } + if (shadow && liveFetch) { + OperationResult sub = result.createMinorSubresult(MidpointMcpServiceImpl.class.getName() + ".liveVsRepository"); + try { + PrismObject repoShadow = + modelService.getObject(ShadowType.class, oid, GetOperationOptions.noFetch(), task, sub); + String note = liveVsRepositoryNote(shadowBean, repoShadow.asObjectable()); + if (note != null) { + values.put("summary.liveVsRepository", note); + } + } catch (Exception e) { + // best-effort comparison only + } finally { + sub.close(); + } + } + MidpointMcpObjectView view = new MidpointMcpObjectView(); + if (shadow) { + view.setSource(source); + view.setFetched(fetchedFlag); + } + view.setValues(values); + return view; + }); + } + + @Override + public MidpointMcpSearchResult searchObjects(MidpointMcpSearchRequest request, Task task, OperationResult result) { + return translateExceptions(() -> { + if (request == null) { + throw new IllegalArgumentException("request is required"); + } + validateSearchModes(request); + validateShadowOnlyParameters(request); + Class clazz = resolveMcpObjectClass(request.getType()); + if (ShadowType.class.equals(clazz)) { + return searchShadowObjects(request, task, result); + } + return searchObjectsForClass(clazz, request, task, result); + }); + } + + @Override + public MidpointMcpAuditSearchResult searchAudit(MidpointMcpAuditSearchRequest request, Task task, OperationResult result) { + return translateExceptions(() -> { + if (request == null) { + throw new IllegalArgumentException("request is required"); + } + validateAuditSearchModes(request); + if (!modelAuditService.supportsRetrieval()) { + throw new IllegalArgumentException("Audit retrieval is not enabled for this deployment"); + } + Instant now = Instant.now(); + Instant fromInstant; + Instant toInstant; + if (StringUtils.isNotBlank(request.getFrom()) || StringUtils.isNotBlank(request.getTo())) { + if (StringUtils.isNotBlank(request.getFrom()) && StringUtils.isNotBlank(request.getTo())) { + fromInstant = parseInstantBound(request.getFrom(), "from"); + toInstant = parseInstantBound(request.getTo(), "to"); + } else if (StringUtils.isNotBlank(request.getFrom())) { + fromInstant = parseInstantBound(request.getFrom(), "from"); + toInstant = now; + } else { + toInstant = parseInstantBound(request.getTo(), "to"); + fromInstant = toInstant.minus(24, ChronoUnit.HOURS); + } + } else { + fromInstant = now.minus(24, ChronoUnit.HOURS); + toInstant = now; + } + if (fromInstant.isAfter(toInstant)) { + throw new IllegalArgumentException("from must be before or equal to to"); + } + + String usedMode; + ObjectFilter contentFilter = null; + String translatedQuery = null; + if (StringUtils.isNotBlank(request.getQuery())) { + usedMode = "simple"; + ObjectQuery sq = MidpointMcpAuditFilterBuilder.buildSimpleTextQuery(prismContext, request.getQuery()); + contentFilter = sq != null ? sq.getFilter() : null; + } else if (request.getAdvancedQuery() != null) { + usedMode = "advancedQuery"; + contentFilter = MidpointMcpAuditFilterBuilder.buildAdvancedFilter(prismContext, request.getAdvancedQuery()); + translatedQuery = MidpointMcpAuditFilterBuilder.describeAdvancedFilter(contentFilter); + } else { + usedMode = "simple"; + } + + ObjectFilter timeFilter = auditTimestampBetweenFilter(fromInstant, toInstant); + ObjectFilter merged = ObjectQueryUtil.filterAndImmutable(contentFilter, timeFilter); + ObjectQuery objectQuery = prismContext.queryFactory().createQuery(merged); + + MidpointMcpAdvancedQuerySpec aq = request.getAdvancedQuery(); + int limit = normalizeAuditLimit(aq != null && aq.getPaging() != null ? aq.getPaging().getLimit() : null); + int offset = normalizeAuditOffset(aq != null && aq.getPaging() != null ? aq.getPaging().getOffset() : null); + MidpointMcpAuditSchema.validateOrderByPaths(aq != null ? aq.getOrderBy() : null); + + ObjectPaging paging = prismContext.queryFactory().createPaging(offset, limit); + if (aq == null || aq.getOrderBy() == null || aq.getOrderBy().isEmpty()) { + paging.addOrderingInstruction(AuditEventRecordType.F_TIMESTAMP, OrderDirection.DESCENDING); + } else { + for (MidpointMcpAdvancedOrderBySpec ob : aq.getOrderBy()) { + if (ob == null || StringUtils.isBlank(ob.getPath())) { + continue; + } + paging.addOrderingInstruction( + MidpointMcpAuditSchema.orderByItemPath(ob.getPath().trim()), + parseOrderDirection(ob.getDirection())); + } + } + objectQuery.setPaging(paging); + + SearchResultList found; + int totalCount; + try { + found = modelAuditService.searchObjects(objectQuery, null, task, result); + + ObjectQuery countQuery = objectQuery.clone(); + countQuery.setPaging(null); + totalCount = modelAuditService.countObjects(countQuery, null, task, result); + } catch (RuntimeException e) { + throw new MidpointMcpException( + "audit_query_failed", + 400, + "Audit query could not be executed with the provided filters/time window.", + "Try narrowing from/to, reducing limit, or using advancedQuery (e.g., target.oid).", + e); + } + + MidpointMcpAuditSearchResult out = new MidpointMcpAuditSearchResult(); + out.setUsedQueryMode(usedMode); + out.setEffectiveWindow(new MidpointMcpAuditEffectiveWindow( + java.time.format.DateTimeFormatter.ISO_INSTANT.format(fromInstant), + java.time.format.DateTimeFormatter.ISO_INSTANT.format(toInstant))); + out.setTotalCount(totalCount); + out.setCount(found.size()); + out.setTranslatedQuery(translatedQuery); + out.setLimit(limit); + out.setOffset(offset); + List records = new ArrayList<>(); + for (AuditEventRecordType r : found) { + records.add(MidpointMcpAuditNormalizer.toSummary(r)); + } + out.setRecords(records); + return out; + }); + } + + @Override + public MidpointMcpAuditExplainResult explainAuditRecord(MidpointMcpAuditExplainRequest request, Task task, OperationResult result) { + return translateExceptions(() -> { + if (request == null || StringUtils.isBlank(request.getId())) { + throw new IllegalArgumentException("id is required"); + } + if (!modelAuditService.supportsRetrieval()) { + throw new IllegalArgumentException("Audit retrieval is not enabled for this deployment"); + } + String id = request.getId().trim(); + AuditEventRecordType record = loadAuditRecordById(id, task, result); + if (record == null) { + throw new MidpointMcpException("not_found", 404, McpPublicErrorMessages.NOT_FOUND); + } + boolean delta = Boolean.TRUE.equals(request.getIncludeDelta()); + boolean res = Boolean.TRUE.equals(request.getIncludeResult()); + return MidpointMcpAuditNormalizer.toExplain(record, delta, res, prismContext); + }); + } + + private AuditEventRecordType loadAuditRecordById(String id, Task task, OperationResult result) + throws SchemaException, SecurityViolationException, ObjectNotFoundException, ExpressionEvaluationException, + CommunicationException, ConfigurationException { + ObjectQuery byEventId = prismContext.queryFor(AuditEventRecordType.class) + .item(AuditEventRecordType.F_EVENT_IDENTIFIER) + .eq(id) + .build(); + SearchResultList list = modelAuditService.searchObjects(byEventId, null, task, result); + if (!list.isEmpty()) { + if (list.size() > 1) { + throw new IllegalArgumentException("Multiple audit records share event identifier " + id); + } + return list.get(0); + } + if (id.matches("^\\d+$")) { + try { + long repoId = Long.parseLong(id); + ObjectQuery byRepo = prismContext.queryFor(AuditEventRecordType.class) + .item(AuditEventRecordType.F_REPO_ID) + .eq(repoId) + .build(); + SearchResultList byRepoList = + modelAuditService.searchObjects(byRepo, null, task, result); + if (!byRepoList.isEmpty()) { + if (byRepoList.size() > 1) { + throw new IllegalArgumentException("Multiple audit records share repo id " + id); + } + return byRepoList.get(0); + } + } catch (NumberFormatException ignored) { + // fall through + } + } + return null; + } + + private ObjectFilter auditTimestampBetweenFilter(Instant from, Instant to) { + XMLGregorianCalendar cFrom = MiscUtil.asXMLGregorianCalendar(from.toEpochMilli()); + XMLGregorianCalendar cTo = MiscUtil.asXMLGregorianCalendar(to.toEpochMilli()); + ObjectFilter ge = prismContext.queryFor(AuditEventRecordType.class) + .item(AuditEventRecordType.F_TIMESTAMP) + .ge(cFrom) + .build() + .getFilter(); + ObjectFilter le = prismContext.queryFor(AuditEventRecordType.class) + .item(AuditEventRecordType.F_TIMESTAMP) + .le(cTo) + .build() + .getFilter(); + return prismContext.queryFactory().createAnd(ge, le); + } + + private static Instant parseInstantBound(String raw, String label) { + String s = raw.trim(); + try { + return Instant.parse(s); + } catch (DateTimeParseException e) { + try { + XMLGregorianCalendar cal = DatatypeFactory.newInstance().newXMLGregorianCalendar(s); + return cal.toGregorianCalendar().toInstant(); + } catch (DatatypeConfigurationException ex) { + throw new IllegalArgumentException("Invalid " + label + " datetime: " + raw); + } + } + } + + private static void validateAuditSearchModes(MidpointMcpAuditSearchRequest request) { + boolean hasQuery = StringUtils.isNotBlank(request.getQuery()); + boolean hasAdvanced = request.getAdvancedQuery() != null; + if (hasQuery && hasAdvanced) { + throw new IllegalArgumentException("Use only one of: query or advancedQuery"); + } + } + + private int normalizeAuditLimit(Integer limit) { + if (limit == null) { + return AUDIT_DEFAULT_LIMIT; + } + if (limit < 1) { + throw new MidpointMcpException("bad_request", 400, "limit must be greater than 0"); + } + return Math.min(limit, AUDIT_MAX_LIMIT); + } + + private int normalizeAuditOffset(Integer offset) { + if (offset == null) { + return 0; + } + if (offset < 0) { + throw new MidpointMcpException("bad_request", 400, "offset must be 0 or greater"); + } + return offset; + } + + private static void validateShadowOnlyParameters(MidpointMcpSearchRequest request) { + String n = request.getType() != null ? request.getType().trim().toLowerCase(Locale.ROOT) : ""; + boolean isShadow = "shadows".equals(n); + if (Boolean.TRUE.equals(request.getFetch()) && !isShadow) { + throw new IllegalArgumentException("fetch is supported only for type shadows"); + } + if (StringUtils.isNotBlank(request.getSearchMode()) && !isShadow) { + throw new IllegalArgumentException("searchMode is supported only for type shadows"); + } + if (!isShadow) { + if (StringUtils.isNotBlank(request.getResourceOid()) + || StringUtils.isNotBlank(request.getShadowKind()) + || StringUtils.isNotBlank(request.getShadowIntent()) + || StringUtils.isNotBlank(request.getObjectClass()) + || Boolean.TRUE.equals(request.getExpandResourceObjectTypes())) { + throw new IllegalArgumentException( + "resourceOid, shadowKind, shadowIntent, objectClass, and expandResourceObjectTypes are supported only for type shadows"); + } + } + } + + private static String normalizeSearchMode(String raw) { + if (StringUtils.isBlank(raw)) { + return "repository"; + } + String s = raw.trim().toLowerCase(Locale.ROOT); + if (!"repository".equals(s) && !"resource".equals(s)) { + throw new IllegalArgumentException("searchMode must be repository or resource"); + } + return s; + } + + private static void validateShadowResourceSearch(MidpointMcpSearchRequest request, String mode) { + boolean expand = Boolean.TRUE.equals(request.getExpandResourceObjectTypes()); + boolean hasRoi = StringUtils.isNotBlank(request.getResourceOid()); + boolean hasKind = StringUtils.isNotBlank(request.getShadowKind()); + boolean hasOc = StringUtils.isNotBlank(request.getObjectClass()); + boolean hasIntentOnly = StringUtils.isNotBlank(request.getShadowIntent()); + + if (expand && !"repository".equals(mode)) { + throw new IllegalArgumentException("expandResourceObjectTypes is only allowed for searchMode repository (default)"); + } + if (expand && !hasRoi) { + throw new IllegalArgumentException("expandResourceObjectTypes requires resourceOid"); + } + if (expand && (hasKind || hasOc || hasIntentOnly)) { + throw new IllegalArgumentException( + "expandResourceObjectTypes cannot be combined with shadowKind, shadowIntent, or objectClass"); + } + if (hasIntentOnly && !hasKind && !hasOc) { + throw new IllegalArgumentException("shadowIntent requires shadowKind or objectClass"); + } + if (hasKind && hasOc) { + throw new IllegalArgumentException("Use either shadowKind or objectClass, not both"); + } + if ("resource".equals(mode)) { + if (expand) { + throw new IllegalArgumentException("expandResourceObjectTypes is not supported for searchMode resource"); + } + if (StringUtils.isBlank(request.getResourceOid())) { + throw new IllegalArgumentException("resourceOid is required when searchMode is resource"); + } + if (!hasKind && !hasOc) { + throw new IllegalArgumentException("For searchMode resource, set shadowKind or objectClass"); + } + } else { + // repository (default): top-level resourceOid must be paired with a type scope or expand + if (hasRoi && !expand && !hasKind && !hasOc) { + throw new MidpointMcpException( + "shadow_repository_scope_incomplete", + 400, + "With searchMode repository, resourceOid must be used together with shadowKind or objectClass, " + + "or set expandResourceObjectTypes=true. Otherwise omit resourceOid and express resource " + + "plus kind/objectClass only in mql/advancedQuery.", + shadowSearchCoordinatesHint()); + } + } + } + + private MidpointMcpSearchResult searchShadowObjects(MidpointMcpSearchRequest request, Task task, OperationResult result) + throws SchemaException, ObjectNotFoundException, SecurityViolationException, CommunicationException, + ConfigurationException, ExpressionEvaluationException { + + authorizeRest(RestAuthorizationAction.SEARCH_OBJECTS, task, result); + String rest = "shadows"; + PrismObjectDefinition def = + prismContext.getSchemaRegistry().findObjectDefinitionByCompileTimeClass(ShadowType.class); + List paths = resolvePathsOrBadRequest(request.getReturnAttributes(), rest, def, true); + + String mode = normalizeSearchMode(request.getSearchMode()); + validateShadowResourceSearch(request, mode); + + PagingResolution paging = resolvePaging(request); + int normalizedLimit = normalizeLimit(paging.limit()); + int normalizedOffset = normalizeOffset(paging.offset()); + + String usedMode; + String translatedMql = null; + String simpleQuery = null; + ObjectQuery objectQuery; + + if (StringUtils.isNotBlank(request.getQuery())) { + usedMode = "simple"; + simpleQuery = request.getQuery().trim(); + objectQuery = createSimpleSearchQuery(ShadowType.class, simpleQuery, normalizedLimit, normalizedOffset); + } else if (request.getAdvancedQuery() != null) { + usedMode = "advancedQuery"; + Map schema = + MidpointMcpAdvancedQueryTranslator.schemaMapForValidation(def); + MidpointMcpAdvancedQuerySpec aq = request.getAdvancedQuery(); + validateOrderByPaths(aq.getOrderBy(), schema, def); + String mqlFilter = MidpointMcpAdvancedQueryTranslator.translateToMqlFilter(aq, schema); + translatedMql = mqlFilter.isEmpty() ? null : mqlFilter; + objectQuery = buildObjectQueryFromMql(ShadowType.class, mqlFilter, normalizedLimit, normalizedOffset, aq.getOrderBy(), def); + } else if (StringUtils.isNotBlank(request.getMql())) { + usedMode = "mql"; + translatedMql = request.getMql().trim(); + objectQuery = buildObjectQueryFromMql(ShadowType.class, translatedMql, normalizedLimit, normalizedOffset, List.of(), def); + } else { + usedMode = "simple"; + objectQuery = createSimpleSearchQuery(ShadowType.class, null, normalizedLimit, normalizedOffset); + } + + List expandedBranches = null; + if ("repository".equals(mode) && Boolean.TRUE.equals(request.getExpandResourceObjectTypes())) { + expandedBranches = buildExpandedResourceShadowTypeBranches(request.getResourceOid().trim(), task, result); + } else if ("repository".equals(mode)) { + objectQuery = augmentShadowRepositoryQueryWithoutExpand(request, objectQuery, task, result); + } + + if ("resource".equals(mode)) { + ObjectFilter scope = buildShadowResourceScopeFilter(request); + ObjectFilter merged = ObjectQueryUtil.filterAndImmutable(objectQuery.getFilter(), scope); + objectQuery = objectQuery.clone(); + objectQuery.setFilter(merged); + } + + Collection> searchOpts; + if ("repository".equals(mode)) { + searchOpts = GetOperationOptions.noFetch(); + } else { + searchOpts = shadowResourceSearchOptions(); + } + + SearchResultList> found; + int totalCount; + if (expandedBranches != null) { + found = searchShadowsExpandedBranches( + objectQuery, expandedBranches, normalizedLimit, normalizedOffset, searchOpts, task, result); + totalCount = countShadowsExpandedBranches(objectQuery, expandedBranches, searchOpts, task, result); + } else { + assertShadowSearchCoordinates(objectQuery); + found = modelService.searchObjects(ShadowType.class, objectQuery, searchOpts, task, result); + ObjectQuery countQuery = objectQuery.clone(); + countQuery.setPaging(null); + try { + Integer c = modelService.countObjects(ShadowType.class, countQuery, searchOpts, task, result); + totalCount = c != null ? c : -1; + } catch (Exception e) { + totalCount = -1; + } + } + + boolean perItemResourceFetch = "repository".equals(mode) && Boolean.TRUE.equals(request.getFetch()); + if (perItemResourceFetch) { + List> refreshed = new ArrayList<>(found.size()); + for (PrismObject s : found) { + refreshed.add(modelService.getObject(ShadowType.class, s.getOid(), shadowLiveGetOptions(), task, result)); + } + found = new SearchResultList<>(refreshed); + } + + boolean fetched = "resource".equals(mode) || perItemResourceFetch; + String source = "resource".equals(mode) ? "resource" : "repository"; + + MidpointMcpSearchResult searchResult = new MidpointMcpSearchResult(); + searchResult.setType(rest); + searchResult.setQuery(simpleQuery); + searchResult.setUsedQueryMode(usedMode); + searchResult.setTranslatedMql(translatedMql); + searchResult.setLimit(normalizedLimit); + searchResult.setOffset(normalizedOffset); + searchResult.setTotalCount(totalCount); + searchResult.setSource(source); + searchResult.setFetched(fetched); + searchResult.setSearchMode(mode); + + MidpointMcpAttributeProjector projector = + new MidpointMcpAttributeProjector(prismContext, modelService, orgStructFunctions); + List items = new ArrayList<>(); + for (PrismObject object : found) { + String summary = MidpointMcpAttributeProjector.searchSummary(object.asObjectable()); + MidpointMcpSearchItem item = new MidpointMcpSearchItem(); + var vals = projector.project(object, rest, paths, summary, task, result, false); + MidpointMcpShadowProjector.putShadowPayload(vals, object.asObjectable()); + item.setValues(vals); + items.add(item); + } + searchResult.setItems(items); + return searchResult; + } + + private ObjectFilter buildShadowResourceScopeFilter(MidpointMcpSearchRequest request) { + String resourceOid = request.getResourceOid().trim(); + try { + if (StringUtils.isNotBlank(request.getObjectClass())) { + QName oc = MidpointMcpShadowProjector.parseObjectClass(request.getObjectClass()); + return ObjectQueryUtil.createResourceAndObjectClassFilter(resourceOid, oc); + } + ShadowKindType kind = MidpointMcpShadowProjector.parseShadowKindEnum(request.getShadowKind()); + if (StringUtils.isNotBlank(request.getShadowIntent())) { + return ObjectQueryUtil.createResourceAndKindIntentFilter( + resourceOid, kind, request.getShadowIntent().trim()); + } + return ObjectQueryUtil.createResourceAndKind(resourceOid, kind).getFilter(); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(e.getMessage()); + } + } + + /** + * Repository shadow search: optional top-level {@code resourceOid} + kind/objectClass merged into the query (AND). + */ + private ObjectQuery augmentShadowRepositoryQueryWithoutExpand( + MidpointMcpSearchRequest request, ObjectQuery objectQuery, Task task, OperationResult result) + throws SchemaException, ObjectNotFoundException, SecurityViolationException, CommunicationException, + ConfigurationException, ExpressionEvaluationException { + + ObjectQuery q = objectQuery; + if (StringUtils.isNotBlank(request.getResourceOid()) + && (StringUtils.isNotBlank(request.getShadowKind()) + || StringUtils.isNotBlank(request.getObjectClass()))) { + ObjectFilter scope = buildShadowResourceScopeFilter(request); + q = andShadowFilter(q, scope); + } + return q; + } + + private static ObjectQuery andShadowFilter(ObjectQuery objectQuery, ObjectFilter extra) { + ObjectFilter merged = ObjectQueryUtil.filterAndImmutable(objectQuery.getFilter(), extra); + ObjectQuery clone = objectQuery.clone(); + clone.setFilter(merged); + return clone; + } + + /** + * One filter per schemaHandling object type (deduped). midPoint does not accept a top-level OR for shadow + * repository coordinates, so callers run one search per branch and merge. + */ + private List buildExpandedResourceShadowTypeBranches(String resourceOid, Task task, OperationResult result) + throws SchemaException, ObjectNotFoundException, SecurityViolationException, CommunicationException, + ConfigurationException, ExpressionEvaluationException { + + PrismObject resource = modelService.getObject( + ResourceType.class, resourceOid, GetOperationOptions.createReadOnlyCollection(), task, result); + SchemaHandlingType handling = resource.asObjectable().getSchemaHandling(); + if (handling == null || handling.getObjectType().isEmpty()) { + throw new MidpointMcpException( + "shadow_expand_no_object_types", + 400, + "Resource has no schemaHandling object types to expand: " + resourceOid, + "Define object types on the resource or use shadowKind / objectClass instead of expandResourceObjectTypes."); + } + List branches = new ArrayList<>(); + LinkedHashSet seen = new LinkedHashSet<>(); + for (ResourceObjectTypeDefinitionType def : handling.getObjectType()) { + ShadowKindType kind = def.getKind(); + if (kind == null) { + continue; + } + String intent = def.getIntent(); + String key = kind.toString() + "|" + (intent != null ? intent : ""); + if (!seen.add(key)) { + continue; + } + ObjectFilter branch; + if (StringUtils.isNotBlank(intent)) { + branch = ObjectQueryUtil.createResourceAndKindIntentFilter(resourceOid, kind, intent.trim()); + } else { + branch = ObjectQueryUtil.createResourceAndKind(resourceOid, kind).getFilter(); + } + branches.add(branch); + } + if (branches.isEmpty()) { + throw new MidpointMcpException( + "shadow_expand_no_kinds", + 400, + "Resource schemaHandling object types have no kind: " + resourceOid, + "Use shadowKind / objectClass or fix resource schema handling."); + } + return branches; + } + + private SearchResultList> searchShadowsExpandedBranches( + ObjectQuery baseQuery, + List typeBranches, + int limit, + int offset, + Collection> searchOpts, + Task task, + OperationResult result) + throws SchemaException, ObjectNotFoundException, SecurityViolationException, CommunicationException, + ConfigurationException, ExpressionEvaluationException { + + ObjectFilter userFilter = baseQuery != null ? baseQuery.getFilter() : null; + Map> byOid = new LinkedHashMap<>(); + for (ObjectFilter branch : typeBranches) { + ObjectFilter merged = ObjectQueryUtil.filterAndImmutable(userFilter, branch); + assertShadowSearchCoordinates(prismContext.queryFactory().createQuery(merged)); + ObjectQuery branchQuery = prismContext.queryFactory().createQuery(merged); + branchQuery.setPaging(prismContext.queryFactory().createPaging(0, EXPAND_PER_BRANCH_MAX)); + SearchResultList> part = + modelService.searchObjects(ShadowType.class, branchQuery, searchOpts, task, result); + for (PrismObject s : part) { + byOid.putIfAbsent(s.getOid(), s); + } + } + List> sorted = new ArrayList<>(byOid.values()); + sorted.sort(Comparator.comparing( + o -> o.getName() != null && o.getName().getOrig() != null ? o.getName().getOrig() : "", + String.CASE_INSENSITIVE_ORDER)); + int from = Math.min(offset, sorted.size()); + int to = Math.min(offset + limit, sorted.size()); + return new SearchResultList<>(sorted.subList(from, to)); + } + + private int countShadowsExpandedBranches( + ObjectQuery baseQuery, + List typeBranches, + Collection> searchOpts, + Task task, + OperationResult result) + throws SchemaException, ObjectNotFoundException, SecurityViolationException, CommunicationException, + ConfigurationException, ExpressionEvaluationException { + + ObjectFilter userFilter = baseQuery != null ? baseQuery.getFilter() : null; + int sum = 0; + for (ObjectFilter branch : typeBranches) { + ObjectFilter merged = ObjectQueryUtil.filterAndImmutable(userFilter, branch); + assertShadowSearchCoordinates(prismContext.queryFactory().createQuery(merged)); + ObjectQuery countQuery = prismContext.queryFactory().createQuery(merged); + Integer c = modelService.countObjects(ShadowType.class, countQuery, searchOpts, task, result); + sum += c != null ? c : 0; + } + return sum; + } + + private void assertShadowSearchCoordinates(ObjectQuery objectQuery) throws SchemaException { + ObjectFilter filter = objectQuery != null ? objectQuery.getFilter() : null; + try { + ResourceOperationCoordinates roc = ObjectQueryUtil.getOperationCoordinates(filter); + roc.checkNotResourceScoped(); + } catch (SchemaException e) { + throw new MidpointMcpException( + "shadow_query_invalid_coordinates", + 400, + e.getMessage(), + shadowSearchCoordinatesHint(), + e); + } catch (IllegalArgumentException e) { + String msg = e.getMessage(); + if (msg != null && msg.contains("cannot be applied to the whole resource")) { + throw new MidpointMcpException( + "shadow_query_not_type_scoped", + 400, + "Shadow search must constrain kind or objectClass (midPoint does not allow resource-wide shadow search).", + shadowSearchCoordinatesHint(), + e); + } + throw e; + } + } + + private static String shadowSearchCoordinatesHint() { + return "Examples (MQL): resourceRef matches (oid = \"\") and kind = \"ACCOUNT\"; " + + "or add top-level resourceOid + shadowKind; or resourceOid + expandResourceObjectTypes=true " + + "to OR all schemaHandling object types. Advanced filters: path resourceRef with op eq and OID value " + + "(not resourceRef.oid)."; + } + + private List> shadowLiveGetOptions() { + List> opts = new ArrayList<>(3); + opts.add(SelectorOptions.create(new GetOperationOptions().forceRefresh(Boolean.TRUE))); + opts.add(SelectorOptions.create( + prismContext.toUniformPath(ShadowType.F_ATTRIBUTES), GetOperationOptions.createRetrieve())); + opts.add(SelectorOptions.create( + prismContext.toUniformPath(ShadowType.F_ASSOCIATIONS), GetOperationOptions.createRetrieve())); + return opts; + } + + private List> shadowResourceSearchOptions() { + return List.of( + SelectorOptions.create( + prismContext.toUniformPath(ShadowType.F_ATTRIBUTES), GetOperationOptions.createRetrieve()), + SelectorOptions.create( + prismContext.toUniformPath(ShadowType.F_ASSOCIATIONS), GetOperationOptions.createRetrieve())); + } + + private static String liveVsRepositoryNote(ShadowType live, ShadowType repo) { + List diffs = new ArrayList<>(); + if (!Objects.equals(live.isDead(), repo.isDead())) { + diffs.add("dead: repository=" + repo.isDead() + ", live view=" + live.isDead()); + } + if (!Objects.equals(live.isExists(), repo.isExists())) { + diffs.add("exists: repository=" + repo.isExists() + ", live view=" + live.isExists()); + } + if (diffs.isEmpty()) { + return null; + } + return String.join("; ", diffs); + } + + private static void validateSearchModes(MidpointMcpSearchRequest request) { + int n = 0; + if (StringUtils.isNotBlank(request.getQuery())) { + n++; + } + if (StringUtils.isNotBlank(request.getMql())) { + n++; + } + if (request.getAdvancedQuery() != null) { + n++; + } + if (n > 1) { + throw new IllegalArgumentException("Use only one of: query (simple name prefix), advancedQuery, or mql"); + } + } + + private MidpointMcpSearchResult searchObjectsForClass( + Class clazz, + MidpointMcpSearchRequest request, + Task task, + OperationResult result) + throws SchemaException, ObjectNotFoundException, SecurityViolationException, CommunicationException, + ConfigurationException, ExpressionEvaluationException { + + authorizeRest(RestAuthorizationAction.SEARCH_OBJECTS, task, result); + String rest = ObjectTypes.getRestTypeFromClass(clazz); + PrismObjectDefinition def = + prismContext.getSchemaRegistry().findObjectDefinitionByCompileTimeClass(clazz); + List paths = resolvePathsOrBadRequest(request.getReturnAttributes(), rest, def, true); + + PagingResolution paging = resolvePaging(request); + int normalizedLimit = normalizeLimit(paging.limit()); + int normalizedOffset = normalizeOffset(paging.offset()); + + String usedMode; + String translatedMql = null; + String simpleQuery = null; + ObjectQuery objectQuery; + + if (StringUtils.isNotBlank(request.getQuery())) { + usedMode = "simple"; + simpleQuery = request.getQuery().trim(); + objectQuery = createSimpleSearchQuery(clazz, simpleQuery, normalizedLimit, normalizedOffset); + } else if (request.getAdvancedQuery() != null) { + usedMode = "advancedQuery"; + Map schema = + MidpointMcpAdvancedQueryTranslator.schemaMapForValidation(def); + MidpointMcpAdvancedQuerySpec aq = request.getAdvancedQuery(); + validateOrderByPaths(aq.getOrderBy(), schema, def); + String mqlFilter = MidpointMcpAdvancedQueryTranslator.translateToMqlFilter(aq, schema); + translatedMql = mqlFilter.isEmpty() ? null : mqlFilter; + objectQuery = buildObjectQueryFromMql(clazz, mqlFilter, normalizedLimit, normalizedOffset, aq.getOrderBy(), def); + } else if (StringUtils.isNotBlank(request.getMql())) { + usedMode = "mql"; + translatedMql = request.getMql().trim(); + objectQuery = buildObjectQueryFromMql(clazz, translatedMql, normalizedLimit, normalizedOffset, List.of(), def); + } else { + usedMode = "simple"; + objectQuery = createSimpleSearchQuery(clazz, null, normalizedLimit, normalizedOffset); + } + + SearchResultList> found = modelService.searchObjects(clazz, objectQuery, null, task, result); + + ObjectQuery countQuery = objectQuery.clone(); + countQuery.setPaging(null); + int totalCount = modelService.countObjects(clazz, countQuery, null, task, result); + + MidpointMcpSearchResult searchResult = new MidpointMcpSearchResult(); + searchResult.setType(rest); + searchResult.setQuery(simpleQuery); + searchResult.setUsedQueryMode(usedMode); + searchResult.setTranslatedMql(translatedMql); + searchResult.setLimit(normalizedLimit); + searchResult.setOffset(normalizedOffset); + searchResult.setTotalCount(totalCount); + + MidpointMcpAttributeProjector projector = + new MidpointMcpAttributeProjector(prismContext, modelService, orgStructFunctions); + List items = new ArrayList<>(); + for (PrismObject object : found) { + String summary = MidpointMcpAttributeProjector.searchSummary(object.asObjectable()); + MidpointMcpSearchItem item = new MidpointMcpSearchItem(); + item.setValues(projector.project(object, rest, paths, summary, task, result, false)); + items.add(item); + } + searchResult.setItems(items); + return searchResult; + } + + private record PagingResolution(Integer limit, Integer offset) {} + + private PagingResolution resolvePaging(MidpointMcpSearchRequest request) { + MidpointMcpAdvancedQuerySpec aq = request.getAdvancedQuery(); + if (aq != null && aq.getPaging() != null) { + MidpointMcpAdvancedPagingSpec p = aq.getPaging(); + Integer limit = p.getLimit() != null ? p.getLimit() : request.getLimit(); + Integer offset = p.getOffset() != null ? p.getOffset() : request.getOffset(); + return new PagingResolution(limit, offset); + } + return new PagingResolution(request.getLimit(), request.getOffset()); + } + + private void validateOrderByPaths( + List orderBy, + Map schema, + PrismObjectDefinition objectDef) { + if (orderBy == null || orderBy.isEmpty()) { + return; + } + for (MidpointMcpAdvancedOrderBySpec ob : orderBy) { + if (ob == null || StringUtils.isBlank(ob.getPath())) { + throw new IllegalArgumentException("orderBy.path is required for each entry"); + } + String path = ob.getPath().trim(); + if (!schema.containsKey(path)) { + throw new IllegalArgumentException("Unknown orderBy path '" + path + "' for this object type"); + } + if (objectDef != null + && CaseType.class.equals(objectDef.getCompileTimeClass()) + && MidpointMcpCaseProjector.isOrderByBlockedForCases(path)) { + throw new IllegalArgumentException( + "orderBy.path '" + path + "' is not supported for cases; use a repository-backed Prism path"); + } + } + } + + private ObjectQuery buildObjectQueryFromMql( + Class clazz, + String mqlFilter, + int limit, + int offset, + List orderBy, + PrismObjectDefinition objDef) { + + ObjectQuery oq; + if (StringUtils.isBlank(mqlFilter)) { + oq = prismContext.queryFor(clazz).build(); + } else { + ObjectFilter filter; + try { + filter = prismContext.createQueryParser().parseFilter(clazz, mqlFilter); + } catch (SchemaException e) { + throw new MidpointMcpException("bad_request", 400, "MQL could not be parsed.", e); + } + oq = prismContext.queryFactory().createQuery(filter); + } + ObjectPaging paging = prismContext.queryFactory().createPaging(offset, limit); + if (orderBy != null) { + for (MidpointMcpAdvancedOrderBySpec ob : orderBy) { + if (ob == null || StringUtils.isBlank(ob.getPath())) { + continue; + } + var itemPath = MidpointMcpAdvancedQueryTranslator.dotPathToItemPath(objDef, ob.getPath().trim()); + OrderDirection dir = parseOrderDirection(ob.getDirection()); + paging.addOrderingInstruction(itemPath, dir); + } + } + oq.setPaging(paging); + return oq; + } + + private static OrderDirection parseOrderDirection(String direction) { + if (StringUtils.isBlank(direction)) { + return OrderDirection.ASCENDING; + } + String d = direction.trim().toLowerCase(Locale.ROOT); + if ("desc".equals(d) || "descending".equals(d)) { + return OrderDirection.DESCENDING; + } + if ("asc".equals(d) || "ascending".equals(d)) { + return OrderDirection.ASCENDING; + } + throw new IllegalArgumentException("orderBy.direction must be 'asc' or 'desc'"); + } + + private List resolvePathsOrBadRequest( + List returnAttributes, + String restType, + PrismObjectDefinition def, + boolean useSearchDefaults) { + try { + return MidpointMcpAttributeProjector.resolveRequestedPaths( + returnAttributes, restType, def, useSearchDefaults); + } catch (IllegalArgumentException e) { + throw new MidpointMcpException("bad_request", 400, e.getMessage(), e); + } + } + + @Override + public MidpointMcpTypeSchemaView describeObjectTypeSchema( + String objectType, Integer maxDepth, Task task, OperationResult result) { + return translateExceptions(() -> { + Class clazz = resolveMcpObjectClass(objectType); + authorizeModelReadForType(clazz, task, result); + PrismObjectDefinition objDef = + prismContext.getSchemaRegistry().findObjectDefinitionByCompileTimeClass(clazz); + int depthLimit = resolveSchemaMaxDepth(maxDepth); + MidpointMcpTypeSchemaView view = new MidpointMcpTypeSchemaView(); + view.setType(ObjectTypes.getRestTypeFromClass(clazz)); + List attrs = + new ArrayList<>(MidpointMcpSchemaFlattener.flatten(objDef, depthLimit)); + if (CaseType.class.equals(clazz)) { + Map byPath = new LinkedHashMap<>(); + for (MidpointMcpSchemaAttribute a : attrs) { + byPath.put(a.getPath(), a); + } + for (MidpointMcpSchemaAttribute syn : MidpointMcpCaseSchema.syntheticDescribeAttributes()) { + byPath.putIfAbsent(syn.getPath(), syn); + } + attrs = new ArrayList<>(byPath.values()); + } + view.setAttributes(attrs); + return view; + }); + } + + private static int resolveSchemaMaxDepth(Integer maxDepth) { + if (maxDepth == null) { + return 2; + } + if (maxDepth == 0) { + return Integer.MAX_VALUE; + } + return maxDepth; + } + + private Class resolveMcpObjectClass(String objectTypeRest) { + if (StringUtils.isBlank(objectTypeRest)) { + throw new IllegalArgumentException("type is required"); + } + String normalized = objectTypeRest.trim().toLowerCase(Locale.ROOT); + if (!MCP_OBJECT_REST_TYPES.contains(normalized)) { + throw new IllegalArgumentException("unsupported type '" + objectTypeRest.trim() + + "'; allowed: " + String.join(", ", MCP_OBJECT_REST_TYPES)); + } + return ObjectTypes.getClassFromRestType(normalized); + } + + private ObjectQuery createSimpleSearchQuery(Class type, String query, int limit, int offset) { + if (CaseType.class.equals(type)) { + return createCaseSimpleSearchQuery(query, limit, offset); + } + if (StringUtils.isNotBlank(query)) { + return prismContext.queryFor(type) + .item(ObjectType.F_NAME) + .startsWith(query) + .offset(offset) + .maxSize(limit) + .build(); + } + + return prismContext.queryFor(type) + .offset(offset) + .maxSize(limit) + .build(); + } + + private ObjectQuery createCaseSimpleSearchQuery(String query, int limit, int offset) { + if (StringUtils.isBlank(query)) { + return prismContext.queryFor(CaseType.class) + .offset(offset) + .maxSize(limit) + .build(); + } + String q = query.trim(); + return prismContext.queryFor(CaseType.class) + .item(ObjectType.F_NAME) + .contains(q) + .or() + .item(ObjectType.F_DESCRIPTION) + .contains(q) + .or() + .item(CaseType.F_WORK_ITEM, CaseWorkItemType.F_NAME) + .contains(q) + .offset(offset) + .maxSize(limit) + .build(); + } + + private void authorizeRest(RestAuthorizationAction action, Task task, OperationResult result) { + translateExceptions(() -> { + securityEnforcer.authorize(action.getUri(), task, result); + return null; + }); + } + + private void authorizeModelReadForType( + Class clazz, Task task, OperationResult result) { + translateExceptions(() -> { + securityEnforcer.authorize( + ModelAuthorizationAction.READ.getUrl(), + null, + AuthorizationParameters.Builder.buildObject(prismContext.createObject(clazz)), + task, + result); + return null; + }); + } + + private T translateExceptions(CheckedSupplier supplier) { + try { + return supplier.get(); + } catch (MidpointMcpException e) { + throw e; + } catch (ObjectNotFoundException e) { + throw new MidpointMcpException("not_found", 404, McpPublicErrorMessages.NOT_FOUND, e); + } catch (SecurityViolationException e) { + throw new MidpointMcpException("forbidden", 403, McpPublicErrorMessages.ACCESS_DENIED, e); + } catch (IllegalArgumentException e) { + throw new MidpointMcpException("bad_request", 400, e.getMessage(), e); + } catch (SchemaException | CommunicationException | ConfigurationException | ExpressionEvaluationException e) { + throw new MidpointMcpException( + "internal_error", 500, McpPublicErrorMessages.INTERNAL_ERROR, e); + } + } + + private int normalizeLimit(Integer limit) { + if (limit == null) { + return DEFAULT_LIMIT; + } + if (limit < 1) { + throw new MidpointMcpException("bad_request", 400, "limit must be greater than 0"); + } + return Math.min(limit, MAX_LIMIT); + } + + private int normalizeOffset(Integer offset) { + if (offset == null) { + return 0; + } + if (offset < 0) { + throw new MidpointMcpException("bad_request", 400, "offset must be 0 or greater"); + } + return offset; + } + + @FunctionalInterface + private interface CheckedSupplier { + + T get() throws ObjectNotFoundException, SchemaException, SecurityViolationException, CommunicationException, + ConfigurationException, ExpressionEvaluationException; + } +} diff --git a/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpShadowProjector.java b/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpShadowProjector.java new file mode 100644 index 00000000000..2a4180fe7cc --- /dev/null +++ b/model/mcp-impl/src/main/java/com/evolveum/midpoint/mcp/impl/MidpointMcpShadowProjector.java @@ -0,0 +1,237 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.impl; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import javax.xml.datatype.XMLGregorianCalendar; +import javax.xml.namespace.QName; + +import com.evolveum.midpoint.prism.binding.TypeSafeEnum; +import com.evolveum.midpoint.prism.polystring.PolyString; +import com.evolveum.midpoint.schema.processor.ShadowAssociation; +import com.evolveum.midpoint.schema.processor.ShadowAssociationValue; +import com.evolveum.midpoint.schema.processor.ShadowSimpleAttribute; +import com.evolveum.midpoint.schema.util.ShadowUtil; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ObjectReferenceType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ShadowKindType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ShadowType; +import com.evolveum.prism.xml.ns._public.types_3.PolyStringType; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.lang3.StringUtils; + +/** + * Normalized shadow {@code attributes} and {@code associations} for MCP responses (stable JSON, not raw connector dumps). + */ +final class MidpointMcpShadowProjector { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private MidpointMcpShadowProjector() {} + + static void putShadowPayload(Map values, ShadowType shadow) { + Map attrs = projectAttributes(shadow); + if (!attrs.isEmpty()) { + values.put("attributes", attrs); + } + Map>> assoc = projectAssociations(shadow); + if (!assoc.isEmpty()) { + values.put("associations", assoc); + } + } + + static String explainShadowSummary(ShadowType shadow, String source, boolean fetched) { + String name = shadow.getName() != null ? shadow.getName().getOrig() : shadow.getOid(); + StringBuilder sb = new StringBuilder(256); + sb.append("Shadow '") + .append(name) + .append("' — data source: ") + .append(source) + .append(fetched ? " (live resource involved)" : " (repository only)") + .append(". "); + if (shadow.getResourceRef() != null && StringUtils.isNotBlank(shadow.getResourceRef().getOid())) { + sb.append("Resource OID ").append(shadow.getResourceRef().getOid()).append(". "); + } + if (shadow.getKind() != null) { + sb.append("Kind ").append(shadow.getKind().value()).append(". "); + } + if (StringUtils.isNotBlank(shadow.getIntent())) { + sb.append("Intent ").append(shadow.getIntent()).append(". "); + } + if (shadow.getObjectClass() != null) { + sb.append("Object class ").append(shadow.getObjectClass().toString()).append(". "); + } + if (shadow.getSynchronizationSituation() != null) { + sb.append("Sync situation ") + .append(shadow.getSynchronizationSituation().value()) + .append(". "); + } + Map>> assoc = projectAssociations(shadow); + if (!assoc.isEmpty()) { + sb.append("Associations: "); + List parts = new ArrayList<>(); + for (Map.Entry>> e : assoc.entrySet()) { + parts.add(e.getKey() + "(" + e.getValue().size() + ")"); + } + sb.append(String.join(", ", parts)).append(". "); + } + return sb.toString().trim(); + } + + private static Map projectAttributes(ShadowType shadow) { + Map out = new LinkedHashMap<>(); + for (var attr : ShadowUtil.getAttributes(shadow.asPrismObject())) { + if (!(attr instanceof ShadowSimpleAttribute simple)) { + continue; + } + String local = simple.getElementName().getLocalPart(); + if (isSensitiveAttributeName(local)) { + continue; + } + Collection real = simple.getRealValues(); + if (real == null || real.isEmpty()) { + continue; + } + Object jsonVal; + if (real.size() == 1) { + jsonVal = simplify(real.iterator().next()); + } else { + List list = new ArrayList<>(real.size()); + for (Object v : real) { + list.add(simplify(v)); + } + jsonVal = list; + } + out.putIfAbsent(local, jsonVal); + } + return out; + } + + private static boolean isSensitiveAttributeName(String local) { + if (local == null) { + return true; + } + String l = local.toLowerCase(); + return l.contains("password") || l.contains("secret") || "userpassword".equals(l); + } + + private static Object simplify(Object v) { + if (v == null) { + return null; + } + if (v instanceof PolyStringType p) { + return p.getOrig(); + } + if (v instanceof PolyString ps) { + return ps.getOrig(); + } + if (v instanceof TypeSafeEnum ts) { + return ts.value(); + } + if (v instanceof Enum e) { + return e.name(); + } + if (v instanceof XMLGregorianCalendar cal) { + return cal.toXMLFormat(); + } + if (v instanceof QName q) { + return q.toString(); + } + if (v instanceof byte[]) { + return null; + } + if (v instanceof Number || v instanceof Boolean || v instanceof String) { + return v; + } + try { + return OBJECT_MAPPER.convertValue(v, Object.class); + } catch (IllegalArgumentException e) { + return String.valueOf(v); + } + } + + private static Map>> projectAssociations(ShadowType shadow) { + Map>> out = new LinkedHashMap<>(); + for (ShadowAssociation association : ShadowUtil.getAssociations(shadow)) { + QName name = association.getElementName(); + if (name == null) { + continue; + } + String key = name.getLocalPart(); + List> entries = new ArrayList<>(); + for (ShadowAssociationValue val : association.getAssociationValues()) { + Map one = new LinkedHashMap<>(); + ObjectReferenceType ref = val.getSingleObjectRefRelaxed(); + if (ref != null) { + if (StringUtils.isNotBlank(ref.getOid())) { + one.put("identifier", ref.getOid()); + } + if (ref.getTargetName() != null && StringUtils.isNotBlank(ref.getTargetName().getOrig())) { + one.put("displayName", ref.getTargetName().getOrig()); + } + } + for (ShadowSimpleAttribute a : val.getAttributes()) { + String ln = a.getElementName().getLocalPart(); + if ("name".equalsIgnoreCase(ln) || "uid".equalsIgnoreCase(ln) || "dn".equalsIgnoreCase(ln)) { + Collection real = a.getRealValues(); + if (real != null && !real.isEmpty()) { + String id = String.valueOf(simplify(real.iterator().next())); + one.putIfAbsent("identifier", id); + one.putIfAbsent("displayName", id); + } + } + } + if (!one.isEmpty()) { + entries.add(one); + } + } + if (!entries.isEmpty()) { + out.put(key, entries); + } + } + return out; + } + + static ShadowKindType parseShadowKindEnum(String raw) { + if (StringUtils.isBlank(raw)) { + throw new IllegalArgumentException("shadowKind is blank"); + } + try { + return ShadowKindType.fromValue(raw.trim().toLowerCase()); + } catch (IllegalArgumentException e) { + try { + return ShadowKindType.valueOf(raw.trim().toUpperCase()); + } catch (IllegalArgumentException e2) { + throw new IllegalArgumentException("Invalid shadowKind: use e.g. account, entitlement, generic"); + } + } + } + + /** + * Accepts JAXB {@code {namespaceURI}localPart} form or {@code namespaceURI#localPart}. + */ + static QName parseObjectClass(String raw) { + if (StringUtils.isBlank(raw)) { + throw new IllegalArgumentException("objectClass is blank"); + } + String s = raw.trim(); + if (s.startsWith("{")) { + return QName.valueOf(s); + } + int hash = s.lastIndexOf('#'); + if (hash > 0 && hash < s.length() - 1) { + return new QName(s.substring(0, hash), s.substring(hash + 1)); + } + throw new IllegalArgumentException( + "objectClass must be in {namespaceURI}localPart or namespaceURI#localPart form"); + } +} diff --git a/model/mcp-impl/src/main/resources/ctx-mcp.xml b/model/mcp-impl/src/main/resources/ctx-mcp.xml new file mode 100644 index 00000000000..de432dcd827 --- /dev/null +++ b/model/mcp-impl/src/main/resources/ctx-mcp.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/model/mcp-impl/src/test/java/com/evolveum/midpoint/mcp/impl/MidpointMcpAuditDeltaDetailBuilderTest.java b/model/mcp-impl/src/test/java/com/evolveum/midpoint/mcp/impl/MidpointMcpAuditDeltaDetailBuilderTest.java new file mode 100644 index 00000000000..2a65cc8da0e --- /dev/null +++ b/model/mcp-impl/src/test/java/com/evolveum/midpoint/mcp/impl/MidpointMcpAuditDeltaDetailBuilderTest.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.impl; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import com.evolveum.midpoint.prism.path.ItemPath; +import com.evolveum.midpoint.prism.util.PrismTestUtil; +import com.evolveum.midpoint.schema.DeltaConvertor; +import com.evolveum.midpoint.schema.MidPointPrismContextFactory; +import com.evolveum.midpoint.tools.testng.AbstractUnitTest; +import com.evolveum.midpoint.util.exception.SchemaException; +import com.evolveum.midpoint.xml.ns._public.common.common_3.AssignmentHolderType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.CredentialsType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.PasswordType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.UserType; +import com.evolveum.prism.xml.ns._public.types_3.ObjectDeltaType; +import com.evolveum.prism.xml.ns._public.types_3.ProtectedStringType; + +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; +import org.xml.sax.SAXException; + +/** + * Unit tests for {@link MidpointMcpAuditDeltaDetailBuilder}. + */ +public class MidpointMcpAuditDeltaDetailBuilderTest extends AbstractUnitTest { + + private static final ItemPath CREDENTIALS_PASSWORD_VALUE_PATH = + ItemPath.create(UserType.F_CREDENTIALS, CredentialsType.F_PASSWORD, PasswordType.F_VALUE); + + @BeforeClass + public void initPrismContext() throws SchemaException, IOException, SAXException { + if (com.evolveum.midpoint.prism.PrismContext.get() == null) { + PrismTestUtil.resetPrismContext(MidPointPrismContextFactory.FACTORY); + } + } + + @Test + public void replacePropertyProducesOldAndNew() throws SchemaException { + var objectDelta = PrismTestUtil.getPrismContext().deltaFactory().object() + .createModificationReplaceProperty(UserType.class, "oid-1", UserType.F_COST_CENTER, "cc-new"); + + ObjectDeltaType bean = DeltaConvertor.toObjectDeltaType(objectDelta); + MidpointMcpAuditDeltaDetailBuilder.AttributeChangesResult r = MidpointMcpAuditDeltaDetailBuilder.buildAttributeChanges(bean); + + assertFalse(r.truncated()); + List> rows = r.rows(); + assertEquals(rows.size(), 1); + Map row = rows.get(0); + assertTrue(row.get("path").toString().toLowerCase().contains("costcenter")); + assertEquals(row.get("modificationType"), "REPLACE"); + assertNull(row.get("oldValue")); + assertEquals(row.get("newValue"), "cc-new"); + } + + @Test + public void passwordReplaceIsRedacted() throws SchemaException { + ProtectedStringType protectedString = new ProtectedStringType(); + protectedString.setClearValue("secret-value"); + + var objectDelta = PrismTestUtil.getPrismContext().deltaFactory().object() + .createModificationReplaceProperty(UserType.class, "oid-2", CREDENTIALS_PASSWORD_VALUE_PATH, protectedString); + + ObjectDeltaType bean = DeltaConvertor.toObjectDeltaType(objectDelta); + MidpointMcpAuditDeltaDetailBuilder.AttributeChangesResult r = MidpointMcpAuditDeltaDetailBuilder.buildAttributeChanges(bean); + + assertEquals(r.rows().size(), 1); + Map row = r.rows().get(0); + assertTrue(row.get("path").toString().toLowerCase().contains("password")); + assertEquals(row.get("oldValue"), "***"); + assertEquals(row.get("newValue"), "***"); + } + + @Test + public void iterationAndIterationTokenAreOmittedFromDeltaRows() throws SchemaException { + var objectDelta = PrismTestUtil.getPrismContext().deltaFactory().object() + .createEmptyModifyDelta(UserType.class, "oid-3"); + objectDelta.addModificationReplaceProperty(UserType.F_COST_CENTER, "cc"); + objectDelta.addModificationReplaceProperty(AssignmentHolderType.F_ITERATION, 0); + objectDelta.addModificationReplaceProperty(AssignmentHolderType.F_ITERATION_TOKEN, "token-value"); + + ObjectDeltaType bean = DeltaConvertor.toObjectDeltaType(objectDelta); + MidpointMcpAuditDeltaDetailBuilder.AttributeChangesResult r = MidpointMcpAuditDeltaDetailBuilder.buildAttributeChanges(bean); + + assertFalse(r.truncated()); + assertEquals(r.rows().size(), 1); + assertTrue(r.rows().get(0).get("path").toString().toLowerCase().contains("costcenter")); + } +} diff --git a/model/mcp-impl/src/test/java/com/evolveum/midpoint/mcp/impl/MidpointMcpAuditFilterBuilderTest.java b/model/mcp-impl/src/test/java/com/evolveum/midpoint/mcp/impl/MidpointMcpAuditFilterBuilderTest.java new file mode 100644 index 00000000000..06cb9f4d4e5 --- /dev/null +++ b/model/mcp-impl/src/test/java/com/evolveum/midpoint/mcp/impl/MidpointMcpAuditFilterBuilderTest.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.impl; + +import static org.testng.Assert.assertNotNull; + +import com.evolveum.midpoint.mcp.api.MidpointMcpAdvancedFilterSpec; +import com.evolveum.midpoint.mcp.api.MidpointMcpAdvancedQuerySpec; +import com.evolveum.midpoint.prism.PrismContext; +import com.evolveum.midpoint.prism.query.ObjectFilter; +import com.evolveum.midpoint.prism.util.PrismTestUtil; +import com.evolveum.midpoint.schema.MidPointPrismContextFactory; +import com.evolveum.midpoint.tools.testng.AbstractUnitTest; + +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; +import org.xml.sax.SAXException; + +import java.io.IOException; + +/** + * Unit tests for {@link MidpointMcpAuditFilterBuilder}. + */ +public class MidpointMcpAuditFilterBuilderTest extends AbstractUnitTest { + + @BeforeClass + public void initPrismContextIfNeeded() throws com.evolveum.midpoint.util.exception.SchemaException, IOException, SAXException { + if (PrismContext.get() == null) { + PrismTestUtil.resetPrismContext(MidPointPrismContextFactory.FACTORY); + } + } + + @Test + public void buildAdvancedFilterMessageEqProducesFilter() { + MidpointMcpAdvancedQuerySpec spec = new MidpointMcpAdvancedQuerySpec(); + MidpointMcpAdvancedFilterSpec f = new MidpointMcpAdvancedFilterSpec(); + f.setPath("message"); + f.setOp("eq"); + f.setValue("login"); + spec.getFilters().add(f); + + ObjectFilter filter = MidpointMcpAuditFilterBuilder.buildAdvancedFilter(PrismContext.get(), spec); + assertNotNull(filter); + } + + @Test + public void describeAdvancedFilterIncludesMessage() { + MidpointMcpAdvancedQuerySpec spec = new MidpointMcpAdvancedQuerySpec(); + MidpointMcpAdvancedFilterSpec f = new MidpointMcpAdvancedFilterSpec(); + f.setPath("message"); + f.setOp("contains"); + f.setValue("admin"); + spec.getFilters().add(f); + + ObjectFilter filter = MidpointMcpAuditFilterBuilder.buildAdvancedFilter(PrismContext.get(), spec); + String desc = MidpointMcpAuditFilterBuilder.describeAdvancedFilter(filter); + assertNotNull(desc); + } +} diff --git a/model/mcp-impl/src/test/java/com/evolveum/midpoint/mcp/impl/MidpointMcpServiceImplTest.java b/model/mcp-impl/src/test/java/com/evolveum/midpoint/mcp/impl/MidpointMcpServiceImplTest.java new file mode 100644 index 00000000000..3f1765802b0 --- /dev/null +++ b/model/mcp-impl/src/test/java/com/evolveum/midpoint/mcp/impl/MidpointMcpServiceImplTest.java @@ -0,0 +1,1013 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.mcp.impl; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; +import static org.testng.Assert.fail; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +import com.evolveum.midpoint.mcp.api.McpPublicErrorMessages; +import com.evolveum.midpoint.mcp.api.MidpointMcpAdvancedFilterSpec; +import com.evolveum.midpoint.mcp.api.MidpointMcpAdvancedQuerySpec; +import com.evolveum.midpoint.mcp.api.MidpointMcpAuditExplainRequest; +import com.evolveum.midpoint.mcp.api.MidpointMcpAuditSearchRequest; +import com.evolveum.midpoint.mcp.api.MidpointMcpException; +import com.evolveum.midpoint.mcp.api.MidpointMcpObjectView; +import com.evolveum.midpoint.mcp.api.MidpointMcpSchemaAttribute; +import com.evolveum.midpoint.mcp.api.MidpointMcpSearchRequest; +import com.evolveum.midpoint.mcp.api.MidpointMcpSearchResult; +import com.evolveum.midpoint.mcp.api.MidpointMcpTypeSchemaView; +import com.evolveum.midpoint.model.api.ModelAuditService; +import com.evolveum.midpoint.model.api.ModelAuthorizationAction; +import com.evolveum.midpoint.model.api.ModelService; +import com.evolveum.midpoint.model.api.expr.OrgStructFunctions; +import com.evolveum.midpoint.prism.PrismContext; +import com.evolveum.midpoint.prism.PrismObject; +import com.evolveum.midpoint.prism.query.ObjectPaging; +import com.evolveum.midpoint.prism.query.ObjectQuery; +import com.evolveum.midpoint.prism.util.PrismTestUtil; +import com.evolveum.midpoint.schema.GetOperationOptions; +import com.evolveum.midpoint.schema.SelectorOptions; +import com.evolveum.midpoint.schema.MidPointPrismContextFactory; +import com.evolveum.midpoint.schema.AccessDecision; +import com.evolveum.midpoint.schema.SearchResultList; +import com.evolveum.midpoint.schema.result.OperationResult; +import com.evolveum.midpoint.security.api.RestAuthorizationAction; +import com.evolveum.midpoint.security.enforcer.api.SecurityEnforcer; +import com.evolveum.midpoint.task.api.Task; +import com.evolveum.midpoint.task.api.test.NullTaskImpl; +import com.evolveum.midpoint.tools.testng.AbstractUnitTest; +import com.evolveum.midpoint.util.exception.ObjectNotFoundException; +import com.evolveum.midpoint.util.exception.SecurityViolationException; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ActivationStatusType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ActivationType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ArchetypeType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.CaseType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ConnectorType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.NodeType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ObjectType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ResourceType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ServiceType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.ShadowType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.TaskType; +import com.evolveum.midpoint.xml.ns._public.common.audit_3.AuditEventRecordType; +import com.evolveum.midpoint.xml.ns._public.common.common_3.UserType; +import com.evolveum.prism.xml.ns._public.types_3.PolyStringType; + +import org.testng.annotations.BeforeClass; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import org.xml.sax.SAXException; + +/** + * Unit tests for {@link MidpointMcpServiceImpl} using JDK proxies (no Mockito). + */ +public class MidpointMcpServiceImplTest extends AbstractUnitTest { + + private static final Task TASK = NullTaskImpl.INSTANCE; + + private CapturingModelHandler modelHandler; + private CapturingAuditHandler auditHandler; + private MidpointMcpServiceImpl service; + + @BeforeClass + public void initPrismContextIfNeeded() throws com.evolveum.midpoint.util.exception.SchemaException, IOException, SAXException { + if (PrismContext.get() == null) { + PrismTestUtil.resetPrismContext(MidPointPrismContextFactory.FACTORY); + } + } + + @BeforeMethod + public void createService() { + modelHandler = new CapturingModelHandler(); + auditHandler = new CapturingAuditHandler(); + ModelService modelService = (ModelService) Proxy.newProxyInstance( + ModelService.class.getClassLoader(), + new Class[] { ModelService.class }, + modelHandler); + ModelAuditService modelAuditService = (ModelAuditService) Proxy.newProxyInstance( + ModelAuditService.class.getClassLoader(), + new Class[] { ModelAuditService.class }, + auditHandler); + + service = new MidpointMcpServiceImpl(); + inject(service, "modelService", modelService); + inject(service, "modelAuditService", modelAuditService); + inject(service, "orgStructFunctions", newNoOpOrgStructFunctions()); + inject(service, "prismContext", PrismContext.get()); + inject(service, "securityEnforcer", newAllowAllSecurityEnforcer()); + } + + @Test + public void describeObjectTypeSchemaAuthorizesModelRead() { + AtomicReference lastOperationUrl = new AtomicReference<>(); + inject(service, "securityEnforcer", newCapturingAllowAllSecurityEnforcer(lastOperationUrl)); + + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + service.describeObjectTypeSchema("users", null, TASK, result); + + assertEquals(lastOperationUrl.get(), ModelAuthorizationAction.READ.getUrl()); + } + + @Test + public void explainObjectAuthorizesGetObject() throws Exception { + AtomicReference lastOperationUrl = new AtomicReference<>(); + inject(service, "securityEnforcer", newCapturingAllowAllSecurityEnforcer(lastOperationUrl)); + + PrismObject user = PrismContext.get().createObject(UserType.class); + user.asObjectable().setOid("u1"); + user.asObjectable().setName(poly("a")); + modelHandler.getObjectResult = user; + + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + service.explainObject("users", "u1", null, null, TASK, result); + + assertEquals(lastOperationUrl.get(), RestAuthorizationAction.GET_OBJECT.getUri()); + } + + @Test + public void searchObjectsAuthorizesSearchObjects() throws Exception { + AtomicReference lastOperationUrl = new AtomicReference<>(); + inject(service, "securityEnforcer", newCapturingAllowAllSecurityEnforcer(lastOperationUrl)); + + PrismObject user = PrismContext.get().createObject(UserType.class); + user.asObjectable().setOid("u1"); + user.asObjectable().setName(poly("bob")); + modelHandler.searchResult = new SearchResultList<>(List.of(user)); + modelHandler.count = 1; + + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + service.searchObjects(search("users", null, 10, 0, null), TASK, result); + + assertEquals(lastOperationUrl.get(), RestAuthorizationAction.SEARCH_OBJECTS.getUri()); + } + + @Test + public void describeObjectTypeSchemaUsersReturnsAttributesAndExtension() { + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + MidpointMcpTypeSchemaView view = service.describeObjectTypeSchema("users", null, TASK, result); + + assertEquals(view.getType(), "users"); + List attrs = view.getAttributes(); + assertTrue(attrs != null && !attrs.isEmpty(), "schema attributes expected"); + assertTrue(attrs.stream().anyMatch(a -> "name".equals(a.getPath())), "expected top-level name path"); + assertNotNull( + PrismContext.get() + .getSchemaRegistry() + .findObjectDefinitionByCompileTimeClass(UserType.class) + .findContainerDefinition(ObjectType.F_EXTENSION), + "User object definition should include extension container (items appear when registered in schema)"); + assertTrue( + attrs.stream().noneMatch(a -> a.getPath().contains("operationExecution")), + "operationExecution subtree should be omitted"); + assertTrue(attrs.stream().noneMatch(a -> a.getPath().startsWith("metadata.")), + "metadata subtree should be omitted from default schema"); + assertTrue(attrs.stream().noneMatch(a -> a.getPath().startsWith("lensContext.")), + "lensContext subtree should be omitted"); + assertTrue(attrs.stream().noneMatch(a -> a.getPath().startsWith("trigger.")), + "trigger subtree should be omitted"); + assertTrue(attrs.stream().noneMatch(a -> "jpegPhoto".equals(a.getPath())), + "jpegPhoto should be omitted"); + assertTrue(attrs.stream().anyMatch(a -> "assignment.targetRef".equals(a.getPath())), + "expected assignment.targetRef at default depth"); + Optional effStatus = attrs.stream() + .filter(a -> "activation.effectiveStatus".equals(a.getPath())) + .findFirst(); + assertTrue(effStatus.isPresent(), "activation.effectiveStatus expected at depth 2"); + assertNotNull(effStatus.get().getEnum(), "enumeration values from Prism schema"); + assertTrue( + effStatus.get().getEnum().contains("ENABLED") + && effStatus.get().getEnum().contains("DISABLED") + && effStatus.get().getEnum().contains("ARCHIVED"), + "ActivationStatusType literals"); + } + + @Test + public void describeObjectTypeSchemaMaxDepthTwoLimitsPathSegments() { + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + MidpointMcpTypeSchemaView view = service.describeObjectTypeSchema("users", 2, TASK, result); + + for (MidpointMcpSchemaAttribute a : view.getAttributes()) { + assertTrue( + pathSegmentCount(a.getPath()) <= 2, + "path exceeds maxDepth: " + a.getPath()); + } + } + + @Test + public void describeObjectTypeSchemaUnlimitedReturnsMoreThanDefaultDepth() { + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + int shallow = service.describeObjectTypeSchema("users", null, TASK, result).getAttributes().size(); + int deep = service.describeObjectTypeSchema("users", 0, TASK, result).getAttributes().size(); + assertTrue(deep >= shallow, "unlimited depth should not drop attributes"); + assertTrue(deep > shallow, "user schema should have paths deeper than default depth 2"); + } + + @Test + public void describeObjectTypeSchemaRejectsUnknownType() { + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + try { + service.describeObjectTypeSchema("unknown", null, TASK, result); + fail("expected MidpointMcpException"); + } catch (MidpointMcpException e) { + assertEquals(e.getStatus(), 400); + } + } + + private static int pathSegmentCount(String path) { + if (path == null || path.isEmpty()) { + return 0; + } + int n = 1; + for (int i = 0; i < path.length(); i++) { + if (path.charAt(i) == '.') { + n++; + } + } + return n; + } + + @Test + public void explainUserSuccess() throws Exception { + PrismObject user = PrismContext.get().createObject(UserType.class); + user.asObjectable().setOid("u1"); + PolyStringType name = new PolyStringType(); + name.setOrig("alice"); + user.asObjectable().setName(name); + modelHandler.getObjectResult = user; + + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + MidpointMcpObjectView view = service.explainObject("users", "u1", null, null, TASK, result); + + assertEquals(view.getValues().get("oid"), "u1"); + assertEquals(view.getValues().get("type"), "users"); + assertEquals(view.getValues().get("name"), "alice"); + assertNotNull(view.getValues().get("summary")); + assertTrue(view.getValues().get("summary").toString().contains("User 'alice'")); + assertNull(view.getValues().get("activation.administrativeStatus")); + } + + @Test + public void explainUserIncludesActivationWhenPresent() throws Exception { + PrismObject user = PrismContext.get().createObject(UserType.class); + user.asObjectable().setOid("u-act"); + user.asObjectable().setName(poly("act-user")); + ActivationType activation = new ActivationType(); + activation.setAdministrativeStatus(ActivationStatusType.ENABLED); + activation.setEffectiveStatus(ActivationStatusType.ENABLED); + user.asObjectable().setActivation(activation); + modelHandler.getObjectResult = user; + + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + MidpointMcpObjectView view = service.explainObject("users", "u-act", null, null, TASK, result); + + assertEquals(view.getValues().get("activation.administrativeStatus"), "ENABLED"); + assertEquals(view.getValues().get("activation.effectiveStatus"), "ENABLED"); + } + + @Test + public void explainUserNotFound() { + modelHandler.getObjectError = new ObjectNotFoundException("gone"); + + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + try { + service.explainObject("users", "missing", null, null, TASK, result); + fail("expected MidpointMcpException"); + } catch (MidpointMcpException ex) { + assertEquals(ex.getCode(), "not_found"); + assertEquals(ex.getStatus(), 404); + assertEquals(ex.getMessage(), McpPublicErrorMessages.NOT_FOUND); + } + } + + @Test + public void explainUserForbidden() { + modelHandler.getObjectError = new SecurityViolationException("nope"); + + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + try { + service.explainObject("users", "u1", null, null, TASK, result); + fail("expected MidpointMcpException"); + } catch (MidpointMcpException ex) { + assertEquals(ex.getCode(), "forbidden"); + assertEquals(ex.getStatus(), 403); + assertEquals(ex.getMessage(), McpPublicErrorMessages.ACCESS_DENIED); + } + } + + @Test + public void explainObjectRejectsUnsupportedType() { + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + try { + service.explainObject("reports", "x", null, null, TASK, result); + fail("expected MidpointMcpException"); + } catch (MidpointMcpException ex) { + assertEquals(ex.getCode(), "bad_request"); + assertEquals(ex.getStatus(), 400); + } + } + + @Test + public void explainObjectNormalizesTypeCase() throws Exception { + PrismObject user = PrismContext.get().createObject(UserType.class); + user.asObjectable().setOid("u1"); + user.asObjectable().setName(poly("alice")); + modelHandler.getObjectResult = user; + + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + MidpointMcpObjectView view = service.explainObject("Users", "u1", null, null, TASK, result); + assertEquals(view.getValues().get("type"), "users"); + } + + @Test + public void searchUsersRejectsNonPositiveLimit() { + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + try { + service.searchObjects(search("users", null, 0, 0, null), TASK, result); + fail("expected MidpointMcpException"); + } catch (MidpointMcpException ex) { + assertEquals(ex.getCode(), "bad_request"); + assertEquals(ex.getStatus(), 400); + } + } + + @Test + public void searchUsersRejectsNegativeOffset() { + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + try { + service.searchObjects(search("users", null, 10, -1, null), TASK, result); + fail("expected MidpointMcpException"); + } catch (MidpointMcpException ex) { + assertEquals(ex.getCode(), "bad_request"); + assertEquals(ex.getStatus(), 400); + } + } + + @Test + public void searchUsersCapsLimitAt100() throws Exception { + PrismObject user = PrismContext.get().createObject(UserType.class); + user.asObjectable().setOid("u1"); + PolyStringType name = new PolyStringType(); + name.setOrig("bob"); + user.asObjectable().setName(name); + modelHandler.searchResult = new SearchResultList<>(List.of(user)); + modelHandler.count = 1; + + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + MidpointMcpSearchResult searchResult = service.searchObjects(search("users", null, 500, 0, null), TASK, result); + + assertEquals(searchResult.getLimit(), 100); + assertNotNull(modelHandler.lastSearchQuery); + assertNotNull(modelHandler.lastSearchQuery.getPaging()); + assertEquals(modelHandler.lastSearchQuery.getPaging().getMaxSize(), Integer.valueOf(100)); + } + + @Test + public void searchUsersOmitsHeavyExplainFieldsByDefault() throws Exception { + PrismObject user = PrismContext.get().createObject(UserType.class); + user.asObjectable().setOid("u-heavy"); + user.asObjectable().setName(poly("heavy")); + modelHandler.searchResult = new SearchResultList<>(List.of(user)); + modelHandler.count = 1; + + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + MidpointMcpSearchResult searchResult = service.searchObjects(search("users", null, 10, 0, null), TASK, result); + + assertNull(searchResult.getItems().getFirst().getValues().get("assignment")); + assertNull(searchResult.getItems().getFirst().getValues().get("linkRef")); + assertNull(searchResult.getItems().getFirst().getValues().get("parentOrgRef")); + } + + @Test + public void searchUsersHappyPath() throws Exception { + PrismObject user = PrismContext.get().createObject(UserType.class); + user.asObjectable().setOid("u2"); + PolyStringType name = new PolyStringType(); + name.setOrig("carol"); + user.asObjectable().setName(name); + modelHandler.searchResult = new SearchResultList<>(List.of(user)); + modelHandler.count = 42; + + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + MidpointMcpSearchResult searchResult = service.searchObjects(search("users", "car", null, null, null), TASK, result); + + assertEquals(searchResult.getType(), "users"); + assertEquals(searchResult.getTotalCount(), 42); + assertEquals(searchResult.getItems().size(), 1); + assertEquals(searchResult.getItems().getFirst().getValues().get("oid"), "u2"); + assertEquals(searchResult.getLimit(), 20); + assertEquals(searchResult.getOffset(), 0); + assertEquals(searchResult.getUsedQueryMode(), "simple"); + assertNull(searchResult.getTranslatedMql()); + assertNull(searchResult.getItems().getFirst().getValues().get("activation.administrativeStatus")); + } + + @Test + public void searchMutuallyExclusiveModesRejected() { + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + MidpointMcpSearchRequest req = search("users", "a", null, null, null); + req.setMql("name = \"x\""); + try { + service.searchObjects(req, TASK, result); + fail("expected MidpointMcpException"); + } catch (MidpointMcpException ex) { + assertEquals(ex.getStatus(), 400); + assertTrue(ex.getMessage().contains("Use only one")); + } + } + + @Test + public void searchAdvancedQueryTranslatesMql() throws Exception { + PrismObject user = PrismContext.get().createObject(UserType.class); + user.asObjectable().setOid("u3"); + user.asObjectable().setName(poly("jack")); + modelHandler.searchResult = new SearchResultList<>(List.of(user)); + modelHandler.count = 1; + + MidpointMcpAdvancedQuerySpec aq = new MidpointMcpAdvancedQuerySpec(); + MidpointMcpAdvancedFilterSpec f1 = new MidpointMcpAdvancedFilterSpec(); + f1.setPath("givenName"); + f1.setOp("startsWith"); + f1.setValue("J"); + aq.getFilters().add(f1); + MidpointMcpAdvancedFilterSpec f2 = new MidpointMcpAdvancedFilterSpec(); + f2.setPath("activation.effectiveStatus"); + f2.setOp("eq"); + f2.setValue("ENABLED"); + aq.getFilters().add(f2); + + MidpointMcpSearchRequest req = search("users", null, 10, 0, null); + req.setAdvancedQuery(aq); + + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + MidpointMcpSearchResult out = service.searchObjects(req, TASK, result); + + assertEquals(out.getUsedQueryMode(), "advancedQuery"); + assertEquals( + out.getTranslatedMql(), + "givenName startsWith \"J\" and activation/effectiveStatus = \"ENABLED\""); + assertNotNull(modelHandler.lastSearchQuery); + } + + @Test + public void searchCasesAdvancedQueryStateEqOpen() throws Exception { + PrismObject aCase = PrismContext.get().createObject(CaseType.class); + aCase.asObjectable().setOid("case-oid-1"); + aCase.asObjectable().setName(poly("approval case")); + modelHandler.searchResult = new SearchResultList<>(List.of(aCase)); + modelHandler.count = 1; + + MidpointMcpAdvancedQuerySpec aq = new MidpointMcpAdvancedQuerySpec(); + MidpointMcpAdvancedFilterSpec f = new MidpointMcpAdvancedFilterSpec(); + f.setPath("state"); + f.setOp("eq"); + f.setValue("open"); + aq.getFilters().add(f); + + MidpointMcpSearchRequest req = search("cases", null, 10, 0, null); + req.setAdvancedQuery(aq); + + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + MidpointMcpSearchResult out = service.searchObjects(req, TASK, result); + + assertEquals(out.getUsedQueryMode(), "advancedQuery"); + assertEquals(out.getTranslatedMql(), "state = \"open\""); + } + + @Test + public void explainCaseIncludesExplanationAndChildSearch() throws Exception { + PrismObject aCase = PrismContext.get().createObject(CaseType.class); + aCase.asObjectable().setOid("case-root"); + aCase.asObjectable().setName(poly("Root case")); + aCase.asObjectable().setState("open"); + modelHandler.getObjectResult = aCase; + modelHandler.searchResult = SearchResultList.empty(); + modelHandler.count = 0; + + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + MidpointMcpObjectView view = service.explainObject("cases", "case-root", null, null, TASK, result); + + assertEquals(view.getValues().get("type"), "cases"); + assertNotNull(view.getValues().get("explanation")); + assertNotNull(view.getValues().get("childCases")); + assertNotNull(view.getValues().get("currentStep")); + } + + @Test + public void searchAdvancedQueryUnknownPathRejected() { + MidpointMcpAdvancedQuerySpec aq = new MidpointMcpAdvancedQuerySpec(); + MidpointMcpAdvancedFilterSpec f = new MidpointMcpAdvancedFilterSpec(); + f.setPath("totallyUnknownFieldXyz"); + f.setOp("exists"); + aq.getFilters().add(f); + MidpointMcpSearchRequest req = search("users", null, 10, 0, null); + req.setAdvancedQuery(aq); + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + try { + service.searchObjects(req, TASK, result); + fail("expected MidpointMcpException"); + } catch (MidpointMcpException ex) { + assertEquals(ex.getStatus(), 400); + assertTrue(ex.getMessage().contains("Unknown path")); + } + } + + @Test + public void searchRawMqlParseErrorIsBadRequest() { + MidpointMcpSearchRequest req = search("users", null, 10, 0, null); + req.setMql("this is not valid mql !!!"); + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + try { + service.searchObjects(req, TASK, result); + fail("expected MidpointMcpException"); + } catch (MidpointMcpException ex) { + assertEquals(ex.getStatus(), 400); + assertEquals(ex.getCode(), "bad_request"); + assertEquals(ex.getMessage(), "MQL could not be parsed."); + } + } + + @Test + public void explainResourceActivationIsNull() throws Exception { + PrismObject resource = PrismContext.get().createObject(ResourceType.class); + resource.asObjectable().setOid("r1"); + resource.asObjectable().setName(poly("res-a")); + modelHandler.getObjectResult = resource; + + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + MidpointMcpObjectView view = service.explainObject("resources", "r1", null, null, TASK, result); + + assertEquals(view.getValues().get("type"), "resources"); + assertNull(view.getValues().get("activation.administrativeStatus")); + assertTrue(view.getValues().get("summary").toString().contains("Resource 'res-a'")); + } + + @Test + public void explainShadowIncludesActivationWhenPresent() throws Exception { + PrismObject shadow = PrismContext.get().createObject(ShadowType.class); + shadow.asObjectable().setOid("s1"); + shadow.asObjectable().setName(poly("acc-a")); + ActivationType activation = new ActivationType(); + activation.setEffectiveStatus(ActivationStatusType.DISABLED); + shadow.asObjectable().setActivation(activation); + modelHandler.getObjectResult = shadow; + + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + MidpointMcpObjectView view = service.explainObject("shadows", "s1", null, null, TASK, result); + + assertEquals(view.getValues().get("type"), "shadows"); + assertEquals(view.getValues().get("activation.effectiveStatus"), "DISABLED"); + assertEquals(view.getSource(), "repository"); + assertEquals(view.getFetched(), Boolean.FALSE); + assertEquals(modelHandler.getObjectOptionsCalls.size(), 1); + assertTrue(isNoFetchOptions(modelHandler.getObjectOptionsCalls.get(0))); + } + + @Test + public void explainShadowFetchTrueUsesLiveOptionsThenNoFetchForCompare() throws Exception { + PrismObject shadow = PrismContext.get().createObject(ShadowType.class); + shadow.asObjectable().setOid("s1"); + shadow.asObjectable().setName(poly("acc-a")); + modelHandler.getObjectResult = shadow; + + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + service.explainObject("shadows", "s1", null, true, TASK, result); + + assertEquals(modelHandler.getObjectOptionsCalls.size(), 2); + assertFalse(isNoFetchOptions(modelHandler.getObjectOptionsCalls.get(0))); + assertTrue(isNoFetchOptions(modelHandler.getObjectOptionsCalls.get(1))); + } + + @Test + public void explainFetchRejectedForNonShadow() { + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + try { + service.explainObject("users", "u1", null, true, TASK, result); + fail("expected MidpointMcpException"); + } catch (MidpointMcpException e) { + assertEquals(e.getStatus(), 400); + assertTrue(e.getMessage().contains("fetch")); + } + } + + @Test + public void searchShadowRepositoryUsesNoFetch() throws Exception { + PrismObject shadow = PrismContext.get().createObject(ShadowType.class); + shadow.asObjectable().setOid("s2"); + shadow.asObjectable().setName(poly("sh-b")); + modelHandler.searchResult = new SearchResultList<>(List.of(shadow)); + modelHandler.count = 1; + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + MidpointMcpSearchRequest shadowReq = search("shadows", null, 10, 0, null); + shadowReq.setResourceOid("res-shadow-test"); + shadowReq.setShadowKind("account"); + MidpointMcpSearchResult r = service.searchObjects(shadowReq, TASK, result); + assertEquals(r.getSource(), "repository"); + assertEquals(r.getFetched(), Boolean.FALSE); + assertEquals(r.getSearchMode(), "repository"); + assertTrue(isNoFetchOptions(modelHandler.lastSearchOptions)); + } + + @Test + public void searchResourceModeRequiresResourceOid() { + MidpointMcpSearchRequest req = search("shadows", null, 10, 0, null); + req.setSearchMode("resource"); + req.setShadowKind("account"); + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + try { + service.searchObjects(req, TASK, result); + fail("expected MidpointMcpException"); + } catch (MidpointMcpException e) { + assertEquals(e.getStatus(), 400); + assertTrue(e.getMessage().contains("resourceOid")); + } + } + + @Test + public void searchFetchRejectedForUsers() { + MidpointMcpSearchRequest req = search("users", null, 10, 0, null); + req.setFetch(true); + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + try { + service.searchObjects(req, TASK, result); + fail("expected MidpointMcpException"); + } catch (MidpointMcpException e) { + assertEquals(e.getStatus(), 400); + } + } + + @Test + public void searchShadowMqlResourceRefOnlyRejectedWithNotTypeScopedCode() { + MidpointMcpSearchRequest req = search("shadows", null, 10, 0, null); + req.setMql("resourceRef matches (oid = \"11111111-1111-1111-1111-111111111111\")"); + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + try { + service.searchObjects(req, TASK, result); + fail("expected MidpointMcpException"); + } catch (MidpointMcpException e) { + assertEquals(e.getStatus(), 400); + assertEquals(e.getCode(), "shadow_query_not_type_scoped"); + assertNotNull(e.getHint()); + } + } + + @Test + public void searchRepositoryResourceOidWithoutKindOrExpandRejected() { + MidpointMcpSearchRequest req = search("shadows", null, 10, 0, null); + req.setResourceOid("res-oid"); + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + try { + service.searchObjects(req, TASK, result); + fail("expected MidpointMcpException"); + } catch (MidpointMcpException e) { + assertEquals(e.getStatus(), 400); + assertEquals(e.getCode(), "shadow_repository_scope_incomplete"); + assertNotNull(e.getHint()); + } + } + + @Test + public void explainArchetypeSuccess() throws Exception { + PrismObject archetype = PrismContext.get().createObject(ArchetypeType.class); + archetype.asObjectable().setOid("a1"); + archetype.asObjectable().setName(poly("arch-a")); + modelHandler.getObjectResult = archetype; + + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + MidpointMcpObjectView view = service.explainObject("archetypes", "a1", null, null, TASK, result); + + assertEquals(ArchetypeType.class, modelHandler.lastGetObjectClass); + assertEquals(view.getValues().get("type"), "archetypes"); + assertTrue(view.getValues().get("summary").toString().contains("Archetype 'arch-a'")); + } + + @Test + public void searchArchetypesHappyPath() throws Exception { + PrismObject archetype = PrismContext.get().createObject(ArchetypeType.class); + archetype.asObjectable().setOid("a2"); + archetype.asObjectable().setName(poly("arch-b")); + modelHandler.searchResult = new SearchResultList<>(List.of(archetype)); + modelHandler.count = 1; + + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + MidpointMcpSearchResult searchResult = service.searchObjects(search("archetypes", null, 10, 0, null), TASK, result); + + assertEquals(searchResult.getType(), "archetypes"); + assertEquals(searchResult.getItems().getFirst().getValues().get("oid"), "a2"); + } + + @Test + public void explainConnectorAndSearchHappyPath() throws Exception { + PrismObject connector = PrismContext.get().createObject(ConnectorType.class); + connector.asObjectable().setOid("c1"); + connector.asObjectable().setName(poly("conn-a")); + connector.asObjectable().setConnectorType("com.example.Connector"); + modelHandler.getObjectResult = connector; + + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + MidpointMcpObjectView view = service.explainObject("connectors", "c1", null, null, TASK, result); + assertEquals(view.getValues().get("type"), "connectors"); + assertNull(view.getValues().get("activation.administrativeStatus")); + + modelHandler.searchResult = new SearchResultList<>(List.of(connector)); + modelHandler.count = 1; + MidpointMcpSearchResult search = service.searchObjects(search("connectors", null, 5, 0, null), TASK, result); + assertEquals(search.getType(), "connectors"); + assertNull(search.getItems().getFirst().getValues().get("activation.administrativeStatus")); + } + + @Test + public void explainServiceAndSearchHappyPath() throws Exception { + PrismObject svc = PrismContext.get().createObject(ServiceType.class); + svc.asObjectable().setOid("svc1"); + svc.asObjectable().setName(poly("svc-a")); + modelHandler.getObjectResult = svc; + + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + assertEquals(service.explainObject("services", "svc1", null, null, TASK, result).getValues().get("type"), "services"); + + modelHandler.searchResult = new SearchResultList<>(List.of(svc)); + modelHandler.count = 3; + assertEquals(service.searchObjects(search("services", null, 10, 0, null), TASK, result).getTotalCount(), 3); + } + + @Test + public void explainTaskAndNodeHappyPath() throws Exception { + PrismObject taskObj = PrismContext.get().createObject(TaskType.class); + taskObj.asObjectable().setOid("t1"); + taskObj.asObjectable().setName(poly("task-a")); + modelHandler.getObjectResult = taskObj; + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + MidpointMcpObjectView vTask = service.explainObject("tasks", "t1", null, null, TASK, result); + assertEquals(vTask.getValues().get("type"), "tasks"); + assertNull(vTask.getValues().get("activation.administrativeStatus")); + + PrismObject node = PrismContext.get().createObject(NodeType.class); + node.asObjectable().setOid("n1"); + node.asObjectable().setName(poly("node-a")); + modelHandler.getObjectResult = node; + MidpointMcpObjectView vNode = service.explainObject("nodes", "n1", null, null, TASK, result); + assertEquals(vNode.getValues().get("type"), "nodes"); + assertNull(vNode.getValues().get("activation.administrativeStatus")); + } + + @Test + public void searchResourcesAndShadowsHappyPath() throws Exception { + PrismObject resource = PrismContext.get().createObject(ResourceType.class); + resource.asObjectable().setOid("r2"); + resource.asObjectable().setName(poly("res-b")); + modelHandler.searchResult = new SearchResultList<>(List.of(resource)); + modelHandler.count = 1; + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + assertEquals(service.searchObjects(search("resources", null, 10, 0, null), TASK, result).getType(), "resources"); + + PrismObject shadow = PrismContext.get().createObject(ShadowType.class); + shadow.asObjectable().setOid("s2"); + shadow.asObjectable().setName(poly("sh-b")); + modelHandler.searchResult = new SearchResultList<>(List.of(shadow)); + modelHandler.count = 1; + MidpointMcpSearchRequest shadowReq = search("shadows", null, 10, 0, null); + shadowReq.setResourceOid("r2"); + shadowReq.setShadowKind("account"); + assertEquals(service.searchObjects(shadowReq, TASK, result).getType(), "shadows"); + } + + @Test + public void searchTasksAndNodesHappyPath() throws Exception { + PrismObject taskObj = PrismContext.get().createObject(TaskType.class); + taskObj.asObjectable().setOid("t2"); + taskObj.asObjectable().setName(poly("task-b")); + modelHandler.searchResult = new SearchResultList<>(List.of(taskObj)); + modelHandler.count = 5; + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + assertEquals(service.searchObjects(search("tasks", null, 10, 0, null), TASK, result).getTotalCount(), 5); + + PrismObject node = PrismContext.get().createObject(NodeType.class); + node.asObjectable().setOid("n2"); + node.asObjectable().setName(poly("node-b")); + modelHandler.searchResult = new SearchResultList<>(List.of(node)); + assertEquals(service.searchObjects(search("nodes", null, 10, 0, null), TASK, result).getType(), "nodes"); + } + + @Test + public void searchAuditRejectsQueryAndAdvancedTogether() { + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + MidpointMcpAuditSearchRequest req = new MidpointMcpAuditSearchRequest(); + req.setQuery("admin"); + req.setAdvancedQuery(new MidpointMcpAdvancedQuerySpec()); + try { + service.searchAudit(req, TASK, result); + fail("expected bad_request"); + } catch (MidpointMcpException e) { + assertEquals(400, e.getStatus()); + } + } + + @Test + public void searchAuditDefaultPagingAndCapturesQuery() { + auditHandler.searchList = new SearchResultList<>(); + auditHandler.count = 0; + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + MidpointMcpAuditSearchRequest req = new MidpointMcpAuditSearchRequest(); + service.searchAudit(req, TASK, result); + assertNotNull(auditHandler.lastSearchQuery); + ObjectPaging p = auditHandler.lastSearchQuery.getPaging(); + assertNotNull(p); + assertEquals(Integer.valueOf(100), p.getMaxSize()); + assertEquals(0, p.getOffset()); + assertNotNull(auditHandler.lastCountQuery); + } + + @Test + public void searchAuditSimpleUuidQueryDoesNotUseTargetNamePaths() { + auditHandler.searchList = new SearchResultList<>(); + auditHandler.count = 0; + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + MidpointMcpAuditSearchRequest req = new MidpointMcpAuditSearchRequest(); + req.setQuery("40cb9035-8990-4516-9e6c-1c290da7f6d8"); + + service.searchAudit(req, TASK, result); + + assertNotNull(auditHandler.lastSearchQuery); + String q = String.valueOf(auditHandler.lastSearchQuery.getFilter()); + assertFalse(q.contains("targetName"), "simple UUID query should not traverse initiator/target targetName"); + assertTrue(q.contains("targetRef"), "simple UUID query should still allow target OID matching"); + } + + @Test + public void explainAuditRecordRequiresId() { + OperationResult result = new OperationResult(MidpointMcpServiceImplTest.class.getName()); + try { + service.explainAuditRecord(new MidpointMcpAuditExplainRequest(), TASK, result); + fail("expected bad_request"); + } catch (MidpointMcpException e) { + assertEquals(400, e.getStatus()); + } + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private static boolean isNoFetchOptions(Collection options) { + if (options == null) { + return false; + } + return GetOperationOptions.isNoFetch((Collection>) (Collection) options); + } + + private static MidpointMcpSearchRequest search( + String type, String query, Integer limit, Integer offset, List returnAttributes) { + MidpointMcpSearchRequest r = new MidpointMcpSearchRequest(); + r.setType(type); + r.setQuery(query); + r.setLimit(limit); + r.setOffset(offset); + r.setReturnAttributes(returnAttributes); + return r; + } + + private static PolyStringType poly(String orig) { + PolyStringType p = new PolyStringType(); + p.setOrig(orig); + return p; + } + + private static void inject(Object target, String fieldName, Object value) { + try { + Field f = MidpointMcpServiceImpl.class.getDeclaredField(fieldName); + f.setAccessible(true); + f.set(target, value); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + + private static OrgStructFunctions newNoOpOrgStructFunctions() { + return (OrgStructFunctions) Proxy.newProxyInstance( + OrgStructFunctions.class.getClassLoader(), + new Class[] { OrgStructFunctions.class }, + (proxy, method, args) -> { + Class rt = method.getReturnType(); + if (rt == boolean.class) { + return false; + } + if (Collection.class.isAssignableFrom(rt)) { + return List.of(); + } + return null; + }); + } + + private static SecurityEnforcer newAllowAllSecurityEnforcer() { + return newCapturingAllowAllSecurityEnforcer(new AtomicReference<>()); + } + + /** + * Records the REST/model operation URL from the 7-arg {@code decideAccess} call (used by {@code authorize}). + */ + private static SecurityEnforcer newCapturingAllowAllSecurityEnforcer(AtomicReference lastOperationUrl) { + return (SecurityEnforcer) Proxy.newProxyInstance( + SecurityEnforcer.class.getClassLoader(), + new Class[] { SecurityEnforcer.class }, + (proxy, method, args) -> { + if (method.isDefault()) { + return InvocationHandler.invokeDefault(proxy, method, args); + } + return switch (method.getName()) { + case "decideAccess" -> { + if (method.getParameterCount() == 7) { + if (args[1] instanceof String url) { + lastOperationUrl.set(url); + } + yield AccessDecision.ALLOW; + } + throw new UnsupportedOperationException(method.toString()); + } + case "getMidPointPrincipal" -> null; + case "failAuthorization" -> throw new SecurityViolationException("Not authorized"); + default -> throw new UnsupportedOperationException(method.toString()); + }; + }); + } + + private static final class CapturingAuditHandler implements InvocationHandler { + + ObjectQuery lastSearchQuery; + ObjectQuery lastCountQuery; + SearchResultList searchList = new SearchResultList<>(); + int count; + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + return switch (method.getName()) { + case "supportsRetrieval" -> true; + case "searchObjects" -> { + lastSearchQuery = (ObjectQuery) args[0]; + yield searchList != null ? searchList : new SearchResultList<>(); + } + case "countObjects" -> { + lastCountQuery = (ObjectQuery) args[0]; + yield count; + } + default -> throw new UnsupportedOperationException(method.toString()); + }; + } + } + + private static final class CapturingModelHandler implements InvocationHandler { + + private Throwable getObjectError; + private PrismObject getObjectResult; + private SearchResultList> searchResult = SearchResultList.empty(); + private int count; + private ObjectQuery lastSearchQuery; + private Class lastGetObjectClass; + private Collection lastSearchOptions; + private Collection lastCountOptions; + private final List> getObjectOptionsCalls = new ArrayList<>(); + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + return switch (method.getName()) { + case "getObject" -> { + if (args != null && args.length > 0 && args[0] instanceof Class c) { + lastGetObjectClass = c; + } + if (args != null && args.length > 2 && args[2] instanceof Collection opts) { + getObjectOptionsCalls.add(opts); + } + if (getObjectError != null) { + throw getObjectError; + } + yield getObjectResult; + } + case "searchObjects" -> { + lastSearchQuery = (ObjectQuery) args[1]; + lastSearchOptions = args.length > 2 && args[2] instanceof Collection c ? c : null; + yield searchResult; + } + case "countObjects" -> { + lastCountOptions = args.length > 2 && args[2] instanceof Collection c ? c : null; + yield count; + } + default -> throw new UnsupportedOperationException(method.toString()); + }; + } + } +} diff --git a/model/mcp-impl/testng-unit.xml b/model/mcp-impl/testng-unit.xml new file mode 100644 index 00000000000..e7d4f202a27 --- /dev/null +++ b/model/mcp-impl/testng-unit.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/model/pom.xml b/model/pom.xml index dbbdf664957..451ddc31047 100644 --- a/model/pom.xml +++ b/model/pom.xml @@ -42,6 +42,8 @@ smart-api smart-impl + mcp-api + mcp-impl rest-impl diff --git a/model/rest-impl/pom.xml b/model/rest-impl/pom.xml index 8375b2efd0a..fe82200bc8c 100644 --- a/model/rest-impl/pom.xml +++ b/model/rest-impl/pom.xml @@ -63,6 +63,28 @@ authentication-api ${project.version} + + com.evolveum.midpoint.model + mcp-api + ${project.version} + + + + io.modelcontextprotocol.sdk + mcp-core + + + io.modelcontextprotocol.sdk + mcp-json-jackson2 + + + com.fasterxml.jackson.core + jackson-databind + + + org.springframework.boot + spring-boot + org.springframework diff --git a/model/rest-impl/src/main/java/com/evolveum/midpoint/rest/impl/AbstractRestController.java b/model/rest-impl/src/main/java/com/evolveum/midpoint/rest/impl/AbstractRestController.java index 648a3c2c156..18f8596d64b 100644 --- a/model/rest-impl/src/main/java/com/evolveum/midpoint/rest/impl/AbstractRestController.java +++ b/model/rest-impl/src/main/java/com/evolveum/midpoint/rest/impl/AbstractRestController.java @@ -218,6 +218,17 @@ protected void finishRequest(Task task, OperationResult result) { } } + /** + * Completes an operation that reuses the same HTTP authentication as the surrounding request (for example an MCP + * tool call). Unlike {@link #finishRequest(Task, OperationResult)}, this does not record a REST {@code TERMINATE_SESSION} + * audit or clear {@link SecurityContextHolder}; the servlet/filter chain owns the security context lifecycle. + */ + protected void finishAuxiliaryRestOperation(Task task, OperationResult result) { + if (result != null) { + result.computeStatusIfUnknown(); + } + } + private void auditLogout(Task task, OperationResult result) { if (isAuditingSkipped(result)) { return; diff --git a/model/rest-impl/src/main/java/com/evolveum/midpoint/rest/impl/MidpointMcpRestConfiguration.java b/model/rest-impl/src/main/java/com/evolveum/midpoint/rest/impl/MidpointMcpRestConfiguration.java new file mode 100644 index 00000000000..eda387629c7 --- /dev/null +++ b/model/rest-impl/src/main/java/com/evolveum/midpoint/rest/impl/MidpointMcpRestConfiguration.java @@ -0,0 +1,762 @@ +/* + * Copyright (C) 2010-2026 Evolveum and contributors + * + * Licensed under the EUPL-1.2 or later. + */ + +package com.evolveum.midpoint.rest.impl; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; + +import com.evolveum.midpoint.mcp.api.McpPublicErrorMessages; +import com.evolveum.midpoint.mcp.api.MidpointMcpAdvancedQuerySpec; +import com.evolveum.midpoint.mcp.api.MidpointMcpAuditExplainRequest; +import com.evolveum.midpoint.mcp.api.MidpointMcpAuditSearchRequest; +import com.evolveum.midpoint.mcp.api.MidpointMcpException; +import com.evolveum.midpoint.mcp.api.MidpointMcpObjectView; +import com.evolveum.midpoint.mcp.api.MidpointMcpSearchRequest; +import com.evolveum.midpoint.mcp.api.MidpointMcpSearchResult; +import com.evolveum.midpoint.mcp.api.MidpointMcpService; +import com.evolveum.midpoint.mcp.api.MidpointMcpTypeSchemaView; +import com.evolveum.midpoint.schema.result.OperationResult; +import com.evolveum.midpoint.task.api.Task; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; +import io.modelcontextprotocol.server.McpTransportContextExtractor; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.bind.annotation.RequestMapping; + +import jakarta.servlet.http.HttpServlet; + +/** + * Registers a read-only MCP server over HTTP (streamable transport) at {@code /ws/mcp}. + * Cursor and other modern MCP HTTP clients POST JSON-RPC to this URL; the legacy SSE split + * ({@code /sse} + {@code /message}) does not match that pattern and would return 404 for {@code POST /ws/mcp}. + */ +@Configuration(proxyBeanMethods = false) +@RequestMapping("/ws/mcp") +public class MidpointMcpRestConfiguration extends AbstractRestController { + + /** + * Key under which we store the Spring Security authentication in the MCP transport context. + *

+ * The MCP streamable transport may resume tool handling on different threads than the servlet thread, + * and Spring Security's ThreadLocal context can be lost there. Restoring it avoids NPEs in authorization. + */ + private static final String SECURITY_AUTH_CONTEXT_KEY = "midpoint_security_auth"; + + private static final String SCHEMA_EXPLAIN_OBJECT = """ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "users", "roles", "orgs", "archetypes", "connectors", + "resources", "services", "shadows", "tasks", "nodes", "cases" + ], + "description": "REST collection / object kind (same role as objectType in REST docs). For cases, response includes workflow fields (work items, decision history, child cases) when returnAttributes is omitted." + }, + "oid": { "type": "string", "description": "midPoint object OID" }, + "returnAttributes": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional dot paths (same names as midpoint_describe_object_type_schema). Omit for curated explain defaults per type (richer than search). To request every schema path, pass returnAttributes as a one-element array where the element is the single-character string asterisk (very large response)." + }, + "fetch": { + "type": "boolean", + "description": "Shadows only; for other types, true is rejected (400). VERY EXPENSIVE when true: performs a live connector read. Use only when you must verify current resource state; omitted or false uses the repository shadow (default, fast). Typical case—resolving a user linkRef to a shadow—should use fetch=false or omit fetch. May fail if the resource is unavailable. Not part of MQL." + } + }, + "required": [ "type", "oid" ] + }"""; + + private static final String SCHEMA_SEARCH_OBJECTS = """ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "users", "roles", "orgs", "archetypes", "connectors", + "resources", "services", "shadows", "tasks", "nodes", "cases" + ], + "description": "REST collection / object kind" + }, + "query": { + "type": "string", + "description": "Optional simple search. For most types: name prefix. For cases: substring match on case name, description, or work item names (OR). Use only one of query, advancedQuery, or mql." + }, + "advancedQuery": { + "type": "object", + "description": "Structured filters translated server-side to MQL (preferred advanced mode). Paths are dot notation from midpoint_describe_object_type_schema (shadow resource reference: path resourceRef, op eq, value = target OID). Mutually exclusive with query and mql.", + "properties": { + "combine": { + "type": "string", + "enum": [ "and", "or" ], + "description": "How to combine filters; default and" + }, + "filters": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { "type": "string" }, + "op": { + "type": "string", + "enum": [ + "eq", "neq", "startsWith", "contains", + "gt", "gte", "lt", "lte", "exists", "in" + ] + }, + "value": { + "description": "Scalar or array (for in). Omit for exists." + } + }, + "required": [ "path", "op" ] + } + }, + "orderBy": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { "type": "string" }, + "direction": { "type": "string", "enum": [ "asc", "desc" ] } + }, + "required": [ "path" ] + } + }, + "paging": { + "type": "object", + "properties": { + "offset": { "type": "integer" }, + "limit": { "type": "integer" } + } + } + } + }, + "mql": { + "type": "string", + "description": "Raw midPoint Query Language filter (expert). Mutually exclusive with query and advancedQuery." + }, + "limit": { "type": "integer", "description": "Page size (default 20, max 100). Overridden by advancedQuery.paging.limit when set." }, + "offset": { "type": "integer", "description": "Overridden by advancedQuery.paging.offset when set." }, + "returnAttributes": { + "type": "array", + "items": { "type": "string" }, + "description": "Same path vocabulary as midpoint_explain_object; when omitted, search uses lighter defaults than explain (no extension.*, no heavy containers). [*] still expands to full schema (huge)." + }, + "fetch": { + "type": "boolean", + "description": "Shadows only with searchMode repository (default). VERY EXPENSIVE when true: one live connector read per search hit (omit or false for repository snapshot only). Use only when explicitly needed; for linkRef-style lookups prefer fetch=false. Ignored when searchMode is resource (already live). Not part of MQL." + }, + "searchMode": { + "type": "string", + "enum": [ "repository", "resource" ], + "description": "Shadows only: repository (default) searches stored shadows; resource searches the connector-backed resource. Not part of MQL." + }, + "resourceOid": { + "type": "string", + "description": "Shadows only. With searchMode resource: required resource OID. With searchMode repository: optional; combine with shadowKind or objectClass to scope the query, or use expandResourceObjectTypes=true (requires resourceOid) to OR every schemaHandling object type on that resource." + }, + "shadowKind": { + "type": "string", + "description": "Shadows only. ShadowKindType e.g. account, entitlement. With searchMode resource: required unless objectClass is set. With searchMode repository: optional top-level scope merged (AND) into the query together with resourceOid. Mutually exclusive with objectClass." + }, + "shadowIntent": { + "type": "string", + "description": "Shadows only, optional with shadowKind (not with objectClass alone). Refined intent for repository or resource scope." + }, + "objectClass": { + "type": "string", + "description": "Shadows only. Object class QName as {namespaceURI}localPart or namespaceURI#localPart. Same repository/resource scope rules as shadowKind. Mutually exclusive with shadowKind." + }, + "expandResourceObjectTypes": { + "type": "boolean", + "description": "Shadows only, searchMode repository: when true with resourceOid, runs one repository search per schemaHandling object type (kind/intent) on that resource, merges and deduplicates by OID, sorts by name, then applies offset/limit. ANDs each branch with query/mql/advancedQuery. Loads up to 500 hits per type before paging (capped). totalCount is the sum of per-branch counts. Mutually exclusive with shadowKind, shadowIntent, and objectClass. Not supported for searchMode resource." + } + }, + "required": [ "type" ] + }"""; + + private static final String SCHEMA_DESCRIBE_OBJECT_TYPE_SCHEMA = """ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "users", "roles", "orgs", "archetypes", "connectors", + "resources", "services", "shadows", "tasks", "nodes", "cases" + ], + "description": "REST collection / object kind" + }, + "maxDepth": { + "type": "integer", + "minimum": 0, + "description": "Recommended for agents: omit this field (server default 2). Max dot-segment depth; use 0 only for full flatten (large JSON)." + } + }, + "required": [ "type" ] + }"""; + + private static final String SCHEMA_SEARCH_AUDIT = """ + { + "type": "object", + "properties": { + "from": { + "type": "string", + "description": "Inclusive lower time bound (ISO-8601). If omitted with to, defaults to 24h before to; if both omitted, defaults to 24h ago (UTC)." + }, + "to": { + "type": "string", + "description": "Inclusive upper time bound (ISO-8601). If omitted with from, defaults to now; if both omitted, defaults to now (UTC)." + }, + "query": { + "type": "string", + "description": "Simple text search (OR across message, task identifier, UUID as OID). Mutually exclusive with advancedQuery." + }, + "advancedQuery": { + "type": "object", + "description": "Structured filters on MCP audit paths (not object schema). Mutually exclusive with query. Paths: timestamp, eventType, eventStage, outcome, initiator.oid|name, target.oid|name|type, channel, task.oid|name, node, message, delta (exists only), result, sessionIdentifier, requestIdentifier, customColumn.. Default order: timestamp desc. Default limit 100, max 500.", + "properties": { + "combine": { "type": "string", "enum": [ "and", "or" ], "description": "Default and" }, + "filters": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { "type": "string" }, + "op": { + "type": "string", + "enum": [ + "eq", "neq", "startsWith", "contains", + "gt", "gte", "lt", "lte", "exists", "in" + ] + }, + "value": { "description": "Scalar or array (for in). Omit for exists." } + }, + "required": [ "path", "op" ] + } + }, + "orderBy": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { "type": "string" }, + "direction": { "type": "string", "enum": [ "asc", "desc" ] } + }, + "required": [ "path" ] + } + }, + "paging": { + "type": "object", + "properties": { + "offset": { "type": "integer" }, + "limit": { "type": "integer" } + } + } + } + } + } + }"""; + + private static final String SCHEMA_EXPLAIN_AUDIT = """ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Audit eventIdentifier (preferred), or numeric repository repoId as decimal string" + }, + "includeDelta": { + "type": "boolean", + "description": "Include normalized delta summary (default false; can be large)" + }, + "includeResult": { + "type": "boolean", + "description": "Include stored result text (default false; may be truncated)" + } + }, + "required": [ "id" ] + }"""; + + @Autowired private MidpointMcpService midpointMcpService; + + @Bean + public JacksonMcpJsonMapper midpointMcpJsonMapper() { + return new JacksonMcpJsonMapper(new ObjectMapper()); + } + + /** + * Path suffix used by the MCP Java streamable servlet to match {@code requestURI} (must match the servlet mount + * so e.g. {@code /midpoint/ws/mcp} ends with this value). + */ + @Bean + public HttpServletStreamableServerTransportProvider midpointMcpTransportProvider( + JacksonMcpJsonMapper midpointMcpJsonMapper, + @Value("${midpoint.mcp.endpoint:/ws/mcp}") String mcpEndpoint) { + + // Capture authentication on the servlet thread, so we can restore it later during async tool execution. + McpTransportContextExtractor extractor = (req) -> { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + return McpTransportContext.create(Map.of(SECURITY_AUTH_CONTEXT_KEY, authentication)); + }; + + return HttpServletStreamableServerTransportProvider.builder() + .jsonMapper(midpointMcpJsonMapper) + .mcpEndpoint(mcpEndpoint) + .contextExtractor(extractor) + .build(); + } + + @Bean + public McpSyncServer midpointMcpSyncServer( + HttpServletStreamableServerTransportProvider transport, + JacksonMcpJsonMapper jsonMapper) { + + return McpServer.sync(transport) + .serverInfo("midPoint MCP", "1.0") + .instructions("Read-only midPoint MCP: HTTP entry at /ws/mcp requires the same authorization-rest-3#all as " + + "other /ws/** REST endpoints (granular REST rights alone are not enough to reach MCP). " + + "Each tool then enforces its own actions (e.g. get/search object, audit read, model read by type for schema). " + + "search/explain return a values map keyed by dot paths (same vocabulary as midpoint_describe_object_type_schema). " + + "Optional returnAttributes selects fields; omit for curated defaults per REST type (search uses lighter defaults than explain); " + + "[\"*\"] expands to full schema (huge). " + + "Types: users, roles, orgs, archetypes, connectors, resources, services, shadows, tasks, nodes, cases. " + + "Schema tool needs authorization-model-3#read for the requested object type; omit maxDepth (default 2) or use 0 for unlimited. " + + "Shadows: static ShadowType only in schema tool. " + + "Shadow fetch=true is VERY EXPENSIVE (live connector); use only when explicitly required. " + + "Resolving a user linkRef to a shadow should use repository data (omit fetch or fetch=false). " + + "Audit: midpoint_search_audit and midpoint_explain_audit_record use a dedicated audit path vocabulary " + + "(not midpoint_describe_object_type_schema); require audit read authorization; time window always applied.") + .jsonMapper(jsonMapper) + .tools( + toolExplainObject(jsonMapper), + toolSearchObjects(jsonMapper), + toolDescribeObjectTypeSchema(jsonMapper), + toolSearchAudit(jsonMapper), + toolExplainAuditRecord(jsonMapper)) + .build(); + } + + @Bean + public ServletRegistrationBean midpointMcpServlet( + HttpServletStreamableServerTransportProvider transport) { + ServletRegistrationBean registration = + new ServletRegistrationBean<>(transport, "/ws/mcp", "/ws/mcp/*"); + registration.setName("midpointMcpServlet"); + registration.setAsyncSupported(true); + return registration; + } + + private McpServerFeatures.SyncToolSpecification toolExplainObject(JacksonMcpJsonMapper jsonMapper) { + String description = "Explain one object: optional returnAttributes (dot paths); default is a curated field set. " + + "Cases: default includes workflow summary (work items, decision history, child case list, explanation text). " + + "Shadows: fetch=true is VERY EXPENSIVE (live connector read); use only when you must see current resource state. " + + "For typical linkRef resolution from a user to a shadow, omit fetch or use fetch=false (repository shadow). " + + "Response includes source and fetched. fetch is not MQL."; + McpSchema.Tool tool = McpSchema.Tool.builder() + .name("midpoint_explain_object") + .title(description) + .description(description) + .inputSchema(jsonMapper, SCHEMA_EXPLAIN_OBJECT) + .build(); + + BiFunction handler = + (exchange, request) -> withRestoredSecurityContext(exchange, () -> handleExplainObject(jsonMapper, request)); + + return McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler(handler) + .build(); + } + + private McpServerFeatures.SyncToolSpecification toolSearchObjects(JacksonMcpJsonMapper jsonMapper) { + String description = "Search by REST type: simple name prefix (query), structured advancedQuery (MQL translation), or raw mql; paging; response includes usedQueryMode and translatedMql for advanced/mql. " + + "Shadows: midPoint requires each shadow search to be scoped by resource plus kind or objectClass (or expandResourceObjectTypes on repository). Resource-only filters are rejected with code shadow_query_not_type_scoped and a hint. " + + "Repository (default): optional resourceOid + shadowKind/objectClass merged into the filter; or resourceOid + expandResourceObjectTypes to search all defined object types. " + + "Resource searchMode: live connector search (resourceOid + shadowKind or objectClass). " + + "With repository searchMode, fetch=true is VERY EXPENSIVE (one live read per hit); use only when explicitly needed—omit fetch or use fetch=false for linkRef-style shadow lookups. " + + "Advanced shadow filters: path resourceRef with op eq and target OID (not resourceRef.oid). fetch, searchMode, resourceOid, shadowKind, shadowIntent, objectClass, expandResourceObjectTypes are request parameters, not MQL."; + McpSchema.Tool tool = McpSchema.Tool.builder() + .name("midpoint_search_objects") + .title(description) + .description(description) + .inputSchema(jsonMapper, SCHEMA_SEARCH_OBJECTS) + .build(); + + BiFunction handler = + (exchange, request) -> withRestoredSecurityContext(exchange, () -> handleSearchObjects(jsonMapper, request)); + + return McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler(handler) + .build(); + } + + private McpServerFeatures.SyncToolSpecification toolDescribeObjectTypeSchema(JacksonMcpJsonMapper jsonMapper) { + String description = "Return flattened attribute paths for a REST collection type (schema registry, includes " + + "extension definitions). Recommended: omit maxDepth (default 2 for agents); maxDepth 0 = unlimited. " + + "Requires authorization-model-3#read for the requested object type. Does not read the repository. For shadows, only static ShadowType " + + "(no connector-specific attributes). Large maxDepth can produce large JSON."; + McpSchema.Tool tool = McpSchema.Tool.builder() + .name("midpoint_describe_object_type_schema") + .title(description) + .description(description) + .inputSchema(jsonMapper, SCHEMA_DESCRIBE_OBJECT_TYPE_SCHEMA) + .build(); + + BiFunction handler = + (exchange, request) -> withRestoredSecurityContext(exchange, () -> handleDescribeObjectTypeSchema(jsonMapper, request)); + + return McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler(handler) + .build(); + } + + private McpServerFeatures.SyncToolSpecification toolSearchAudit(JacksonMcpJsonMapper jsonMapper) { + String description = "Search audit records: optional ISO-8601 from/to (default last 24h UTC), simple query OR structured " + + "advancedQuery on MCP audit paths (timestamp, eventType, eventStage, outcome, initiator.*, target.*, channel, " + + "task.oid|task.name, node, message, customColumn., …). Default paging limit 100, max 500; default sort " + + "timestamp desc. Response includes effectiveWindow, totalCount, translatedQuery (advanced). Requires audit read."; + McpSchema.Tool tool = McpSchema.Tool.builder() + .name("midpoint_search_audit") + .title(description) + .description(description) + .inputSchema(jsonMapper, SCHEMA_SEARCH_AUDIT) + .build(); + BiFunction handler = + (exchange, request) -> withRestoredSecurityContext(exchange, () -> handleSearchAudit(jsonMapper, request)); + return McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler(handler) + .build(); + } + + private McpServerFeatures.SyncToolSpecification toolExplainAuditRecord(JacksonMcpJsonMapper jsonMapper) { + String description = "Load one audit record by eventIdentifier (or numeric repo id) and return normalized fields plus " + + "explanation; optional includeDelta/includeResult for bounded extra detail. When includeDelta is true, each " + + "operation includes attributeChanges: [{path, modificationType, oldValue, newValue}] (values redacted for " + + "password/credentials paths; per-delta cap with attributeChangesTruncated). Requires audit read."; + McpSchema.Tool tool = McpSchema.Tool.builder() + .name("midpoint_explain_audit_record") + .title(description) + .description(description) + .inputSchema(jsonMapper, SCHEMA_EXPLAIN_AUDIT) + .build(); + BiFunction handler = + (exchange, request) -> withRestoredSecurityContext(exchange, () -> handleExplainAuditRecord(jsonMapper, request)); + return McpServerFeatures.SyncToolSpecification.builder() + .tool(tool) + .callHandler(handler) + .build(); + } + + private McpSchema.CallToolResult handleExplainObject(JacksonMcpJsonMapper jsonMapper, McpSchema.CallToolRequest request) { + Task task = initRequest(); + OperationResult result = createSubresult(task, "mcpTool"); + try { + Map args = request.arguments(); + String objectType = stringArg(args, "type"); + String oid = stringArg(args, "oid"); + if (StringUtils.isBlank(objectType)) { + return toolError(jsonMapper, 400, "type is required"); + } + if (StringUtils.isBlank(oid)) { + return toolError(jsonMapper, 400, "oid is required"); + } + List returnAttributes = stringListArg(args, "returnAttributes"); + MidpointMcpObjectView view = + midpointMcpService.explainObject( + objectType, oid, returnAttributes, booleanArg(args, "fetch"), task, result); + return toolSuccess(jsonMapper, view); + } catch (MidpointMcpException e) { + return toolError(jsonMapper, e); + } catch (Exception e) { + logger.error("MCP explain tool failed: {}", e.toString(), e); + return toolError(jsonMapper, 500, McpPublicErrorMessages.UNEXPECTED_TOOL_FAILURE); + } finally { + finishAuxiliaryRestOperation(task, result); + } + } + + private McpSchema.CallToolResult withRestoredSecurityContext( + io.modelcontextprotocol.server.McpSyncServerExchange exchange, + java.util.function.Supplier action) { + + Authentication previous = SecurityContextHolder.getContext().getAuthentication(); + Object captured = exchange.transportContext().get(SECURITY_AUTH_CONTEXT_KEY); + Authentication capturedAuth = captured instanceof Authentication a ? a : null; + + if (capturedAuth != null) { + SecurityContextHolder.getContext().setAuthentication(capturedAuth); + } + try { + return action.get(); + } finally { + SecurityContextHolder.getContext().setAuthentication(previous); + } + } + + private McpSchema.CallToolResult handleSearchObjects(JacksonMcpJsonMapper jsonMapper, McpSchema.CallToolRequest request) { + Task task = initRequest(); + OperationResult result = createSubresult(task, "mcpTool"); + try { + Map args = request.arguments(); + String objectType = stringArg(args, "type"); + if (StringUtils.isBlank(objectType)) { + return toolError(jsonMapper, 400, "type is required"); + } + MidpointMcpSearchRequest searchRequest = new MidpointMcpSearchRequest(); + searchRequest.setType(objectType); + searchRequest.setQuery(stringArg(args, "query")); + searchRequest.setMql(stringArg(args, "mql")); + searchRequest.setLimit(integerArg(args, "limit")); + searchRequest.setOffset(integerArg(args, "offset")); + searchRequest.setReturnAttributes(stringListArg(args, "returnAttributes")); + searchRequest.setFetch(booleanArg(args, "fetch")); + searchRequest.setSearchMode(stringArg(args, "searchMode")); + searchRequest.setResourceOid(stringArg(args, "resourceOid")); + searchRequest.setShadowKind(stringArg(args, "shadowKind")); + searchRequest.setShadowIntent(stringArg(args, "shadowIntent")); + searchRequest.setObjectClass(stringArg(args, "objectClass")); + searchRequest.setExpandResourceObjectTypes(booleanArg(args, "expandResourceObjectTypes")); + + Object advancedRaw = args != null ? args.get("advancedQuery") : null; + if (advancedRaw != null) { + if (!(advancedRaw instanceof Map)) { + return toolError(jsonMapper, 400, "advancedQuery must be a JSON object"); + } + @SuppressWarnings("unchecked") + Map advancedMap = (Map) advancedRaw; + try { + searchRequest.setAdvancedQuery( + jsonMapper.getObjectMapper().convertValue(advancedMap, MidpointMcpAdvancedQuerySpec.class)); + } catch (IllegalArgumentException e) { + return toolError(jsonMapper, 400, "advancedQuery is invalid: " + e.getMessage()); + } + } + + MidpointMcpSearchResult searchResult = midpointMcpService.searchObjects(searchRequest, task, result); + return toolSuccess(jsonMapper, searchResult); + } catch (MidpointMcpException e) { + return toolError(jsonMapper, e); + } catch (Exception e) { + logger.error("MCP search tool failed: {}", e.toString(), e); + return toolError(jsonMapper, 500, McpPublicErrorMessages.UNEXPECTED_TOOL_FAILURE); + } finally { + finishAuxiliaryRestOperation(task, result); + } + } + + private McpSchema.CallToolResult handleDescribeObjectTypeSchema(JacksonMcpJsonMapper jsonMapper, McpSchema.CallToolRequest request) { + Task task = initRequest(); + OperationResult result = createSubresult(task, "mcpTool"); + try { + Map args = request.arguments(); + String objectType = stringArg(args, "type"); + if (StringUtils.isBlank(objectType)) { + return toolError(jsonMapper, 400, "type is required"); + } + Integer maxDepth = integerArg(args, "maxDepth"); + MidpointMcpTypeSchemaView view = + midpointMcpService.describeObjectTypeSchema(objectType, maxDepth, task, result); + return toolSuccess(jsonMapper, view); + } catch (MidpointMcpException e) { + return toolError(jsonMapper, e); + } catch (Exception e) { + logger.error("MCP describe object type schema tool failed: {}", e.toString(), e); + return toolError(jsonMapper, 500, McpPublicErrorMessages.UNEXPECTED_TOOL_FAILURE); + } finally { + finishAuxiliaryRestOperation(task, result); + } + } + + private McpSchema.CallToolResult handleSearchAudit(JacksonMcpJsonMapper jsonMapper, McpSchema.CallToolRequest request) { + Task task = initRequest(); + OperationResult result = createSubresult(task, "mcpTool"); + try { + Map args = request.arguments(); + MidpointMcpAuditSearchRequest searchRequest = new MidpointMcpAuditSearchRequest(); + searchRequest.setFrom(stringArg(args, "from")); + searchRequest.setTo(stringArg(args, "to")); + searchRequest.setQuery(stringArg(args, "query")); + Object advancedRaw = args != null ? args.get("advancedQuery") : null; + if (advancedRaw != null) { + if (!(advancedRaw instanceof Map)) { + return toolError(jsonMapper, 400, "advancedQuery must be a JSON object"); + } + @SuppressWarnings("unchecked") + Map advancedMap = (Map) advancedRaw; + try { + searchRequest.setAdvancedQuery( + jsonMapper.getObjectMapper().convertValue(advancedMap, MidpointMcpAdvancedQuerySpec.class)); + } catch (IllegalArgumentException e) { + return toolError(jsonMapper, 400, "advancedQuery is invalid: " + e.getMessage()); + } + } + var searchResult = midpointMcpService.searchAudit(searchRequest, task, result); + return toolSuccess(jsonMapper, searchResult); + } catch (MidpointMcpException e) { + return toolError(jsonMapper, e); + } catch (Exception e) { + logger.error("MCP audit search tool failed: {}", e.toString(), e); + return toolError(jsonMapper, 500, McpPublicErrorMessages.UNEXPECTED_TOOL_FAILURE); + } finally { + finishAuxiliaryRestOperation(task, result); + } + } + + private McpSchema.CallToolResult handleExplainAuditRecord(JacksonMcpJsonMapper jsonMapper, McpSchema.CallToolRequest request) { + Task task = initRequest(); + OperationResult result = createSubresult(task, "mcpTool"); + try { + Map args = request.arguments(); + MidpointMcpAuditExplainRequest explainRequest = new MidpointMcpAuditExplainRequest(); + explainRequest.setId(stringArg(args, "id")); + explainRequest.setIncludeDelta(booleanArg(args, "includeDelta")); + explainRequest.setIncludeResult(booleanArg(args, "includeResult")); + var explainResult = midpointMcpService.explainAuditRecord(explainRequest, task, result); + return toolSuccess(jsonMapper, explainResult); + } catch (MidpointMcpException e) { + return toolError(jsonMapper, e); + } catch (Exception e) { + logger.error("MCP audit explain tool failed: {}", e.toString(), e); + return toolError(jsonMapper, 500, McpPublicErrorMessages.UNEXPECTED_TOOL_FAILURE); + } finally { + finishAuxiliaryRestOperation(task, result); + } + } + + private static McpSchema.CallToolResult toolSuccess(JacksonMcpJsonMapper jsonMapper, Object value) { + try { + String text = jsonMapper.getObjectMapper().writeValueAsString(value); + return McpSchema.CallToolResult.builder() + .addTextContent(text) + .isError(false) + .build(); + } catch (IOException e) { + return McpSchema.CallToolResult.builder() + .addTextContent(McpPublicErrorMessages.SERIALIZATION_FAILED) + .isError(true) + .build(); + } + } + + private static McpSchema.CallToolResult toolError(JacksonMcpJsonMapper jsonMapper, MidpointMcpException e) { + return toolError(jsonMapper, e.getStatus(), e.getMessage(), e.getCode(), e.getHint()); + } + + private static McpSchema.CallToolResult toolError(JacksonMcpJsonMapper jsonMapper, int status, String message) { + return toolError(jsonMapper, status, message, null, null); + } + + private static McpSchema.CallToolResult toolError( + JacksonMcpJsonMapper jsonMapper, int status, String message, String code, String hint) { + try { + Map body = new LinkedHashMap<>(); + body.put("status", status); + body.put("message", message != null ? message : ""); + if (StringUtils.isNotBlank(code)) { + body.put("code", code.trim()); + } + if (StringUtils.isNotBlank(hint)) { + body.put("hint", hint.trim()); + } + String text = jsonMapper.getObjectMapper().writeValueAsString(body); + return McpSchema.CallToolResult.builder() + .addTextContent(text) + .isError(true) + .build(); + } catch (IOException ex) { + return McpSchema.CallToolResult.builder() + .addTextContent(message) + .isError(true) + .build(); + } + } + + private static String stringArg(Map args, String key) { + if (args == null) { + return null; + } + Object v = args.get(key); + return v != null ? String.valueOf(v) : null; + } + + private static List stringListArg(Map args, String key) { + if (args == null) { + return null; + } + Object v = args.get(key); + if (v == null) { + return null; + } + if (v instanceof List list) { + List out = new ArrayList<>(list.size()); + for (Object o : list) { + if (o != null) { + out.add(String.valueOf(o)); + } + } + return out.isEmpty() ? null : out; + } + return null; + } + + private static Boolean booleanArg(Map args, String key) { + if (args == null) { + return null; + } + Object v = args.get(key); + if (v == null) { + return null; + } + if (v instanceof Boolean b) { + return b; + } + return Boolean.parseBoolean(String.valueOf(v)); + } + + private static Integer integerArg(Map args, String key) { + if (args == null) { + return null; + } + Object v = args.get(key); + if (v == null) { + return null; + } + if (v instanceof Number n) { + return n.intValue(); + } + try { + return Integer.parseInt(String.valueOf(v)); + } catch (NumberFormatException e) { + return null; + } + } + +} diff --git a/pom.xml b/pom.xml index fe6ba06ddff..a040a774d11 100644 --- a/pom.xml +++ b/pom.xml @@ -281,6 +281,8 @@ 20260102.1 1.8.0 1.7.1 + + 1.1.1 @@ -1675,6 +1677,17 @@ totp ${java-otp.version} + + + io.modelcontextprotocol.sdk + mcp-core + ${mcp.sdk.version} + + + io.modelcontextprotocol.sdk + mcp-json-jackson2 + ${mcp.sdk.version} +