Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions ssf/core/src/main/java/org/keycloak/ssf/event/SsfEvent.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> createAdminDetails() {
Map<String, Object> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,17 @@ public String getType() {
}
}

@Override
public Map<String, Object> createAdminDetails() {
var adminRepresentation = super.createAdminDetails();
// 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;
}

@Override
protected void appendFields(Map<String, Object> fields) {
super.appendFields(fields);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,19 @@ public CaepEvent(String type) {
super(type);
}

@Override
public Map<String, Object> createAdminDetails() {
Map<String, Object> 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ public void setReason(String reason) {
this.reason = reason;
}

@Override
public Map<String, Object> createAdminDetails() {
var adminRepresentation = super.createAdminDetails();
adminRepresentation.put("status", status);
if (reason != null) {
adminRepresentation.put("reason", reason);
}
return adminRepresentation;
}

@Override
protected void appendFields(Map<String, Object> fields) {
super.appendFields(fields);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ public void setState(String state) {
this.state = state;
}

@Override
public Map<String, Object> createAdminDetails() {
var adminRepresentation = super.createAdminDetails();
if (state != null) {
adminRepresentation.put("state", state);
}
return adminRepresentation;
}

@Override
protected void appendFields(Map<String, Object> fields) {
super.appendFields(fields);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, Object> auditRep = createEmitEventAuditRepresentation(request, emitResult);
UserModel user = auth.adminAuth().getUser();
adminEvent.operation(OperationType.ACTION)
Expand All @@ -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<String, Object> createEmitEventAuditRepresentation(SsfEmitEventRequest request, EmitEventResult emitResult) {
Map<String, Object> auditRep = new LinkedHashMap<>();
auditRep.put("eventType", request.getEventType());
Expand All @@ -1213,12 +1221,26 @@ protected Map<String, Object> 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<String, Object> 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<String, Object> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}

/**
Expand Down
Loading