diff --git a/xtraplatform-auth/src/main/java/de/ii/xtraplatform/auth/app/JwtTokenHandler.java b/xtraplatform-auth/src/main/java/de/ii/xtraplatform/auth/app/JwtTokenHandler.java index 2005b38f..74276ea8 100644 --- a/xtraplatform-auth/src/main/java/de/ii/xtraplatform/auth/app/JwtTokenHandler.java +++ b/xtraplatform-auth/src/main/java/de/ii/xtraplatform/auth/app/JwtTokenHandler.java @@ -295,6 +295,7 @@ public Optional parseToken(String token) { .permissions(readList(claimsJws, claimsProvider.getClaims().getPermissions())) .apiPermissions( readListPerApi(claimsJws, claimsProvider.getClaims().getPermissions())) + .claims(claimsJws) .build(); if (LOGGER.isTraceEnabled()) { diff --git a/xtraplatform-auth/src/main/java/de/ii/xtraplatform/auth/domain/User.java b/xtraplatform-auth/src/main/java/de/ii/xtraplatform/auth/domain/User.java index f90ceb8c..3b0477e5 100644 --- a/xtraplatform-auth/src/main/java/de/ii/xtraplatform/auth/domain/User.java +++ b/xtraplatform-auth/src/main/java/de/ii/xtraplatform/auth/domain/User.java @@ -34,6 +34,8 @@ enum PolicyDecision { Map> getApiPermissions(); + Map getClaims(); + @Value.Default default Role getRole() { return Role.NONE; diff --git a/xtraplatform-base/src/main/java/de/ii/xtraplatform/base/domain/AppConfiguration.java b/xtraplatform-base/src/main/java/de/ii/xtraplatform/base/domain/AppConfiguration.java index 79f71579..26f8096b 100755 --- a/xtraplatform-base/src/main/java/de/ii/xtraplatform/base/domain/AppConfiguration.java +++ b/xtraplatform-base/src/main/java/de/ii/xtraplatform/base/domain/AppConfiguration.java @@ -151,6 +151,14 @@ && getJobs().getMaxConcurrent() == 1) { @Valid public abstract RedisConfiguration getRedis(); + /** + * @langEn See [AuditLog](120-auditLog.md). + * @langDe Siehe [AuditLog](120-auditLog.md). + */ + @JsonProperty("auditLog") + @Valid + public abstract AuditLogConfiguration getAuditLog(); + @JsonIgnore @Override public void setServerFactory(ServerFactory factory) { diff --git a/xtraplatform-base/src/main/java/de/ii/xtraplatform/base/domain/AuditLogConfiguration.java b/xtraplatform-base/src/main/java/de/ii/xtraplatform/base/domain/AuditLogConfiguration.java new file mode 100644 index 00000000..3f0748d1 --- /dev/null +++ b/xtraplatform-base/src/main/java/de/ii/xtraplatform/base/domain/AuditLogConfiguration.java @@ -0,0 +1,198 @@ +/* + * Copyright 2026 interactive instruments GmbH + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package de.ii.xtraplatform.base.domain; + +import com.fasterxml.jackson.annotation.JsonMerge; +import com.fasterxml.jackson.annotation.OptBoolean; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import de.ii.xtraplatform.docs.DocFile; +import de.ii.xtraplatform.docs.DocStep; +import de.ii.xtraplatform.docs.DocStep.Step; +import de.ii.xtraplatform.docs.DocTable; +import de.ii.xtraplatform.docs.DocTable.ColumnSet; +import java.util.List; +import org.immutables.value.Value; +import org.immutables.value.Value.Default; + +/** + * @langEn # AuditLog + *

## Options + *

{@docTable:properties} + * @langDe # AuditLog + *

## Optionen + *

{@docTable:properties} + * @ref:cfgProperties {@link de.ii.xtraplatform.base.domain.ImmutableAuditLogConfiguration} + */ +@DocFile( + path = "application/20-configuration", + name = "120-auditLog.md", + tables = { + @DocTable( + name = "properties", + rows = { + @DocStep(type = Step.TAG_REFS, params = "{@ref:cfgProperties}"), + @DocStep(type = Step.JSON_PROPERTIES) + }, + columnSet = ColumnSet.JSON_PROPERTIES) + }) +@Value.Immutable +@Value.Modifiable +@JsonDeserialize(as = ModifiableAuditLogConfiguration.class) +public interface AuditLogConfiguration { + + /** + * @langEn If `true`, audit logging is enabled; otherwise, it is disabled. + * @langDe Wenn `true`, wird das Audit-Logging eingeschaltet, ansonsten deaktiviert. + * @default false + */ + @Default + default boolean getEnabled() { + return false; + } + + /** + * @langEn Indicates how often the write process should be retried on errors. Should be set to 0 + * if no retries are desired. + * @langDe Gibt an, wie oft der Schreibprozess bei Fehlern wiederholt werden soll. Sollte auf 0 + * gesetzt werden, falls keine Neuversuche erwünscht sind. + * @default 3 + */ + @Default + default int getRetries() { + return 3; + } + + /** + * @langEn Specifies the path to prepend to the log file. `{api}` and `{date}` are replaced with + * the API ID and the request's ISO date, respectively. If the request is API-independent, + * `{api}` is replaced with "landingpage". For example, log files for `{api}/foo/{date}/bar` + * could be stored at `resources/logs/audit/vineyards/foo/2026-06-03/bar`. + * @langDe Gibt den Pfad an, der vor der Log-Datei angehängt werden soll. Dabei werden `{api}` und + * `{date}` jeweils mit der API-ID bzw. dem ISO-Datum der Anfrage ersetzt. Falls die Anfrage + * API-unabhängig ist, wird `{api}` mit "landingpage" ersetzt. Beispielsweise könnten die + * Log-Dateien für `{api}/foo/{date}/bar` unter + * `resources/logs/audit/vineyards/foo/2026-06-03/bar` gespeichert werden. + * @default {api}/{date} + */ + @Default + default String getPathPrefix() { + return "{api}/{date}"; + } + + /** + * @langEn Specifies the format in which logs are stored. Currently supported: `JSON` and + * `JSON_PRETTY` (formatted JSON). + * @langDe Gibt an, in welchem Format die Logs gespeichert werden. Unterstützt werden momentan + * `JSON` und `JSON_PRETTY` (formatiertes JSON). + * @default JSON + */ + @Default + default TYPE getType() { + return TYPE.JSON; + } + + /** + * @langEn The `included` list specifies which headers should be logged. The `excluded` list + * specifies which headers would be logged according to `included` but should not be logged. + * The special value `*` can be used for both lists and covers all headers. If `excluded = [ + * '*' ]`, no headers are logged. + * @langDe Die `included`-Liste gibt an, welche Header geloggt werden sollen. Die `excluded`-Liste + * gibt an, welche Header gemäß `included` geloggt würden, aber nicht geloggt werden sollen. + * Der spezielle Wert `*` kann für beide Listen verwendet werden und umfasst alle Header. Wenn + * `excluded = [ '*' ]`, werden keine Header geloggt. + * @default included: [ '*' ]\nexcluded: [] + */ + @Default + default HeadersConfiguration getHeaders() { + return ModifiableHeadersConfiguration.create(); + } + + /** + * @langEn Specifies which claims from the token should be logged and which should explicitly not + * be logged by using `included`/`excluded` lists. The exact logic is the same as in + * `headers`. + * @langDe Gibt mit `included`/`excluded`-Listen an, welche Claims aus dem Token geloggt werden + * sollen bzw. explizit nicht geloggt werden dürfen. Die genaue Logik entspricht der in + * `headers`. + * @default included: [ '*' ]\nexcluded: [] + */ + @Default + default ClaimsConfiguration getClaims() { + return ModifiableClaimsConfiguration.create(); + } + + /** + * @langEn Specifies for which HTTP status codes requests should be logged and which should + * explicitly not be logged by using `included`/`excluded` lists. The exact logic is the same + * as in `headers`. + * @langDe Gibt mit `included`/`excluded`-Listen an, bei welchen HTTP-Status-Codes Anfragen + * geloggt werden sollen bzw. explizit nicht geloggt werden dürfen. Die genaue Logik + * entspricht der in `headers`. + * @default included: [ '200' ]\nexcluded: [] + */ + @Default + default HttpStatusConfiguration getHttpStatus() { + return ModifiableHttpStatusConfiguration.create(); + } + + enum TYPE { + JSON, + JSON_PRETTY + } + + @Value.Immutable + @Value.Modifiable + @JsonDeserialize(as = ModifiableHeadersConfiguration.class) + interface HeadersConfiguration { + @Value.Default + @JsonMerge(OptBoolean.FALSE) + default List getIncluded() { + return List.of("*"); + } + + @Value.Default + @JsonMerge(OptBoolean.FALSE) + default List getExcluded() { + return List.of(); + } + } + + @Value.Immutable + @Value.Modifiable + @JsonDeserialize(as = ModifiableClaimsConfiguration.class) + interface ClaimsConfiguration { + @Value.Default + @JsonMerge(OptBoolean.FALSE) + default List getIncluded() { + return List.of(); + } + + @Value.Default + @JsonMerge(OptBoolean.FALSE) + default List getExcluded() { + return List.of(); + } + } + + @Value.Immutable + @Value.Modifiable + @JsonDeserialize(as = ModifiableHttpStatusConfiguration.class) + interface HttpStatusConfiguration { + @Value.Default + @JsonMerge(OptBoolean.FALSE) + default List getIncluded() { + return List.of("200"); + } + + @Value.Default + @JsonMerge(OptBoolean.FALSE) + default List getExcluded() { + return List.of(); + } + } +} diff --git a/xtraplatform-base/src/main/resources/cfg.base.yml b/xtraplatform-base/src/main/resources/cfg.base.yml index ce61bb69..2f5929f7 100644 --- a/xtraplatform-base/src/main/resources/cfg.base.yml +++ b/xtraplatform-base/src/main/resources/cfg.base.yml @@ -68,4 +68,19 @@ logging: metrics: frequency: 1 minute reporters: [ ] - reportOnStop: false \ No newline at end of file + reportOnStop: false + +auditLog: + enabled: false + retries: 3 + type: ${TYPE:-JSON} + pathPrefix: '{api}/{date}' + headers: + included: [ '*' ] + excluded: [ ] + claims: + included: [ ] + excluded: [ ] + httpStatus: + included: [ '200' ] + excluded: [ ] \ No newline at end of file diff --git a/xtraplatform-openapi/src/main/java/de/ii/xtraplatform/openapi/app/OpenApiSwaggerUiResource.java b/xtraplatform-openapi/src/main/java/de/ii/xtraplatform/openapi/app/OpenApiSwaggerUiResource.java index 3ae31069..ffab777d 100644 --- a/xtraplatform-openapi/src/main/java/de/ii/xtraplatform/openapi/app/OpenApiSwaggerUiResource.java +++ b/xtraplatform-openapi/src/main/java/de/ii/xtraplatform/openapi/app/OpenApiSwaggerUiResource.java @@ -14,11 +14,11 @@ import com.google.common.io.Files; import com.google.common.io.Resources; import de.ii.xtraplatform.openapi.domain.OpenApiViewerResource; +import de.ii.xtraplatform.web.domain.JoinableStreamingOutput; import jakarta.inject.Inject; import jakarta.inject.Singleton; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.StreamingOutput; import java.net.URL; import java.util.Objects; import org.slf4j.Logger; @@ -43,7 +43,8 @@ public Response getFile(String file) { throw new NotFoundException(); } - return Response.ok((StreamingOutput) output -> Resources.asByteSource(url).copyTo(output)) + return Response.ok( + new JoinableStreamingOutput(output -> Resources.asByteSource(url).copyTo(output))) .type(getMimeType(file)) .build(); } catch (Throwable e) { diff --git a/xtraplatform-services/build.gradle b/xtraplatform-services/build.gradle index cd946c3d..49bcf4c7 100644 --- a/xtraplatform-services/build.gradle +++ b/xtraplatform-services/build.gradle @@ -1,4 +1,3 @@ - maturity = 'MATURE' maintenance = 'FULL' description = 'Service entities, background tasks.' @@ -9,6 +8,7 @@ dependencies { provided project(':xtraplatform-entities') provided project(':xtraplatform-values') provided project(':xtraplatform-openapi') + provided project(':xtraplatform-blobs') embedded libs.cron4j } diff --git a/xtraplatform-services/src/main/java/de/ii/xtraplatform/services/app/AuditLogImpl.java b/xtraplatform-services/src/main/java/de/ii/xtraplatform/services/app/AuditLogImpl.java new file mode 100644 index 00000000..f671537c --- /dev/null +++ b/xtraplatform-services/src/main/java/de/ii/xtraplatform/services/app/AuditLogImpl.java @@ -0,0 +1,411 @@ +/* + * Copyright 2026 interactive instruments GmbH + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package de.ii.xtraplatform.services.app; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.github.azahnen.dagger.annotations.AutoBind; +import de.ii.xtraplatform.base.domain.AppContext; +import de.ii.xtraplatform.base.domain.AuditLogConfiguration; +import de.ii.xtraplatform.base.domain.Jackson; +import de.ii.xtraplatform.base.domain.LogContext; +import de.ii.xtraplatform.blobs.domain.ResourceStore; +import de.ii.xtraplatform.services.domain.AuditLog; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Singleton +@AutoBind +public class AuditLogImpl implements AuditLog { + + private static final Logger LOGGER = LoggerFactory.getLogger(AuditLogImpl.class); + + private final Map auditLogMapping = new ConcurrentHashMap<>(); + private final ResourceStore auditLogStore; + private final AppContext appContext; + private ObjectWriter objectWriter; + + @Inject + AuditLogImpl(Jackson jackson, ResourceStore resourceStore, AppContext appContext) { + objectWriter = + jackson + .getDefaultObjectMapper() + .writer() + .without(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + if (appContext.getConfiguration().getAuditLog().getType() == AuditLogConfiguration.TYPE.JSON) { + objectWriter = objectWriter.without(SerializationFeature.INDENT_OUTPUT); + } else { + objectWriter = objectWriter.with(SerializationFeature.INDENT_OUTPUT); + } + + this.auditLogStore = resourceStore.writableWith("logs", "audit"); + this.appContext = appContext; + } + + private Optional getOptionalLog(String requestId) { + if (auditLogMapping.containsKey(requestId)) { + return Optional.of(auditLogMapping.get(requestId)); + } else { + return Optional.empty(); + } + } + + private boolean isDisabled() { + return !isEnabled(); + } + + private Path createPath(String requestId, Log log) { + String pathPrefix = appContext.getConfiguration().getAuditLog().getPathPrefix(); + + // Replace api + String api = Objects.isNull(log.getApi()) ? "landingpage" : log.getApi(); + pathPrefix = pathPrefix.replace("{api}", api); + + // Replace date + String isoDate = + DateTimeFormatter.ISO_LOCAL_DATE.withZone(ZoneOffset.UTC).format(log.getStarted()); + pathPrefix = pathPrefix.replace("{date}", isoDate); + + return Path.of(pathPrefix).resolve(Path.of(requestId + ".json")); + } + + private Map filterClaims(Map claims) { + Map filteredClaims = new LinkedHashMap<>(); + List included = appContext.getConfiguration().getAuditLog().getClaims().getIncluded(); + List excluded = appContext.getConfiguration().getAuditLog().getClaims().getExcluded(); + + claims.forEach( + (k, v) -> { + if (!excluded.contains("*") + && !excluded.contains(k) + && (included.contains("*") || included.contains(k))) { + filteredClaims.put(k, v); + } + }); + + return filteredClaims; + } + + @Override + public void createLog(String requestId) { + if (isDisabled()) { + return; + } + auditLogMapping.computeIfAbsent(requestId, k -> new LogImpl(requestId)); + } + + @Override + public void abortLog(String requestId) { + auditLogMapping.remove(requestId); + } + + @Override + public void setIncludePropertyValues(String requestId, boolean value) { + if (isDisabled()) { + return; + } + getOptionalLog(requestId).ifPresent(log -> log.setIncludePropertyValues(value)); + } + + @Override + public boolean getIncludePropertyValues(String requestId) { + return getOptionalLog(requestId).map(Log::getIncludePropertyValues).orElse(false); + } + + @Override + public boolean logIsAvailable(String requestId) { + return !isDisabled() && auditLogMapping.containsKey(requestId); + } + + @Override + public boolean isEnabled() { + return appContext.getConfiguration().getAuditLog().getEnabled(); + } + + @Override + public void setApi(String requestId, String api) { + if (isDisabled()) { + return; + } + getOptionalLog(requestId).ifPresent(log -> log.setApi(api)); + } + + @Override + public void setActor( + String requestId, String actorType, String actorId, Map claims) { + if (isDisabled()) { + return; + } + getOptionalLog(requestId) + .ifPresent(log -> log.setActor(actorType, actorId, filterClaims(claims))); + } + + @Override + public void setOperationMethod(String requestId, String method) { + if (isDisabled()) { + return; + } + getOptionalLog(requestId).ifPresent(log -> log.setOperationMethod(method)); + } + + @Override + public void setOperationPath(String requestId, String path) { + if (isDisabled()) { + return; + } + getOptionalLog(requestId).ifPresent(log -> log.setOperationPath(path)); + } + + @Override + public void setOperationHeaders(String requestId, MultivaluedMap headers) { + if (isDisabled()) { + return; + } + + List included = appContext.getConfiguration().getAuditLog().getHeaders().getIncluded(); + List excluded = appContext.getConfiguration().getAuditLog().getHeaders().getExcluded(); + MultivaluedMap headersFiltered = new MultivaluedHashMap<>(); + + headers.forEach( + (k, v) -> { + if (!excluded.contains("*") + && !excluded.contains(k) + && (included.contains("*") || included.contains(k))) { + headersFiltered.addAll(k, v); + } + }); + + getOptionalLog(requestId).ifPresent(log -> log.setOperationHeaders(headersFiltered)); + } + + @Override + public void setOperationStatus(String requestId, String status) { + if (isDisabled()) { + return; + } + getOptionalLog(requestId).ifPresent(log -> log.setOperationStatus(status)); + } + + @Override + public void setTarget(String requestId, Map target) { + if (isDisabled()) { + return; + } + getOptionalLog(requestId).ifPresent(log -> log.setTarget(target)); + } + + @Override + @SuppressWarnings({ + "PMD.CognitiveComplexity", + "PMD.CyclomaticComplexity", + "PMD.AvoidInstantiatingObjectsInLoops" + }) + public boolean removeAndWriteLog(String requestId) { + if (isDisabled()) { + return false; + } + + Log log = auditLogMapping.remove(requestId); + if (Objects.isNull(log)) { + return false; + } + + log.finish(); + + final byte[] serializedLog; + try { + if (LOGGER.isTraceEnabled()) { + LOGGER.trace(objectWriter.writeValueAsString(log)); + } + serializedLog = objectWriter.writeValueAsBytes(log); + } catch (JsonProcessingException e) { + LOGGER.error("Failed to serialize audit log {}", requestId, e); + return false; + } + + Path path = createPath(requestId, log); + int maxRetries = appContext.getConfiguration().getAuditLog().getRetries(); + int retries = 0; + do { + try { + auditLogStore.put(path, new ByteArrayInputStream(serializedLog)); + return true; + } catch (IOException e) { + if (retries < maxRetries) { + int delay = 100 * (retries + 1); + if (LOGGER.isWarnEnabled()) { + LOGGER.warn("Failed to write audit log {}, retrying in {}ms", requestId, delay); + } + try { + Thread.sleep(delay); + } catch (InterruptedException ex) { + // ignore + } + } else { + LogContext.error( + LOGGER, e, "Giving up writing audit log {} after {} retries", requestId, retries + 1); + LOGGER.error( + "Audit log for {}: {}", requestId, new String(serializedLog, StandardCharsets.UTF_8)); + return false; + } + } + retries++; + } while (retries <= maxRetries); + + return false; + } + + @JsonPropertyOrder({"id", "started", "finished", "api", "actor", "operation", "target"}) + public static class LogImpl implements Log { + private final String id; + private final Instant started; + private final Map actor = new LinkedHashMap<>(); + private final Map operation = new LinkedHashMap<>(); + private Instant finished; + private Map target; + private String api; + private boolean includePropertyValues = true; + + LogImpl(String id) { + this.id = id; + this.started = Instant.now(); + } + + @Override + public void finish() { + finished = Instant.now(); + } + + @Override + public void setActor(String actorType, String actorId, Map claims) { + actor.put("type", actorType); + actor.put("id", actorId); + actor.put("claims", claims); + } + + @Override + public void setOperationMethod(String method) { + operation.put("method", method); + } + + @Override + public void setOperationPath(String path) { + operation.put("path", path); + } + + @Override + public void setOperationHeaders(MultivaluedMap headers) { + Map headersReduced = new LinkedHashMap<>(); + + headers.forEach( + (key, values) -> { + if (!values.isEmpty()) { + headersReduced.put(key, values.size() == 1 ? values.get(0) : values); + } + }); + + operation.put("headers", headersReduced); + } + + @Override + public void setOperationStatus(String status) { + operation.put("status", status); + } + + @JsonIgnore + @Override + public boolean getIncludePropertyValues() { + return includePropertyValues; + } + + @Override + public void setIncludePropertyValues(boolean value) { + includePropertyValues = value; + } + + @JsonProperty("id") + @Override + public String getId() { + return id; + } + + @JsonProperty("started") + @Override + public Instant getStarted() { + return started; + } + + @JsonProperty("finished") + @Override + public Instant getFinished() { + return finished; + } + + @JsonProperty("api") + @Override + public String getApi() { + return api; + } + + @Override + public void setApi(String api) { + this.api = api; + } + + @JsonProperty("actor") + @Override + public Map getActor() { + // Apply the anonymous user if no actor has been set + if (!actor.containsKey("type") && !actor.containsKey("id")) { + actor.put("type", "AnonymousUser"); + actor.put("id", "Anonymous"); + } + + return actor; + } + + @JsonProperty("operation") + @Override + public Map getOperation() { + return operation; + } + + @JsonProperty("target") + @Override + public Map getTarget() { + return target; + } + + @Override + public void setTarget(Map target) { + this.target = new LinkedHashMap<>(target); + } + } +} diff --git a/xtraplatform-services/src/main/java/de/ii/xtraplatform/services/app/AuditLogResponseFilter.java b/xtraplatform-services/src/main/java/de/ii/xtraplatform/services/app/AuditLogResponseFilter.java new file mode 100644 index 00000000..905dba38 --- /dev/null +++ b/xtraplatform-services/src/main/java/de/ii/xtraplatform/services/app/AuditLogResponseFilter.java @@ -0,0 +1,125 @@ +/* + * Copyright 2026 interactive instruments GmbH + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package de.ii.xtraplatform.services.app; + +import com.github.azahnen.dagger.annotations.AutoBind; +import de.ii.xtraplatform.base.domain.AppContext; +import de.ii.xtraplatform.services.domain.AuditLog; +import de.ii.xtraplatform.web.domain.JoinableStreamingOutput; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import jakarta.ws.rs.InternalServerErrorException; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerResponseContext; +import jakarta.ws.rs.container.ContainerResponseFilter; +import java.io.IOException; +import java.util.List; +import java.util.Objects; + +@Singleton +@AutoBind +public class AuditLogResponseFilter implements ContainerResponseFilter { + + private final AuditLog auditLog; + private final List included; + private final List excluded; + + @Inject + public AuditLogResponseFilter(AuditLog auditLog, AppContext appContext) { + this.auditLog = auditLog; + this.included = appContext.getConfiguration().getAuditLog().getHttpStatus().getIncluded(); + this.excluded = appContext.getConfiguration().getAuditLog().getHttpStatus().getExcluded(); + } + + private boolean sufficientHttpCode(int statusCodeInt) { + String statusCode = String.valueOf(statusCodeInt); + + return !excluded.contains("*") + && !excluded.contains(statusCode) + && (included.contains("*") || included.contains(statusCode)); + } + + @Override + public void filter( + ContainerRequestContext requestContext, ContainerResponseContext responseContext) + throws IOException { + // Return if auditLog is disabled in global config (cfg.yml) + if (!auditLog.isEnabled()) { + return; + } + + // Return if the request context is missing + if (Objects.isNull(requestContext)) { + return; + } + + // Return if the requestId is missing + if (!(requestContext.getProperty("REQUEST_ID") instanceof String requestId)) { + return; + } + + // Return if no log is available (for example if the log was aborted during another step) + if (!auditLog.logIsAvailable(requestId)) { + return; + } + + // Abort log and return if the response context is missing + if (Objects.isNull(responseContext)) { + auditLog.abortLog(requestId); + return; + } + + if (responseContext.getEntity() instanceof JoinableStreamingOutput streamingOutput) { + streamingOutput.whenComplete( + (throwable) -> { + int statusCode = getStatusCode(responseContext.getStatus(), throwable); + + writeLogEntry(requestId, statusCode); + }); + return; + } + + writeLogEntry(requestId, responseContext.getStatus()); + } + + private void writeLogEntry(String requestId, int statusCode) { + // Abort log and return if HTTP-Code is not applicable according to the global config + if (!sufficientHttpCode(statusCode)) { + auditLog.abortLog(requestId); + return; + } + // Log status + auditLog.setOperationStatus(requestId, Integer.toString(statusCode)); + + // Write the final log and save result + boolean logSuccessful = auditLog.removeAndWriteLog(requestId); + + // Abort request if writing the log was not successful! + if (!logSuccessful) { + throw new InternalServerErrorException(); + } + } + + // This is not one hundred percent correct. If the web server already started writing the response + // to the client before an exception occurred, the returned status code will always be 200, even + // if the response is broken and the log shows an error. But there is no way to determine this + // case, so we log the status code that should have been returned. + private static int getStatusCode(int initialStatusCode, Throwable throwable) { + int statusCode = initialStatusCode; + + if (Objects.nonNull(throwable)) { + if (throwable instanceof WebApplicationException webAppException) { + statusCode = webAppException.getResponse().getStatus(); + } else { + statusCode = 500; + } + } + return statusCode; + } +} diff --git a/xtraplatform-services/src/main/java/de/ii/xtraplatform/services/app/ServicesEndpoint.java b/xtraplatform-services/src/main/java/de/ii/xtraplatform/services/app/ServicesEndpoint.java index cf9b0d2b..add1afd2 100755 --- a/xtraplatform-services/src/main/java/de/ii/xtraplatform/services/app/ServicesEndpoint.java +++ b/xtraplatform-services/src/main/java/de/ii/xtraplatform/services/app/ServicesEndpoint.java @@ -13,6 +13,7 @@ import de.ii.xtraplatform.base.domain.LogContext; import de.ii.xtraplatform.base.domain.LogContext.MARKER; import de.ii.xtraplatform.entities.domain.EntityRegistry; +import de.ii.xtraplatform.services.domain.AuditLog; import de.ii.xtraplatform.services.domain.Service; import de.ii.xtraplatform.services.domain.ServiceData; import de.ii.xtraplatform.services.domain.ServiceEndpoint; @@ -79,6 +80,8 @@ public class ServicesEndpoint implements Endpoint { private final Lazy> serviceResources; private final Lazy> serviceListingProviders; + private final AuditLog auditLog; + @Inject public ServicesEndpoint( EntityRegistry entityRegistry, @@ -87,7 +90,8 @@ public ServicesEndpoint( StaticResourceHandler staticResourceHandler, Lazy> loginHandler, Lazy> serviceResources, - Lazy> serviceListingProviders) { + Lazy> serviceListingProviders, + AuditLog auditLog) { this.entityRegistry = entityRegistry; this.servicesContext = servicesContext; this.serviceContext = serviceContext; @@ -95,6 +99,7 @@ public ServicesEndpoint( this.loginHandler = loginHandler; this.serviceResources = serviceResources; this.serviceListingProviders = serviceListingProviders; + this.auditLog = auditLog; } @GET @@ -360,6 +365,8 @@ private void setupRequestLogging( String serviceId, Integer version, ContainerRequestContext containerRequestContext) { String uuid = LogContext.generateRandomUuid().toString(); containerRequestContext.setProperty("REQUEST_ID", uuid); + auditLog.createLog(uuid); + if (LOGGER.isDebugEnabled() || LOGGER.isDebugEnabled(MARKER.REQUEST)) { LogContext.put(LogContext.CONTEXT.REQUEST, uuid); diff --git a/xtraplatform-services/src/main/java/de/ii/xtraplatform/services/domain/AuditLog.java b/xtraplatform-services/src/main/java/de/ii/xtraplatform/services/domain/AuditLog.java new file mode 100644 index 00000000..b5590717 --- /dev/null +++ b/xtraplatform-services/src/main/java/de/ii/xtraplatform/services/domain/AuditLog.java @@ -0,0 +1,79 @@ +/* + * Copyright 2026 interactive instruments GmbH + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package de.ii.xtraplatform.services.domain; + +import jakarta.ws.rs.core.MultivaluedMap; +import java.time.Instant; +import java.util.Map; + +public interface AuditLog { + void createLog(String requestId); + + void abortLog(String requestId); + + void setIncludePropertyValues(String requestId, boolean value); + + boolean getIncludePropertyValues(String requestId); + + boolean isEnabled(); + + boolean logIsAvailable(String requestId); + + void setApi(String requestId, String api); + + void setActor(String requestId, String actorType, String actorId, Map claims); + + void setOperationMethod(String requestId, String method); + + void setOperationPath(String requestId, String path); + + void setOperationHeaders(String requestId, MultivaluedMap headers); + + void setOperationStatus(String requestId, String status); + + void setTarget(String requestId, Map target); + + boolean removeAndWriteLog(String requestId); + + interface Log { + + void finish(); + + void setActor(String actorType, String actorId, Map claims); + + void setOperationMethod(String method); + + void setOperationPath(String path); + + void setOperationHeaders(MultivaluedMap headers); + + void setOperationStatus(String status); + + void setIncludePropertyValues(boolean value); + + boolean getIncludePropertyValues(); + + String getId(); + + Instant getStarted(); + + Instant getFinished(); + + String getApi(); + + void setApi(String api); + + Map getActor(); + + Map getOperation(); + + Map getTarget(); + + void setTarget(Map target); + } +} diff --git a/xtraplatform-web/src/main/java/de/ii/xtraplatform/web/domain/JoinableStreamingOutput.java b/xtraplatform-web/src/main/java/de/ii/xtraplatform/web/domain/JoinableStreamingOutput.java new file mode 100644 index 00000000..7217d4f2 --- /dev/null +++ b/xtraplatform-web/src/main/java/de/ii/xtraplatform/web/domain/JoinableStreamingOutput.java @@ -0,0 +1,45 @@ +/* + * Copyright 2026 interactive instruments GmbH + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package de.ii.xtraplatform.web.domain; + +import jakarta.ws.rs.core.StreamingOutput; +import java.io.IOException; +import java.io.OutputStream; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +public class JoinableStreamingOutput implements StreamingOutput { + + private final StreamingOutput delegate; + private final CompletableFuture completableFuture; + + public JoinableStreamingOutput(StreamingOutput delegate) { + this.delegate = delegate; + this.completableFuture = new CompletableFuture<>(); + } + + public void whenComplete(Consumer onComplete) { + completableFuture.whenComplete( + (result, throwable) -> { + onComplete.accept(throwable); + }); + } + + @Override + @SuppressWarnings({"PMD.AvoidCatchingGenericException"}) + public void write(OutputStream outputStream) throws IOException { + try { + delegate.write(outputStream); + } catch (Throwable throwable) { + completableFuture.completeExceptionally(throwable); + throw throwable; + } finally { + completableFuture.complete(null); + } + } +}