From c8c40cf712c0f5ae1354c4d3c484038e38049cea Mon Sep 17 00:00:00 2001 From: Thomas Darimont Date: Fri, 10 Jul 2026 16:29:35 +0200 Subject: [PATCH 1/2] SSF: Derive emit audit event data from typed events (#50720) Instead of persisting the verbatim emit request payload in the admin event, EmitEventResult now carries the validated typed SsfEvent and the audit representation is built via the new SsfEvent.createAdminDetails() hook. Each event class contributes only its audit-safe fields (e.g. credential_type/change_type for credential-change, status/reason for stream-updated, the standard CAEP claims on CaepEvent); the default is an empty map, so free-form payload data and PII never reach the admin event store. EventEmitterService now also rejects payloads that do not materialise as a typed SsfEvent. Fixes #50720 Signed-off-by: Thomas Darimont --- .../java/org/keycloak/ssf/event/SsfEvent.java | 11 ++++++ .../ssf/event/caep/CaepCredentialChange.java | 17 ++++++++ .../keycloak/ssf/event/caep/CaepEvent.java | 13 +++++++ .../event/stream/SsfStreamUpdatedEvent.java | 10 +++++ .../stream/SsfStreamVerificationEvent.java | 9 +++++ .../ssf/services/admin/SsfAdminResource.java | 38 ++++++++++++++---- .../SsfTransmitterEventEmitterTests.java | 39 +++++++++++++++++++ .../ssf/transmitter/emit/EmitEventResult.java | 12 +++--- .../transmitter/emit/EventEmitterService.java | 16 ++++---- 9 files changed, 145 insertions(+), 20 deletions(-) diff --git a/ssf/core/src/main/java/org/keycloak/ssf/event/SsfEvent.java b/ssf/core/src/main/java/org/keycloak/ssf/event/SsfEvent.java index 66afdf461d5d..8783c57c74ee 100644 --- a/ssf/core/src/main/java/org/keycloak/ssf/event/SsfEvent.java +++ b/ssf/core/src/main/java/org/keycloak/ssf/event/SsfEvent.java @@ -111,6 +111,17 @@ public void setAlias(String alias) { this.alias = alias; } + /** + * Creates and returns a representation of the administrative details for an event. + * This representation is structured as a map with string keys and object values. + * + * @return a map containing the administrative representation + */ + public Map createAdminDetails() { + Map adminRep = new LinkedHashMap<>(); + return adminRep; + } + /** * Verify that this event instance carries the fields the SSF / * CAEP / RISC spec marks as REQUIRED. Called by the synthetic-emit diff --git a/ssf/core/src/main/java/org/keycloak/ssf/event/caep/CaepCredentialChange.java b/ssf/core/src/main/java/org/keycloak/ssf/event/caep/CaepCredentialChange.java index 60f9b02be8d0..b5739aaa17ea 100644 --- a/ssf/core/src/main/java/org/keycloak/ssf/event/caep/CaepCredentialChange.java +++ b/ssf/core/src/main/java/org/keycloak/ssf/event/caep/CaepCredentialChange.java @@ -5,6 +5,7 @@ import org.keycloak.ssf.event.SsfEventValidationException; import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonValue; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; @@ -35,6 +36,9 @@ public class CaepCredentialChange extends CaepEvent { @JsonProperty("credential_type") protected String credentialType; + @JsonIgnore + protected CaepCredentialType resolvedCredentialType; + /** * This MUST be one of the following strings: * @@ -99,6 +103,7 @@ public String getCredentialType() { public void setCredentialType(String credentialType) { this.credentialType = credentialType; + this.resolvedCredentialType = CaepCredentialType.fromString(credentialType); } public ChangeType getChangeType() { @@ -141,6 +146,10 @@ public void setFido2Aaguid(String fido2Aaguid) { this.fido2Aaguid = fido2Aaguid; } + public CaepCredentialType getResolvedCredentialType() { + return resolvedCredentialType; + } + /** * See: https://openid.net/specs/openid-caep-specification-1_0.html#rfc.section.3.3.1 */ @@ -205,6 +214,14 @@ public String getType() { } } + @Override + public Map createAdminDetails() { + var adminRepresentation = super.createAdminDetails(); + adminRepresentation.put("credential_type", resolvedCredentialType.getType()); + adminRepresentation.put("change_type", changeType); + return adminRepresentation; + } + @Override protected void appendFields(Map fields) { super.appendFields(fields); diff --git a/ssf/core/src/main/java/org/keycloak/ssf/event/caep/CaepEvent.java b/ssf/core/src/main/java/org/keycloak/ssf/event/caep/CaepEvent.java index 6738196e99ce..ed106189f1ad 100644 --- a/ssf/core/src/main/java/org/keycloak/ssf/event/caep/CaepEvent.java +++ b/ssf/core/src/main/java/org/keycloak/ssf/event/caep/CaepEvent.java @@ -66,6 +66,19 @@ public CaepEvent(String type) { super(type); } + @Override + public Map createAdminDetails() { + Map adminRep = super.createAdminDetails(); + if (eventTimestamp != null) { + adminRep.put("event_timestamp", eventTimestamp); + } + if (initiatingEntity != null) { + adminRep.put("initiating_entity", initiatingEntity); + } + // excluding reasonAdmin and reasonUser to avoid exposing PII here + return adminRep; + } + public SubjectId getSubjectId() { return subjectId; } diff --git a/ssf/core/src/main/java/org/keycloak/ssf/event/stream/SsfStreamUpdatedEvent.java b/ssf/core/src/main/java/org/keycloak/ssf/event/stream/SsfStreamUpdatedEvent.java index ad5800004d9e..734298608a08 100644 --- a/ssf/core/src/main/java/org/keycloak/ssf/event/stream/SsfStreamUpdatedEvent.java +++ b/ssf/core/src/main/java/org/keycloak/ssf/event/stream/SsfStreamUpdatedEvent.java @@ -47,6 +47,16 @@ public void setReason(String reason) { this.reason = reason; } + @Override + public Map createAdminDetails() { + var adminRepresentation = super.createAdminDetails(); + adminRepresentation.put("status", status); + if (reason != null) { + adminRepresentation.put("reason", reason); + } + return adminRepresentation; + } + @Override protected void appendFields(Map fields) { super.appendFields(fields); diff --git a/ssf/core/src/main/java/org/keycloak/ssf/event/stream/SsfStreamVerificationEvent.java b/ssf/core/src/main/java/org/keycloak/ssf/event/stream/SsfStreamVerificationEvent.java index ec31635e2d1a..4e68f450f9c2 100644 --- a/ssf/core/src/main/java/org/keycloak/ssf/event/stream/SsfStreamVerificationEvent.java +++ b/ssf/core/src/main/java/org/keycloak/ssf/event/stream/SsfStreamVerificationEvent.java @@ -28,6 +28,15 @@ public void setState(String state) { this.state = state; } + @Override + public Map createAdminDetails() { + var adminRepresentation = super.createAdminDetails(); + if (state != null) { + adminRepresentation.put("state", state); + } + return adminRepresentation; + } + @Override protected void appendFields(Map fields) { super.appendFields(fields); diff --git a/ssf/services/src/main/java/org/keycloak/ssf/services/admin/SsfAdminResource.java b/ssf/services/src/main/java/org/keycloak/ssf/services/admin/SsfAdminResource.java index 29e5445c51f0..0abd644d6716 100644 --- a/ssf/services/src/main/java/org/keycloak/ssf/services/admin/SsfAdminResource.java +++ b/ssf/services/src/main/java/org/keycloak/ssf/services/admin/SsfAdminResource.java @@ -35,6 +35,7 @@ import org.keycloak.services.resources.admin.AdminEventBuilder; import org.keycloak.services.resources.admin.fgap.AdminPermissionEvaluator; import org.keycloak.ssf.SsfException; +import org.keycloak.ssf.event.SsfEvent; import org.keycloak.ssf.stream.StreamStatus; import org.keycloak.ssf.subject.ComplexSubjectId; import org.keycloak.ssf.subject.SubjectId; @@ -1179,12 +1180,6 @@ public Response emitEvent( // event log mirrors what the operator did, not what the dispatcher // chose to do downstream — the latter is captured in the result // `status` carried in the representation. - // - // Representation is a slim summary (event type, subject reference, - // result status + jti). We deliberately do NOT include the verbatim - // event body from the request, which can be arbitrarily large and - // may carry payload-specific PII; admins who need that detail can - // still grep the SSF metric / outbox row by jti. Map auditRep = createEmitEventAuditRepresentation(request, emitResult); UserModel user = auth.adminAuth().getUser(); adminEvent.operation(OperationType.ACTION) @@ -1200,6 +1195,19 @@ public Response emitEvent( emitResult.message())).build(); } + /** + * Creates an audit representation of the event emitted. By default we deliberately do NOT include the verbatim event body + * from the request, which can be arbitrarily large and may carry payload-specific PII; admins who need that detail can still grep the SSF metric / outbox row by jti. + * + * Subclasses may add bounded, non-sensitive metadata, but must not persist caller-supplied event payloads or other free-form values. + * + * @param request the request containing event emission details such as event type, + * subject type, and subject value. + * @param emitResult the result of the emission, containing the status and + * optionally a unique identifier (jti). + * @return a map representing the audit metadata of the emitted event. Keys include + * "eventType", "subjectType", "subjectValue", "status", and optionally "jti". + */ protected Map createEmitEventAuditRepresentation(SsfEmitEventRequest request, EmitEventResult emitResult) { Map auditRep = new LinkedHashMap<>(); auditRep.put("eventType", request.getEventType()); @@ -1213,12 +1221,26 @@ protected Map createEmitEventAuditRepresentation(SsfEmitEventReq if (emitResult.jti() != null) { auditRep.put("jti", emitResult.jti()); } - if (request.getEvent() != null) { - auditRep.put("eventData", request.getEvent()); + if (emitResult.event() != null) { + // ssfEvent is already validated here + Map adminFields = createAdminDetails(emitResult.event()); + if (adminFields != null && !adminFields.isEmpty()) { + auditRep.put("eventData", adminFields); + } } return auditRep; } + /** + * Creates a map containing administrative details for the provided SsfEvent. + * + * @param ssfEvent the event object from which administrative details are created + * @return a map with key-value pairs representing administrative details of the event + */ + protected Map createAdminDetails(SsfEvent ssfEvent) { + return ssfEvent.createAdminDetails(); + } + /** * Looks up a single outbox row by {@code (receiverClient, jti)} so * an admin can inspect the delivery state of a specific SET — used diff --git a/ssf/tests/base/src/test/java/org/keycloak/tests/ssf/transmitter/SsfTransmitterEventEmitterTests.java b/ssf/tests/base/src/test/java/org/keycloak/tests/ssf/transmitter/SsfTransmitterEventEmitterTests.java index 08945037986a..9ce3aa87c5bb 100644 --- a/ssf/tests/base/src/test/java/org/keycloak/tests/ssf/transmitter/SsfTransmitterEventEmitterTests.java +++ b/ssf/tests/base/src/test/java/org/keycloak/tests/ssf/transmitter/SsfTransmitterEventEmitterTests.java @@ -17,6 +17,7 @@ import org.keycloak.http.simple.SimpleHttp; import org.keycloak.http.simple.SimpleHttpResponse; import org.keycloak.jose.jws.JWSInput; +import org.keycloak.representations.idm.AdminEventRepresentation; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientScopeRepresentation; import org.keycloak.representations.idm.OrganizationRepresentation; @@ -561,6 +562,43 @@ public void emit_adminShorthandSubjectNotFound_returnsSubjectNotFound() throws E "rejected admin emit must not produce a push"); } + @Test + public void emit_persistsOnlyExplicitlyAllowedEventPayloadInAdminEventRepresentation() throws Exception { + realm.admin().clearAdminEvents(); + String mgmtToken = obtainServiceAccountToken(MGMT_EMITTER, MGMT_EMITTER_SECRET); + + try (SimpleHttpResponse res = emit(mgmtToken, "CaepCredentialChange", TEST_EMAIL, + Map.of( + "credential_type", "MY_CREDENTIAL_TYPE", + "change_type", "update", + "sensitive_subject_email", TEST_EMAIL, + "reason_admin", Map.of("en", TEST_EMAIL), + "reason_user", Map.of("en", TEST_EMAIL) + ))) { + Assertions.assertEquals(200, res.getStatus(), + "emit should succeed for a properly authorized management client"); + } + + AdminEventRepresentation emitAdminEvent = realm.admin().getAdminEvents().stream() + .filter(event -> event.getResourcePath() != null) + .filter(event -> event.getResourcePath().endsWith("events/emit")) + .findFirst() + .orElseThrow(() -> new AssertionError("emit admin event was not stored")); + + JsonNode representation = JsonSerialization.mapper.readTree(emitAdminEvent.getRepresentation()); + + Assertions.assertTrue(representation.has("eventData")); + JsonNode eventDataNode = representation.path("eventData"); + Assertions.assertEquals("custom", eventDataNode.get("credential_type").asText()); + Assertions.assertEquals("update", eventDataNode.get("change_type").asText()); + + Assertions.assertFalse(eventDataNode.has("sensitive_subject_email"), + "admin event representation should NOT contain unknown caller-supplied event payload attributes"); + Assertions.assertFalse(eventDataNode.has("reason_admin")); + Assertions.assertFalse(eventDataNode.has("reason_user")); + } + + // --- helpers --------------------------------------------------------- protected String emitEndpointUrl() { @@ -861,6 +899,7 @@ public RealmBuilder configure(RealmBuilder realm) { realm.eventsEnabled(true); realm.adminEventsEnabled(true); + realm.adminEventsDetailsEnabled(true); realm.eventsListeners("jboss-logging", "ssf-events"); realm.users( diff --git a/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/emit/EmitEventResult.java b/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/emit/EmitEventResult.java index c630e9be7fdc..def1b5de1514 100644 --- a/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/emit/EmitEventResult.java +++ b/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/emit/EmitEventResult.java @@ -1,5 +1,7 @@ package org.keycloak.ssf.transmitter.emit; +import org.keycloak.ssf.event.SsfEvent; + /** * Outcome of a synthetic SSF event emission. Carries the dispatch * status, (on success) the {@code jti} of the SET that went out so the @@ -8,17 +10,17 @@ * failures (e.g. payload-shape mismatch against the registered event * class) so the admin endpoint can return a 400 with a useful body. */ -public record EmitEventResult(EmitEventStatus status, String jti, String message) { +public record EmitEventResult(EmitEventStatus status, String jti, String message, SsfEvent event) { - public static EmitEventResult dispatched(String jti) { - return new EmitEventResult(EmitEventStatus.DISPATCHED, jti, null); + public static EmitEventResult dispatched(String jti, SsfEvent typedEvent) { + return new EmitEventResult(EmitEventStatus.DISPATCHED, jti, null, typedEvent); } public static EmitEventResult dropped(EmitEventStatus status) { - return new EmitEventResult(status, null, null); + return new EmitEventResult(status, null, null, null); } public static EmitEventResult dropped(EmitEventStatus status, String message) { - return new EmitEventResult(status, null, message); + return new EmitEventResult(status, null, message, null); } } diff --git a/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/emit/EventEmitterService.java b/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/emit/EventEmitterService.java index 07b4ac62bf70..6e88c4e29116 100644 --- a/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/emit/EventEmitterService.java +++ b/ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/emit/EventEmitterService.java @@ -209,12 +209,14 @@ public EmitEventResult emit(ClientModel receiverClient, // status enum (invalid_event_data) so callers get one stable // identifier that names both the failure category and the // offending alias.field — they can localise from there. - if (eventPayload instanceof SsfEvent typedEvent) { - try { - typedEvent.validate(); - } catch (SsfEventValidationException e) { - return EmitEventResult.dropped(EmitEventStatus.INVALID_EVENT_DATA, e.getMessage()); - } + if (!(eventPayload instanceof SsfEvent typedEvent)) { + return EmitEventResult.dropped(EmitEventStatus.INVALID_EVENT_DATA, "Event payload is not an ssf event"); + } + + try { + typedEvent.validate(); + } catch (SsfEventValidationException e) { + return EmitEventResult.dropped(EmitEventStatus.INVALID_EVENT_DATA, e.getMessage()); } // 6. Build the SET (sub_id verbatim from the emitter) and hand @@ -232,7 +234,7 @@ public EmitEventResult emit(ClientModel receiverClient, log.debugf("SSF synthetic event dispatched. receiverClientId=%s streamId=%s eventType=%s jti=%s", receiverClient.getClientId(), stream.getStreamId(), eventTypeUri, token.getJti()); - return EmitEventResult.dispatched(token.getJti()); + return EmitEventResult.dispatched(token.getJti(), typedEvent); } /** From 0e1f5860e297022d3c2c6e1e59b759746df62bc0 Mon Sep 17 00:00:00 2001 From: Thomas Darimont Date: Tue, 11 Aug 2026 14:06:13 +0200 Subject: [PATCH 2/2] SSF: Handle SET validation exceptions in push endpoint Compute credential_type for adminDetails ad-hoc. Signed-off-by: Thomas Darimont --- .../ssf/event/caep/CaepCredentialChange.java | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/ssf/core/src/main/java/org/keycloak/ssf/event/caep/CaepCredentialChange.java b/ssf/core/src/main/java/org/keycloak/ssf/event/caep/CaepCredentialChange.java index b5739aaa17ea..8bd60d9f0853 100644 --- a/ssf/core/src/main/java/org/keycloak/ssf/event/caep/CaepCredentialChange.java +++ b/ssf/core/src/main/java/org/keycloak/ssf/event/caep/CaepCredentialChange.java @@ -5,7 +5,6 @@ import org.keycloak.ssf.event.SsfEventValidationException; import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonValue; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; @@ -36,9 +35,6 @@ public class CaepCredentialChange extends CaepEvent { @JsonProperty("credential_type") protected String credentialType; - @JsonIgnore - protected CaepCredentialType resolvedCredentialType; - /** * This MUST be one of the following strings: * @@ -103,7 +99,6 @@ public String getCredentialType() { public void setCredentialType(String credentialType) { this.credentialType = credentialType; - this.resolvedCredentialType = CaepCredentialType.fromString(credentialType); } public ChangeType getChangeType() { @@ -146,10 +141,6 @@ public void setFido2Aaguid(String fido2Aaguid) { this.fido2Aaguid = fido2Aaguid; } - public CaepCredentialType getResolvedCredentialType() { - return resolvedCredentialType; - } - /** * See: https://openid.net/specs/openid-caep-specification-1_0.html#rfc.section.3.3.1 */ @@ -217,7 +208,10 @@ public String getType() { @Override public Map createAdminDetails() { var adminRepresentation = super.createAdminDetails(); - adminRepresentation.put("credential_type", resolvedCredentialType.getType()); + // fromString() collapses caller-supplied free-text credential types to the + // closed CaepCredentialType vocabulary so free-form values can't leak into + // the admin event store + adminRepresentation.put("credential_type", CaepCredentialType.fromString(credentialType).getType()); adminRepresentation.put("change_type", changeType); return adminRepresentation; }