From afca4f78bdb8a2611eb5d9ba0e29c5cdf1996064 Mon Sep 17 00:00:00 2001 From: Oleg Zimakov Date: Wed, 24 Jun 2026 17:17:51 -0700 Subject: [PATCH] [SYNCOPE-1981] Search audit events by who Add the ability to search audit events by the principal that performed them. A new "who" query parameter on AuditQuery matches the AuditEvent.who column by exact value and may be repeated to match any of the given values (OR), composing with the existing audit search filters. The filter is threaded through AuditServiceImpl, AuditLogic and the AuditEventDAO interface and all of its implementations (JPA, Neo4j, Elasticsearch, OpenSearch). Values are bound as query parameters (SQL/Cypher) or passed as structured term queries (Elasticsearch/OpenSearch), so there is no injection surface. Covered by new integration tests in AuditITCase. --- .../common/rest/api/beans/AuditQuery.java | 22 ++++++ .../apache/syncope/core/logic/AuditLogic.java | 5 +- .../rest/cxf/service/AuditServiceImpl.java | 1 + .../persistence/api/dao/AuditEventDAO.java | 3 + .../persistence/jpa/dao/JPAAuditEventDAO.java | 17 +++++ .../neo4j/dao/Neo4jAuditEventDAO.java | 14 ++++ .../dao/ElasticsearchAuditEventDAO.java | 16 ++++- .../dao/OpenSearchAuditEventDAO.java | 16 ++++- .../apache/syncope/fit/core/AuditITCase.java | 72 +++++++++++++++++++ 9 files changed, 160 insertions(+), 6 deletions(-) diff --git a/common/idrepo/rest-api/src/main/java/org/apache/syncope/common/rest/api/beans/AuditQuery.java b/common/idrepo/rest-api/src/main/java/org/apache/syncope/common/rest/api/beans/AuditQuery.java index cddcf3b6165..9d82c912a0d 100644 --- a/common/idrepo/rest-api/src/main/java/org/apache/syncope/common/rest/api/beans/AuditQuery.java +++ b/common/idrepo/rest-api/src/main/java/org/apache/syncope/common/rest/api/beans/AuditQuery.java @@ -19,8 +19,11 @@ package org.apache.syncope.common.rest.api.beans; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.ws.rs.QueryParam; +import java.util.HashSet; +import java.util.Set; import org.apache.syncope.common.lib.types.OpEvent; import org.apache.syncope.common.rest.api.service.JAXRSService; @@ -40,6 +43,11 @@ public Builder entityKey(final String entityKey) { return this; } + public Builder who(final String who) { + getInstance().getWho().add(who); + return this; + } + public Builder type(final OpEvent.CategoryType type) { getInstance().setType(type); return this; @@ -68,6 +76,8 @@ public Builder outcome(final OpEvent.Outcome outcome) { private String entityKey; + private Set who = new HashSet<>(); + private OpEvent.CategoryType type; private String category; @@ -89,6 +99,18 @@ public void setEntityKey(final String entityKey) { this.entityKey = entityKey; } + @Parameter(name = "who", description = "audit event author(s) (username) to match; " + + "may be repeated to match any of the given values", array = + @ArraySchema(schema = @Schema(implementation = String.class))) + public Set getWho() { + return who; + } + + @QueryParam("who") + public void setWho(final Set who) { + this.who = who; + } + @Parameter(name = "type", description = "audit type to match", schema = @Schema(implementation = OpEvent.CategoryType.class)) public OpEvent.CategoryType getType() { diff --git a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/AuditLogic.java b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/AuditLogic.java index 89c9718cb77..291c5291e6a 100644 --- a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/AuditLogic.java +++ b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/AuditLogic.java @@ -275,6 +275,7 @@ public List events() { @Transactional(readOnly = true) public Page search( final String entityKey, + final Set who, final OpEvent.CategoryType type, final String category, final String subcategory, @@ -284,10 +285,10 @@ public Page search( final OffsetDateTime after, final Pageable pageable) { - long count = auditEventDAO.count(entityKey, type, category, subcategory, op, result, before, after); + long count = auditEventDAO.count(entityKey, who, type, category, subcategory, op, result, before, after); List matching = auditEventDAO.search( - entityKey, type, category, subcategory, op, result, before, after, pageable); + entityKey, who, type, category, subcategory, op, result, before, after, pageable); return new SyncopePage<>(matching, pageable, count); } diff --git a/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/service/AuditServiceImpl.java b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/service/AuditServiceImpl.java index 4d4578d4624..468830cf5e7 100644 --- a/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/service/AuditServiceImpl.java +++ b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/service/AuditServiceImpl.java @@ -70,6 +70,7 @@ public List events() { public PagedResult search(final AuditQuery auditQuery) { Page result = logic.search( auditQuery.getEntityKey(), + auditQuery.getWho(), auditQuery.getType(), auditQuery.getCategory(), auditQuery.getSubcategory(), diff --git a/core/persistence-api/src/main/java/org/apache/syncope/core/persistence/api/dao/AuditEventDAO.java b/core/persistence-api/src/main/java/org/apache/syncope/core/persistence/api/dao/AuditEventDAO.java index 6c5822ae0ca..a2f9e2a1077 100644 --- a/core/persistence-api/src/main/java/org/apache/syncope/core/persistence/api/dao/AuditEventDAO.java +++ b/core/persistence-api/src/main/java/org/apache/syncope/core/persistence/api/dao/AuditEventDAO.java @@ -20,6 +20,7 @@ import java.time.OffsetDateTime; import java.util.List; +import java.util.Set; import org.apache.syncope.common.lib.to.AuditEventTO; import org.apache.syncope.common.lib.types.OpEvent; import org.apache.syncope.core.persistence.api.entity.AuditEvent; @@ -31,6 +32,7 @@ public interface AuditEventDAO { long count( String entityKey, + Set who, OpEvent.CategoryType type, String category, String subcategory, @@ -54,6 +56,7 @@ default AuditEventTO toAuditEventTO(final AuditEvent auditEvent) { List search( String entityKey, + Set who, OpEvent.CategoryType type, String category, String subcategory, diff --git a/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/dao/JPAAuditEventDAO.java b/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/dao/JPAAuditEventDAO.java index 8226c7e033e..5fec4882ae7 100644 --- a/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/dao/JPAAuditEventDAO.java +++ b/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/dao/JPAAuditEventDAO.java @@ -24,6 +24,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; import org.apache.syncope.common.lib.to.AuditEventTO; import org.apache.syncope.common.lib.types.OpEvent; @@ -32,6 +33,7 @@ import org.apache.syncope.core.persistence.jpa.entity.JPAAuditEvent; import org.springframework.data.domain.Pageable; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; public class JPAAuditEventDAO implements AuditEventDAO { @@ -59,6 +61,17 @@ protected AuditEventCriteriaBuilder entityKey(final String entityKey) { return this; } + public AuditEventCriteriaBuilder who(final Set who, final List parameters) { + if (!CollectionUtils.isEmpty(who)) { + query.append(andIfNeeded()).append("who IN ("). + append(who.stream(). + map(value -> "?" + setParameter(parameters, value)). + collect(Collectors.joining(", "))). + append(")"); + } + return this; + } + public AuditEventCriteriaBuilder opEvent( final OpEvent.CategoryType type, final String category, @@ -125,6 +138,7 @@ protected void fillWithParameters(final Query query, final List paramete @Override public long count( final String entityKey, + final Set who, final OpEvent.CategoryType type, final String category, final String subcategory, @@ -137,6 +151,7 @@ public long count( String queryString = "SELECT COUNT(0)" + " FROM " + JPAAuditEvent.TABLE + " WHERE" + criteriaBuilder(entityKey). + who(who, parameters). opEvent(type, category, subcategory, op, outcome). before(before, parameters). after(after, parameters). @@ -151,6 +166,7 @@ public long count( @Override public List search( final String entityKey, + final Set who, final OpEvent.CategoryType type, final String category, final String subcategory, @@ -164,6 +180,7 @@ public List search( String queryString = "SELECT id" + " FROM " + JPAAuditEvent.TABLE + " WHERE" + criteriaBuilder(entityKey). + who(who, parameters). opEvent(type, category, subcategory, op, outcome). before(before, parameters). after(after, parameters). diff --git a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/dao/Neo4jAuditEventDAO.java b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/dao/Neo4jAuditEventDAO.java index 8c8d9f2b4fc..fa8bce46786 100644 --- a/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/dao/Neo4jAuditEventDAO.java +++ b/core/persistence-neo4j/src/main/java/org/apache/syncope/core/persistence/neo4j/dao/Neo4jAuditEventDAO.java @@ -23,6 +23,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; import org.apache.syncope.common.lib.to.AuditEventTO; import org.apache.syncope.common.lib.types.OpEvent; @@ -36,6 +37,7 @@ import org.springframework.data.neo4j.core.Neo4jClient; import org.springframework.data.neo4j.core.Neo4jTemplate; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; public class Neo4jAuditEventDAO implements AuditEventDAO { @@ -60,6 +62,14 @@ protected AuditEventCriteriaBuilder entityKey(final String entityKey) { return this; } + public AuditEventCriteriaBuilder who(final Set who, final Map parameters) { + if (!CollectionUtils.isEmpty(who)) { + parameters.put("who", List.copyOf(who)); + query.append(andIfNeeded()).append("n.who IN $who"); + } + return this; + } + public AuditEventCriteriaBuilder opEvent( final OpEvent.CategoryType type, final String category, @@ -127,6 +137,7 @@ protected AuditEventCriteriaBuilder criteriaBuilder(final String entityKey) { @Override public long count( final String entityKey, + final Set who, final OpEvent.CategoryType type, final String category, final String subcategory, @@ -138,6 +149,7 @@ public long count( Map parameters = new HashMap<>(); String query = "MATCH (n:" + Neo4jAuditEvent.NODE + ") " + " WHERE " + criteriaBuilder(entityKey). + who(who, parameters). opEvent(type, category, subcategory, op, outcome). before(before, parameters). after(after, parameters). @@ -150,6 +162,7 @@ public long count( @Override public List search( final String entityKey, + final Set who, final OpEvent.CategoryType type, final String category, final String subcategory, @@ -163,6 +176,7 @@ public List search( StringBuilder query = new StringBuilder("MATCH (n:" + Neo4jAuditEvent.NODE + ") " + "WHERE " + criteriaBuilder(entityKey). + who(who, parameters). opEvent(type, category, subcategory, op, outcome). before(before, parameters). after(after, parameters). diff --git a/ext/elasticsearch/persistence/src/main/java/org/apache/syncope/core/persistence/elasticsearch/dao/ElasticsearchAuditEventDAO.java b/ext/elasticsearch/persistence/src/main/java/org/apache/syncope/core/persistence/elasticsearch/dao/ElasticsearchAuditEventDAO.java index 4fe92c1fe70..2798489ee57 100644 --- a/ext/elasticsearch/persistence/src/main/java/org/apache/syncope/core/persistence/elasticsearch/dao/ElasticsearchAuditEventDAO.java +++ b/ext/elasticsearch/persistence/src/main/java/org/apache/syncope/core/persistence/elasticsearch/dao/ElasticsearchAuditEventDAO.java @@ -35,6 +35,7 @@ import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.stream.Stream; import org.apache.syncope.common.lib.to.AuditEventTO; import org.apache.syncope.common.lib.types.OpEvent; @@ -81,6 +82,7 @@ public AuditEvent save(final AuditEvent auditEvent) { protected Query getQuery( final String entityKey, + final Set who, final OpEvent.CategoryType type, final String category, final String subcategory, @@ -99,6 +101,14 @@ protected Query getQuery( query("\"key\":\"" + entityKey + "\"").build()).build()); } + if (!CollectionUtils.isEmpty(who)) { + List whoQueries = who.stream().map(value -> new Query.Builder(). + term(QueryBuilders.term().field("who").value(value).build()).build()). + toList(); + queries.add(new Query.Builder(). + bool(QueryBuilders.bool().should(whoQueries).minimumShouldMatch("1").build()).build()); + } + queries.add(new Query.Builder().regexp(QueryBuilders.regexp(). field("opEvent"). value(OpEvent.toString(type, category, subcategory, op, outcome). @@ -127,6 +137,7 @@ protected Query getQuery( @Override public long count( final String entityKey, + final Set who, final OpEvent.CategoryType type, final String category, final String subcategory, @@ -137,7 +148,7 @@ public long count( CountRequest request = new CountRequest.Builder(). index(ElasticsearchUtils.getAuditIndex(AuthContextUtils.getDomain())). - query(getQuery(entityKey, type, category, subcategory, op, outcome, before, after)). + query(getQuery(entityKey, who, type, category, subcategory, op, outcome, before, after)). build(); LOG.debug("Count request: {}", request); @@ -161,6 +172,7 @@ protected List sortBuilders(final Stream orderBy) { @Override public List search( final String entityKey, + final Set who, final OpEvent.CategoryType type, final String category, final String subcategory, @@ -173,7 +185,7 @@ public List search( SearchRequest request = new SearchRequest.Builder(). index(ElasticsearchUtils.getAuditIndex(AuthContextUtils.getDomain())). searchType(SearchType.QueryThenFetch). - query(getQuery(entityKey, type, category, subcategory, op, outcome, before, after)). + query(getQuery(entityKey, who, type, category, subcategory, op, outcome, before, after)). from(pageable.isUnpaged() ? 0 : pageable.getPageSize() * pageable.getPageNumber()). size(pageable.isUnpaged() ? indexMaxResultWindow : pageable.getPageSize()). sort(sortBuilders(pageable.getSort().get())). diff --git a/ext/opensearch/persistence/src/main/java/org/apache/syncope/core/persistence/opensearch/dao/OpenSearchAuditEventDAO.java b/ext/opensearch/persistence/src/main/java/org/apache/syncope/core/persistence/opensearch/dao/OpenSearchAuditEventDAO.java index 71833f4ce31..02bf3be892f 100644 --- a/ext/opensearch/persistence/src/main/java/org/apache/syncope/core/persistence/opensearch/dao/OpenSearchAuditEventDAO.java +++ b/ext/opensearch/persistence/src/main/java/org/apache/syncope/core/persistence/opensearch/dao/OpenSearchAuditEventDAO.java @@ -22,6 +22,7 @@ import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.stream.Stream; import org.apache.syncope.common.lib.to.AuditEventTO; import org.apache.syncope.common.lib.types.OpEvent; @@ -80,6 +81,7 @@ public AuditEvent save(final AuditEvent auditEvent) { protected Query getQuery( final String entityKey, + final Set who, final OpEvent.CategoryType type, final String category, final String subcategory, @@ -98,6 +100,14 @@ protected Query getQuery( query("\"key\":\"" + entityKey + "\"").build()).build()); } + if (!CollectionUtils.isEmpty(who)) { + List whoQueries = who.stream().map(value -> new Query.Builder(). + term(QueryBuilders.term().field("who").value(v -> v.stringValue(value)).build()).build()). + toList(); + queries.add(new Query.Builder(). + bool(QueryBuilders.bool().should(whoQueries).minimumShouldMatch("1").build()).build()); + } + queries.add(new Query.Builder().regexp(QueryBuilders.regexp(). field("opEvent"). value(OpEvent.toString(type, category, subcategory, op, outcome). @@ -126,6 +136,7 @@ protected Query getQuery( @Override public long count( final String entityKey, + final Set who, final OpEvent.CategoryType type, final String category, final String subcategory, @@ -136,7 +147,7 @@ public long count( CountRequest request = new CountRequest.Builder(). index(OpenSearchUtils.getAuditIndex(AuthContextUtils.getDomain())). - query(getQuery(entityKey, type, category, subcategory, op, outcome, before, after)). + query(getQuery(entityKey, who, type, category, subcategory, op, outcome, before, after)). build(); LOG.debug("Count request: {}", request); @@ -160,6 +171,7 @@ protected List sortBuilders(final Stream orderBy) { @Override public List search( final String entityKey, + final Set who, final OpEvent.CategoryType type, final String category, final String subcategory, @@ -172,7 +184,7 @@ public List search( SearchRequest request = new SearchRequest.Builder(). index(OpenSearchUtils.getAuditIndex(AuthContextUtils.getDomain())). searchType(SearchType.QueryThenFetch). - query(getQuery(entityKey, type, category, subcategory, op, outcome, before, after)). + query(getQuery(entityKey, who, type, category, subcategory, op, outcome, before, after)). from(pageable.isUnpaged() ? 0 : pageable.getPageSize() * pageable.getPageNumber()). size(pageable.isUnpaged() ? indexMaxResultWindow : pageable.getPageSize()). sort(sortBuilders(pageable.getSort().get())). diff --git a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/AuditITCase.java b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/AuditITCase.java index bbaedc394d3..7d94db44a6e 100644 --- a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/AuditITCase.java +++ b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/AuditITCase.java @@ -161,6 +161,78 @@ public void findByUserAndOther() { USER_SERVICE.delete(userTO.getKey()); } + @Test + public void findByWho() { + UserTO userTO = createUser(UserITCase.getUniqueSample("audit-who@syncope.org")).getEntity(); + assertNotNull(userTO.getKey()); + + // exact match on the author of the just-created user's create event + AuditQuery exact = new AuditQuery.Builder(). + entityKey(userTO.getKey()). + who(ADMIN_UNAME). + type(OpEvent.CategoryType.LOGIC). + category(UserLogic.class.getSimpleName()). + op("create"). + outcome(OpEvent.Outcome.SUCCESS). + build(); + assertFalse(query(exact, MAX_WAIT_SECONDS).isEmpty()); + + // multiple who values are OR'ed: admin still matches alongside an unrelated author + AuditQuery multiple = new AuditQuery.Builder(). + entityKey(userTO.getKey()). + who(ADMIN_UNAME). + who("non-existent-" + UUID.randomUUID()). + type(OpEvent.CategoryType.LOGIC). + category(UserLogic.class.getSimpleName()). + op("create"). + outcome(OpEvent.Outcome.SUCCESS). + build(); + assertFalse(AUDIT_SERVICE.search(multiple).getResult().isEmpty()); + + // the who filter excludes non-matching authors + AuditQuery noMatch = new AuditQuery.Builder(). + entityKey(userTO.getKey()). + who("non-existent-" + UUID.randomUUID()). + type(OpEvent.CategoryType.LOGIC). + category(UserLogic.class.getSimpleName()). + op("create"). + outcome(OpEvent.Outcome.SUCCESS). + build(); + assertTrue(AUDIT_SERVICE.search(noMatch).getResult().isEmpty()); + + USER_SERVICE.delete(userTO.getKey()); + } + + @Test + public void findByWhoIsExactMatch() { + UserTO userTO = createUser(UserITCase.getUniqueSample("audit-who-exact@syncope.org")).getEntity(); + assertNotNull(userTO.getKey()); + + // make sure the create event is there (matched exactly by the admin author) + AuditQuery exact = new AuditQuery.Builder(). + entityKey(userTO.getKey()). + who(ADMIN_UNAME). + type(OpEvent.CategoryType.LOGIC). + category(UserLogic.class.getSimpleName()). + op("create"). + outcome(OpEvent.Outcome.SUCCESS). + build(); + assertFalse(query(exact, MAX_WAIT_SECONDS).isEmpty()); + + // who matches exactly: a '*' is treated literally, not as a wildcard + AuditQuery wildcard = new AuditQuery.Builder(). + entityKey(userTO.getKey()). + who(ADMIN_UNAME.substring(0, 3) + '*'). + type(OpEvent.CategoryType.LOGIC). + category(UserLogic.class.getSimpleName()). + op("create"). + outcome(OpEvent.Outcome.SUCCESS). + build(); + assertTrue(AUDIT_SERVICE.search(wildcard).getResult().isEmpty()); + + USER_SERVICE.delete(userTO.getKey()); + } + @Test public void findByGroup() { GroupTO groupTO = createGroup(GroupITCase.getBasicSample("AuditGroup")).getEntity();