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();