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