From 1d0ea6ba6fbdfe068b50314acf04ee70d1ef07ed Mon Sep 17 00:00:00 2001 From: Oleg Zimakov Date: Fri, 26 Jun 2026 18:45:40 -0700 Subject: [PATCH] [SYNCOPE-1978] Search audit events by username Restore the ability to search audit events by the username of the entity whose payload was logged, complementing the existing entityKey (UUID) match. A new repeatable "username" query parameter on AuditQuery matches the token "username":"" embedded in the serialized audit payload (before, inputs, output, throwable), with exact match and multiple values OR'ed, composing with the existing audit search filters. This is useful to retrieve a user's audit trail by login name, in particular after the user has been deleted and its key is no longer available. The filter is threaded through AuditServiceImpl, AuditLogic and the AuditEventDAO interface and all of its implementations (JPA, Neo4j, Elasticsearch, OpenSearch). The username value is bound as a query parameter (SQL/Cypher) or passed as a structured phrase query (Elasticsearch/OpenSearch), so there is no injection surface; the JPA LIKE predicate escapes metacharacters so only exact matches are returned. Covered by integration tests in AuditITCase, including the deleted-user case. --- .../common/rest/api/beans/AuditQuery.java | 20 +++++ .../apache/syncope/core/logic/AuditLogic.java | 6 +- .../rest/cxf/service/AuditServiceImpl.java | 1 + .../persistence/api/dao/AuditEventDAO.java | 2 + .../persistence/jpa/dao/JPAAuditEventDAO.java | 29 +++++++ .../neo4j/dao/Neo4jAuditEventDAO.java | 14 ++++ .../dao/ElasticsearchAuditEventDAO.java | 18 ++++- .../dao/OpenSearchAuditEventDAO.java | 18 ++++- .../apache/syncope/fit/core/AuditITCase.java | 81 +++++++++++++++++++ 9 files changed, 183 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 9d82c912a0..631057691e 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 @@ -43,6 +43,11 @@ public Builder entityKey(final String entityKey) { return this; } + public Builder username(final String username) { + getInstance().getUsername().add(username); + return this; + } + public Builder who(final String who) { getInstance().getWho().add(who); return this; @@ -76,6 +81,8 @@ public Builder outcome(final OpEvent.Outcome outcome) { private String entityKey; + private Set username = new HashSet<>(); + private Set who = new HashSet<>(); private OpEvent.CategoryType type; @@ -99,6 +106,19 @@ public void setEntityKey(final String entityKey) { this.entityKey = entityKey; } + @Parameter(name = "username", description = "username(s) embedded in the audited payload to match " + + "(the affected entity, as opposed to the 'who' author); " + + "may be repeated to match any of the given values", array = + @ArraySchema(schema = @Schema(implementation = String.class))) + public Set getUsername() { + return username; + } + + @QueryParam("username") + public void setUsername(final Set username) { + this.username = username; + } + @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))) 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 291c5291e6..cff41724a4 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 username, final Set who, final OpEvent.CategoryType type, final String category, @@ -285,10 +286,11 @@ public Page search( final OffsetDateTime after, final Pageable pageable) { - long count = auditEventDAO.count(entityKey, who, type, category, subcategory, op, result, before, after); + long count = auditEventDAO.count( + entityKey, username, who, type, category, subcategory, op, result, before, after); List matching = auditEventDAO.search( - entityKey, who, type, category, subcategory, op, result, before, after, pageable); + entityKey, username, 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 468830cf5e..0580f0953a 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.getUsername(), auditQuery.getWho(), auditQuery.getType(), auditQuery.getCategory(), 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 a2f9e2a107..5e83015b95 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 @@ -32,6 +32,7 @@ public interface AuditEventDAO { long count( String entityKey, + Set username, Set who, OpEvent.CategoryType type, String category, @@ -56,6 +57,7 @@ default AuditEventTO toAuditEventTO(final AuditEvent auditEvent) { List search( String entityKey, + Set username, Set who, OpEvent.CategoryType type, String category, 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 df621d77cd..2f77c4c8b2 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 @@ -63,6 +63,31 @@ protected AuditEventCriteriaBuilder entityKey(final String entityKey) { return this; } + // Unlike the entityKey predicate above (a constrained UUID, concatenated), the free-form username + // is bound as a query parameter and its LIKE metacharacters are escaped, so '%'/'_' in a username + // match literally and there is no injection surface. + // '#' is used as the LIKE escape char (instead of '\') and applied uniformly: a single '\' in + // native SQL is mishandled by MySQL/MariaDB and Oracle has no default LIKE escape, whereas '#' + // works across every supported database (this DAO is not subclassed per-database). + protected static String escapeForLike(final String value) { + return value.replace("#", "##").replace("%", "#%").replace("_", "#_"); + } + + public AuditEventCriteriaBuilder username(final Set username, final List parameters) { + if (!CollectionUtils.isEmpty(username)) { + query.append(andIfNeeded()).append("("). + append(username.stream().map(value -> { + String pattern = "%\"username\":\"" + escapeForLike(value) + "\"%"; + return "(beforeValue LIKE ?" + setParameter(parameters, pattern) + " ESCAPE '#'" + + " OR inputs LIKE ?" + setParameter(parameters, pattern) + " ESCAPE '#'" + + " OR output LIKE ?" + setParameter(parameters, pattern) + " ESCAPE '#'" + + " OR throwable LIKE ?" + setParameter(parameters, pattern) + " ESCAPE '#')"; + }).collect(Collectors.joining(" OR "))). + append(")"); + } + return this; + } + public AuditEventCriteriaBuilder who(final Set who, final List parameters) { if (!CollectionUtils.isEmpty(who)) { query.append(andIfNeeded()).append("who IN ("). @@ -140,6 +165,7 @@ protected void fillWithParameters(final Query query, final List paramete @Override public long count( final String entityKey, + final Set username, final Set who, final OpEvent.CategoryType type, final String category, @@ -153,6 +179,7 @@ public long count( String queryString = "SELECT COUNT(0)" + " FROM " + JPAAuditEvent.TABLE + " WHERE" + criteriaBuilder(entityKey). + username(username, parameters). who(who, parameters). opEvent(type, category, subcategory, op, outcome). before(before, parameters). @@ -168,6 +195,7 @@ public long count( @Override public List search( final String entityKey, + final Set username, final Set who, final OpEvent.CategoryType type, final String category, @@ -182,6 +210,7 @@ public List search( String queryString = "SELECT id" + " FROM " + JPAAuditEvent.TABLE + " WHERE" + criteriaBuilder(entityKey). + username(username, parameters). who(who, parameters). opEvent(type, category, subcategory, op, outcome). before(before, 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 2c59bdbf0a..b976c3a162 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 @@ -60,6 +60,16 @@ protected AuditEventCriteriaBuilder entityKey(final String entityKey) { return this; } + public AuditEventCriteriaBuilder username(final Set username, final Map parameters) { + if (!CollectionUtils.isEmpty(username)) { + parameters.put("usernames", username.stream().map(value -> "\"username\":\"" + value + "\"").toList()); + query.append(andIfNeeded()). + append("ANY(u IN $usernames WHERE n.before CONTAINS u OR n.inputs CONTAINS u " + + "OR n.output CONTAINS u OR n.throwable CONTAINS u)"); + } + return this; + } + public AuditEventCriteriaBuilder who(final Set who, final Map parameters) { if (!CollectionUtils.isEmpty(who)) { parameters.put("who", List.copyOf(who)); @@ -135,6 +145,7 @@ protected AuditEventCriteriaBuilder criteriaBuilder(final String entityKey) { @Override public long count( final String entityKey, + final Set username, final Set who, final OpEvent.CategoryType type, final String category, @@ -147,6 +158,7 @@ public long count( Map parameters = new HashMap<>(); String query = "MATCH (n:" + Neo4jAuditEvent.NODE + ") " + " WHERE " + criteriaBuilder(entityKey). + username(username, parameters). who(who, parameters). opEvent(type, category, subcategory, op, outcome). before(before, parameters). @@ -160,6 +172,7 @@ public long count( @Override public List search( final String entityKey, + final Set username, final Set who, final OpEvent.CategoryType type, final String category, @@ -174,6 +187,7 @@ public List search( StringBuilder query = new StringBuilder("MATCH (n:" + Neo4jAuditEvent.NODE + ") " + "WHERE " + criteriaBuilder(entityKey). + username(username, parameters). who(who, parameters). opEvent(type, category, subcategory, op, outcome). before(before, 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 2798489ee5..de1c2307d7 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 @@ -82,6 +82,7 @@ public AuditEvent save(final AuditEvent auditEvent) { protected Query getQuery( final String entityKey, + final Set username, final Set who, final OpEvent.CategoryType type, final String category, @@ -101,6 +102,17 @@ protected Query getQuery( query("\"key\":\"" + entityKey + "\"").build()).build()); } + if (!CollectionUtils.isEmpty(username)) { + List usernameQueries = username.stream().map(value -> new Query.Builder(). + multiMatch(QueryBuilders.multiMatch(). + fields("before", "inputs", "output", "throwable"). + type(TextQueryType.Phrase). + query("\"username\":\"" + value + "\"").build()).build()). + toList(); + queries.add(new Query.Builder(). + bool(QueryBuilders.bool().should(usernameQueries).minimumShouldMatch("1").build()).build()); + } + if (!CollectionUtils.isEmpty(who)) { List whoQueries = who.stream().map(value -> new Query.Builder(). term(QueryBuilders.term().field("who").value(value).build()).build()). @@ -137,6 +149,7 @@ protected Query getQuery( @Override public long count( final String entityKey, + final Set username, final Set who, final OpEvent.CategoryType type, final String category, @@ -148,7 +161,7 @@ public long count( CountRequest request = new CountRequest.Builder(). index(ElasticsearchUtils.getAuditIndex(AuthContextUtils.getDomain())). - query(getQuery(entityKey, who, type, category, subcategory, op, outcome, before, after)). + query(getQuery(entityKey, username, who, type, category, subcategory, op, outcome, before, after)). build(); LOG.debug("Count request: {}", request); @@ -172,6 +185,7 @@ protected List sortBuilders(final Stream orderBy) { @Override public List search( final String entityKey, + final Set username, final Set who, final OpEvent.CategoryType type, final String category, @@ -185,7 +199,7 @@ public List search( SearchRequest request = new SearchRequest.Builder(). index(ElasticsearchUtils.getAuditIndex(AuthContextUtils.getDomain())). searchType(SearchType.QueryThenFetch). - query(getQuery(entityKey, who, type, category, subcategory, op, outcome, before, after)). + query(getQuery(entityKey, username, 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 02bf3be892..eb5d63feaf 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 @@ -81,6 +81,7 @@ public AuditEvent save(final AuditEvent auditEvent) { protected Query getQuery( final String entityKey, + final Set username, final Set who, final OpEvent.CategoryType type, final String category, @@ -100,6 +101,17 @@ protected Query getQuery( query("\"key\":\"" + entityKey + "\"").build()).build()); } + if (!CollectionUtils.isEmpty(username)) { + List usernameQueries = username.stream().map(value -> new Query.Builder(). + multiMatch(QueryBuilders.multiMatch(). + fields("before", "inputs", "output", "throwable"). + type(TextQueryType.Phrase). + query("\"username\":\"" + value + "\"").build()).build()). + toList(); + queries.add(new Query.Builder(). + bool(QueryBuilders.bool().should(usernameQueries).minimumShouldMatch("1").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()). @@ -136,6 +148,7 @@ protected Query getQuery( @Override public long count( final String entityKey, + final Set username, final Set who, final OpEvent.CategoryType type, final String category, @@ -147,7 +160,7 @@ public long count( CountRequest request = new CountRequest.Builder(). index(OpenSearchUtils.getAuditIndex(AuthContextUtils.getDomain())). - query(getQuery(entityKey, who, type, category, subcategory, op, outcome, before, after)). + query(getQuery(entityKey, username, who, type, category, subcategory, op, outcome, before, after)). build(); LOG.debug("Count request: {}", request); @@ -171,6 +184,7 @@ protected List sortBuilders(final Stream orderBy) { @Override public List search( final String entityKey, + final Set username, final Set who, final OpEvent.CategoryType type, final String category, @@ -184,7 +198,7 @@ public List search( SearchRequest request = new SearchRequest.Builder(). index(OpenSearchUtils.getAuditIndex(AuthContextUtils.getDomain())). searchType(SearchType.QueryThenFetch). - query(getQuery(entityKey, who, type, category, subcategory, op, outcome, before, after)). + query(getQuery(entityKey, username, 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 7d94db44a6..f4d207669a 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 @@ -233,6 +233,87 @@ public void findByWhoIsExactMatch() { USER_SERVICE.delete(userTO.getKey()); } + @Test + public void findByUsername() { + UserTO userTO = createUser(UserITCase.getUniqueSample("audit-username@syncope.org")).getEntity(); + assertNotNull(userTO.getKey()); + + // exact match on the username whose payload was logged for the create event + AuditQuery exact = new AuditQuery.Builder(). + username(userTO.getUsername()). + type(OpEvent.CategoryType.LOGIC). + category(UserLogic.class.getSimpleName()). + op("create"). + outcome(OpEvent.Outcome.SUCCESS). + build(); + List exactResult = query(exact, MAX_WAIT_SECONDS); + assertFalse(exactResult.isEmpty()); + // the matched event genuinely carries this username in its payload (not matched by chance) + assertTrue(exactResult.stream().anyMatch(event -> (event.getOutput() != null + && event.getOutput().contains("\"username\":\"" + userTO.getUsername() + "\"")) + || (event.getBefore() != null + && event.getBefore().contains("\"username\":\"" + userTO.getUsername() + "\"")))); + + // multiple username values are OR'ed + AuditQuery multiple = new AuditQuery.Builder(). + username(userTO.getUsername()). + username("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()); + + // a non-matching username excludes the event + AuditQuery noMatch = new AuditQuery.Builder(). + username("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()); + + // the match is exact: a strict prefix of the username does not match + AuditQuery prefix = new AuditQuery.Builder(). + username(userTO.getUsername().substring(0, userTO.getUsername().length() - 1)). + type(OpEvent.CategoryType.LOGIC). + category(UserLogic.class.getSimpleName()). + op("create"). + outcome(OpEvent.Outcome.SUCCESS). + build(); + assertTrue(AUDIT_SERVICE.search(prefix).getResult().isEmpty()); + + USER_SERVICE.delete(userTO.getKey()); + } + + @Test + public void findDeletedUserByUsername() { + UserTO userTO = createUser(UserITCase.getUniqueSample("audit-username-deleted@syncope.org")).getEntity(); + String username = userTO.getUsername(); + assertNotNull(userTO.getKey()); + + AuditQuery deleteQuery = new AuditQuery.Builder(). + username(username). + type(OpEvent.CategoryType.LOGIC). + category(UserLogic.class.getSimpleName()). + op("delete"). + outcome(OpEvent.Outcome.SUCCESS). + build(); + try { + AUDIT_SERVICE.setConf(buildAuditConf("[LOGIC]:[UserLogic]:[]:[delete]:[SUCCESS]", true)); + + USER_SERVICE.delete(userTO.getKey()); + + // the user is gone (its key can no longer be resolved), but the delete event's "before" + // snapshot still carries the username, so it remains searchable by username + assertFalse(query(deleteQuery, MAX_WAIT_SECONDS).isEmpty()); + } finally { + AUDIT_SERVICE.setConf(buildAuditConf("[LOGIC]:[UserLogic]:[]:[delete]:[SUCCESS]", false)); + } + } + @Test public void findByGroup() { GroupTO groupTO = createGroup(GroupITCase.getBasicSample("AuditGroup")).getEntity();