-
Notifications
You must be signed in to change notification settings - Fork 8.8k
SSF: Revise SSF event structure (#50792) #50793
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e26a547
d235bc9
96a49bd
a9b8f2d
9165e81
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,117 +3,55 @@ | |
| import java.lang.reflect.Field; | ||
| import java.util.HashMap; | ||
| import java.util.HashSet; | ||
| import java.util.LinkedHashMap; | ||
| import java.util.Map; | ||
| import java.util.Set; | ||
| import java.util.StringJoiner; | ||
| import java.util.concurrent.ConcurrentHashMap; | ||
| import java.util.concurrent.ConcurrentMap; | ||
|
|
||
| import org.keycloak.ssf.subject.SubjectId; | ||
| import org.keycloak.ssf.subject.SubjectIdJsonDeserializer; | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonAnyGetter; | ||
| import com.fasterxml.jackson.annotation.JsonAnySetter; | ||
| import com.fasterxml.jackson.annotation.JsonIgnore; | ||
| import com.fasterxml.jackson.annotation.JsonInclude; | ||
| import com.fasterxml.jackson.annotation.JsonProperty; | ||
| import com.fasterxml.jackson.databind.annotation.JsonDeserialize; | ||
|
|
||
| /** | ||
| * Represents a generic SSF event. | ||
| * | ||
| * <p> | ||
| * See: https://datatracker.ietf.org/doc/html/rfc8417 | ||
| */ | ||
| @JsonInclude(JsonInclude.Include.NON_NULL) | ||
| public abstract class SsfEvent { | ||
|
|
||
| private static final ConcurrentMap<Class<?>, Set<String>> DECLARED_JSON_PROPERTIES = new ConcurrentHashMap<>(); | ||
|
|
||
| /** | ||
| * Internal (shorter) alias for the event type. | ||
| */ | ||
| @JsonIgnore | ||
| protected String alias; | ||
|
|
||
| @JsonProperty("subject") | ||
| @JsonDeserialize(using = SubjectIdJsonDeserializer.class) | ||
| protected SubjectId subjectId; | ||
|
|
||
| @JsonIgnore | ||
| protected String eventType; | ||
|
|
||
| /** | ||
| * The time of the event (UNIX timestamp). Nullable so events that do | ||
| * not carry a timestamp — e.g. {@code ssf/event-type/verification} | ||
| * (SSF §8.1.4 carries only {@code state}) and other stream-management | ||
| * events — are omitted from the wire JSON instead of being serialized | ||
| * as {@code "event_timestamp": 0} (the default value of a primitive | ||
| * {@code long}, which Jackson always emits). | ||
| */ | ||
| @JsonProperty("event_timestamp") | ||
| protected Long eventTimestamp; | ||
|
|
||
| /** | ||
| * The entity that initiated the event | ||
| */ | ||
| @JsonProperty("initiating_entity") | ||
| protected InitiatingEntity initiatingEntity; | ||
|
|
||
| /** | ||
| * A localized administrative message intended for logging and auditing. | ||
| * key is language code, value is message. | ||
| * The event type URI | ||
| */ | ||
| @JsonProperty("reason_admin") | ||
| protected Map<String, String> reasonAdmin; | ||
| @JsonIgnore | ||
| protected String eventType; | ||
|
|
||
| /** | ||
| * A localized message intended for the end user. | ||
| * key is language code, value is message. | ||
| * Additional unmapped event-specific fields. | ||
| */ | ||
| @JsonProperty("reason_user") | ||
| protected Map<String, String> reasonUser; | ||
|
|
||
| @JsonIgnore | ||
| protected Map<String, Object> attributes = new HashMap<>(); | ||
|
|
||
| public SsfEvent(String eventType) { | ||
| this.eventType = eventType; | ||
|
|
||
| // use the simple class name as the default alias | ||
| this.alias = getClass().getSimpleName(); | ||
| } | ||
|
|
||
| public SubjectId getSubjectId() { | ||
| return subjectId; | ||
| } | ||
|
|
||
| public Long getEventTimestamp() { | ||
| return eventTimestamp; | ||
| } | ||
|
|
||
| public void setEventTimestamp(long eventTimestamp) { | ||
| this.eventTimestamp = eventTimestamp; | ||
| } | ||
|
|
||
| public InitiatingEntity getInitiatingEntity() { | ||
| return initiatingEntity; | ||
| } | ||
|
|
||
| public void setInitiatingEntity(InitiatingEntity initiatingEntity) { | ||
| this.initiatingEntity = initiatingEntity; | ||
| } | ||
|
|
||
| public Map<String, String> getReasonAdmin() { | ||
| return reasonAdmin; | ||
| this(eventType, null); | ||
| } | ||
|
|
||
| public void setReasonAdmin(Map<String, String> reasonAdmin) { | ||
| this.reasonAdmin = reasonAdmin; | ||
| } | ||
|
|
||
| public Map<String, String> getReasonUser() { | ||
| return reasonUser; | ||
| } | ||
|
|
||
| public void setReasonUser(Map<String, String> reasonUser) { | ||
| this.reasonUser = reasonUser; | ||
| public SsfEvent(String eventType, String alias) { | ||
| this.eventType = eventType; | ||
| // use the simple class name as the default alias | ||
| this.alias = alias == null ? getClass().getSimpleName() : alias; | ||
| } | ||
|
|
||
| public String getEventType() { | ||
|
|
@@ -141,13 +79,11 @@ public void setAttributeValue(String key, Object value) { | |
| if (declaredJsonPropertyNames(getClass()).contains(key)) { | ||
| throw new IllegalArgumentException( | ||
| "Custom attribute key '" + key + "' collides with a declared @JsonProperty on " | ||
| + getClass().getName()); | ||
| + getClass().getName()); | ||
| } | ||
| attributes.put(key, value); | ||
| } | ||
|
|
||
| private static final ConcurrentMap<Class<?>, Set<String>> DECLARED_JSON_PROPERTIES = new ConcurrentHashMap<>(); | ||
|
|
||
| private static Set<String> declaredJsonPropertyNames(Class<?> type) { | ||
| return DECLARED_JSON_PROPERTIES.computeIfAbsent(type, t -> { | ||
| Set<String> names = new HashSet<>(); | ||
|
|
@@ -167,10 +103,6 @@ public void setEventType(String eventType) { | |
| this.eventType = eventType; | ||
| } | ||
|
|
||
| public void setSubjectId(SubjectId subjectId) { | ||
| this.subjectId = subjectId; | ||
| } | ||
|
|
||
| public String getAlias() { | ||
| return alias; | ||
| } | ||
|
|
@@ -202,4 +134,72 @@ public void setAlias(String alias) { | |
| public void validate() { | ||
| // no-op — overridden by event subclasses that have spec-required fields | ||
| } | ||
|
|
||
| @Override | ||
| public String toString() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A subclass that overrides
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd keep toString() overridable since custom/extension events may legitimately want to fully control their rendering rather than go through appendFields. |
||
| Map<String, Object> fields = new LinkedHashMap<>(); | ||
| appendFields(fields); | ||
| if (attributes != null && !attributes.isEmpty()) { | ||
| Map<String, Object> renderedAttributes = new LinkedHashMap<>(); | ||
| for (var entry : attributes.entrySet()) { | ||
|
thomasdarimont marked this conversation as resolved.
|
||
| if (entry.getValue() != null) { | ||
| renderedAttributes.put(entry.getKey(), entry.getValue()); | ||
| } | ||
| } | ||
| if (!renderedAttributes.isEmpty()) { | ||
| fields.putIfAbsent("attributes", renderedAttributes); | ||
| } | ||
| } | ||
| StringJoiner rendered = new StringJoiner(", "); | ||
| for (var entry : fields.entrySet()) { | ||
| Object value = entry.getValue(); | ||
| if (value == null) { | ||
| continue; | ||
| } | ||
| rendered.add(entry.getKey() + "=" + render(value)); | ||
| } | ||
| String name = alias != null ? alias : getClass().getSimpleName(); | ||
| return rendered.length() == 0 ? name : name + "{" + rendered + "}"; | ||
| } | ||
|
|
||
| /** | ||
| * Renders a field value for {@link #toString()}, quoting {@link String} | ||
| * values at every nesting level so entries inside maps (extension | ||
| * {@link #attributes}, {@code reason_admin} / {@code reason_user}) read the | ||
| * same as top-level fields. Values originate from JSON payloads, which | ||
| * cannot form cycles, so the recursion is bounded. | ||
| */ | ||
| private static String render(Object value) { | ||
| if (value instanceof String) { | ||
| return "'" + value + '\''; | ||
| } | ||
| if (value instanceof Map<?, ?> map) { | ||
| StringJoiner joiner = new StringJoiner(", ", "{", "}"); | ||
| for (var entry : map.entrySet()) { | ||
| joiner.add(entry.getKey() + "=" + render(entry.getValue())); | ||
| } | ||
| return joiner.toString(); | ||
| } | ||
| if (value instanceof Iterable<?> iterable) { | ||
| StringJoiner joiner = new StringJoiner(", ", "[", "]"); | ||
| for (var element : iterable) { | ||
| joiner.add(render(element)); | ||
| } | ||
| return joiner.toString(); | ||
| } | ||
| return String.valueOf(value); | ||
| } | ||
|
|
||
| /** | ||
| * Contributes the fields of this level of the event hierarchy to the | ||
| * {@link #toString()} output; insertion order is the render order. | ||
| * Subclasses override this (calling {@code super.appendFields(fields)} first) | ||
| * instead of {@code toString()} itself. Values may be put unconditionally — | ||
| * {@code null} entries are filtered and {@link String} values quoted centrally | ||
| * at every nesting level when rendering, and the extension {@link #attributes} | ||
| * map is appended automatically when non-empty. | ||
| */ | ||
| protected void appendFields(Map<String, Object> fields) { | ||
| fields.put("eventType", eventType); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Two things here:
GenericSsfEventextendsSsfEventdirectly, unknown event types now lose typed subject parsing. Previouslysubjectwent throughSubjectIdJsonDeserializerinto a typedSubjectId; now it lands inattributesas a rawLinkedHashMapandgetSubjectId()is gone entirely. Nothing onmainreads it today (SubjectSubscriptionFilteruses the token's subject), but it is a capability regression for extension/unknown events.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is ctor is intended to be used by custom implementations that don't want to implement their own event classes.
As I mentioned in #50793 (comment) the subject attribute of an event is not used by modern events and exists only for backwards compatbility with implementations that used the older SSE CAEP structures (such as Apple).