Emit RISC account-disabled/account-enabled SSF events - #51318
Conversation
Implements keycloak#48900 Signed-off-by: Stan Silvert <ssilvert@redhat.com> Assisted-By: Claude Sonnet 5
There was a problem hiding this comment.
Pull request overview
Adds RISC account-enabled/account-disabled SSF signals for administrative state changes and permanent brute-force lockouts.
Changes:
- Introduces and registers RISC account-state event types.
- Maps admin and brute-force events to RISC SETs.
- Applies emit-only filtering to admin-generated events and adds documentation/tests.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
SecurityEventTokenMapperTest.java |
Tests RISC event mapping. |
SsfTransmitterEventListener.java |
Applies emit-only filtering to admin events. |
SecurityEventTokenMapper.java |
Maps account-state transitions to RISC events. |
RiscEvent.java |
Adds the RISC event base class. |
RiscAccountEnabled.java |
Defines account-enabled events. |
RiscAccountDisabled.java |
Defines account-disabled events and reasons. |
DefaultSsfEventProviderFactory.java |
Registers RISC event types. |
UserResource.java |
Records enabled-state transitions in admin events. |
Details.java |
Adds enabled-state detail keys. |
ssf-support.adoc |
Documents RISC account-state support. |
Signed-off-by: Stan Silvert <ssilvert@redhat.com>
Signed-off-by: Stan Silvert <ssilvert@redhat.com>
…-start race Remove the unused CaepEvent import spotless flagged, and prime SsfTransmitterAccountStateEventTests' first admin-event SSF lookup with a throwaway disable/re-enable before the real assertions — CI showed only the very first such call on a freshly started test server transiently sees no streams. Signed-off-by: Stan Silvert <ssilvert@redhat.com> Assisted-by: Claude Sonnet 5
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (1)
ssf/tests/base/src/test/java/org/keycloak/tests/ssf/transmitter/SsfTransmitterAccountStateEventTests.java:143
- The warm-up updates enqueue their pushes asynchronously, so
pushes.clear()does not wait for either outbox row to be delivered. A delayed warm-up account-disabled/enabled SET can therefore satisfy a later positive assertion or make either no-push test fail; wait until the warm-up account-enabled SET has actually arrived (or otherwise await an empty outbox) before clearing the queue.
setUserEnabled(false);
setUserEnabled(true);
pushes.clear();
The prior fix cleared the pushes queue immediately after the warm-up disable/re-enable cycle, but delivery is async (outbox drainer) — its pushes arrived late and leaked into the next test's assertions. Poll for each one instead of clearing right away. Signed-off-by: Stan Silvert <ssilvert@redhat.com> Assisted-by: Claude Sonnet 5
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (1)
ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/event/SecurityEventTokenMapper.java:839
- The permanent-lockout trigger is only covered with a manually constructed
Event, so the asynchronous brute-force transaction, listener dispatch, and resulting push are never exercised. Add an integration test that causes a real permanent lockout and asserts anaccount-disabledSET with the brute-force reason andpolicyinitiator, matching the end-to-end coverage added for the admin trigger.
case USER_DISABLED_BY_PERMANENT_LOCKOUT ->
generateAccountDisabledEvent(event, adminEvent, stream, RiscAccountDisabled.REASON_BRUTE_FORCE);
Log the realm and raw candidate client list (enabled, ssf.enabled, streamId) on every findStreamsForSsfReceiverClients() call, to pin down why it intermittently misses a just-created stream on the admin-event path. Remove once root-caused. Signed-off-by: Stan Silvert <ssilvert@redhat.com> Assisted-by: Claude Sonnet 5
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (3)
ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/stream/storage/client/ClientStreamStore.java:363
- This WARN runs on every convertible user/admin event and eagerly formats every receiver candidate, creating production log noise and per-event allocation for a temporary CI diagnostic. Remove it before merging (or move targeted diagnostics behind a debug/rate-limited guard).
log.warnf("SSF-DIAG findStreamsForSsfReceiverClients: realm=%s(%s) rawCandidates=%s",
ssf/tests/base/src/test/java/org/keycloak/tests/ssf/transmitter/SsfTransmitterAccountStateEventTests.java:150
- These ignored poll results make the exact first-event loss described above an accepted outcome: after priming, every test can pass even though a newly created production stream drops its first event. The first delivery should remain asserted and the lookup race should be fixed rather than hidden by warm-up traffic.
setUserEnabled(false);
pushes.poll(PUSH_WAIT_SECONDS, TimeUnit.SECONDS);
setUserEnabled(true);
pushes.poll(PUSH_WAIT_SECONDS, TimeUnit.SECONDS);
ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/event/SecurityEventTokenMapper.java:702
- The brute-force path is covered only by constructing this event directly in the mapper unit test; no SSF integration test triggers
DefaultBruteForceProtectorand verifies delivery. Add an end-to-end permanent-lockout case assertingaccount-disabled,disabled-by-bruteforce, andPOLICYso the producer/listener boundary is covered like the admin path.
case USER_DISABLED_BY_PERMANENT_LOCKOUT -> true;
The stream-lookup diagnostics show the receiver's stream present and stable, but the failing test's real disable event leaves no trace at all — it's being dropped upstream of that lookup. Add diagnostics to onEvent's shouldIgnoreEvent/canConvert gates and to generateSecurityEventTokensForAdminEvent's two silent continues (eventUser null, shouldDispatchForUser false) to find which one. Signed-off-by: Stan Silvert <ssilvert@redhat.com> Assisted-by: Claude Sonnet 5
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (3)
ssf/tests/base/src/test/java/org/keycloak/tests/ssf/transmitter/SsfTransmitterAccountStateEventTests.java:150
- This warm-up hides the actual delivery defect: when the first post-creation lookup returns no streams, the account-state event is dropped without retry, and consuming an unasserted throwaway transition merely lets the test pass. Fix or explicitly synchronize stream visibility before exercising delivery, then remove this discard cycle so the test verifies the first real event—the behavior production callers receive.
setUserEnabled(false);
pushes.poll(PUSH_WAIT_SECONDS, TimeUnit.SECONDS);
setUserEnabled(true);
pushes.poll(PUSH_WAIT_SECONDS, TimeUnit.SECONDS);
ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/stream/storage/client/ClientStreamStore.java:363
- This temporary diagnostic materializes every matching receiver, builds identifiers/configuration strings, and emits them at WARN on every mapped SSF event. Remove it before merging; otherwise routine event delivery creates noisy logs and avoidable per-event allocation while exposing receiver and stream identifiers.
// TEMPORARY DIAGNOSTIC — investigating an intermittent CI failure where
// this returns empty for a client whose stream was just created
// (keycloak/keycloak#51318). Remove once root-caused.
List<ClientModel> rawCandidates = session.clients()
.searchClientsByAttributes(realm, attributes, null, null)
.toList();
log.warnf("SSF-DIAG findStreamsForSsfReceiverClients: realm=%s(%s) rawCandidates=%s",
realm.getName(), realm.getId(),
rawCandidates.stream()
.map(c -> c.getClientId() + "[enabled=" + c.isEnabled()
+ ",ssf.enabled=" + c.getAttribute(SSF_ENABLED_KEY)
+ ",streamId=" + c.getAttribute(SSF_STREAM_ID_KEY) + "]")
.toList());
return rawCandidates.stream()
ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/event/SsfTransmitterEventListener.java:323
- The unconditional WARN records every admin event before filtering and serializes its full details map, causing log flooding and unnecessary exposure of admin-event metadata in normal production traffic. Remove the temporary
SSF-DIAGinstrumentation (including the other added WARN branches in this method), or keep narrowly needed diagnostics behind DEBUG/TRACE without dumping details.
log.warnf("SSF-DIAG onEvent: canConvert=%s id=%s operationType=%s resourceType=%s resourcePath=%s details=%s",
canConvert, adminEvent.getId(), adminEvent.getOperationType(), adminEvent.getResourceType(),
adminEvent.getResourcePath(), adminEvent.getDetails());
df8d133 to
b50681d
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (3)
ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/event/SsfTransmitterEventListener.java:324
- This unconditional WARN executes for every admin event in an SSF-enabled realm, including ordinary non-convertible updates, and serializes the full details map into production logs. Remove this temporary diagnostic and keep the predicate as the original direct guard.
boolean canConvert = transmitter.securityEventTokenMapper().canConvert(adminEvent);
log.warnf("SSF-DIAG onEvent: canConvert=%s id=%s operationType=%s resourceType=%s resourcePath=%s details=%s",
canConvert, adminEvent.getId(), adminEvent.getOperationType(), adminEvent.getResourceType(),
adminEvent.getResourcePath(), adminEvent.getDetails());
ssf/tests/base/src/test/java/org/keycloak/tests/ssf/transmitter/SsfTransmitterAccountStateEventTests.java:137
- This warm-up deliberately discards the first post-stream-creation events, so the integration tests can pass while the exact production workflow under test loses its first account-state notification. Fix the stream-visibility race and assert delivery of the first transition instead of priming around it.
// Observed in CI: the very first admin-event-triggered SSF lookup
// (StreamService.findStreamsForSsfReceiverClients, via the admin-event
// path) against a freshly started per-class test server can transiently
// see no streams — likely a client-attribute-search cache/index warm-up
// race, since every subsequent call in the same server instance (i.e.
ssf/transmitter/src/main/java/org/keycloak/ssf/transmitter/stream/storage/client/ClientStreamStore.java:363
- This path runs for every mappable SSF event, so eagerly materializing all receiver clients and logging their IDs/configuration at WARN will create persistent log noise and allocation overhead in production. Remove the temporary diagnostic before merging.
// TEMPORARY DIAGNOSTIC — investigating an intermittent CI failure where
// this returns empty for a client whose stream was just created
// (keycloak/keycloak#51318). Remove once root-caused.
List<ClientModel> rawCandidates = session.clients()
.searchClientsByAttributes(realm, attributes, null, null)
setEmitOnlyEvents() wrote a null attribute value to clear the receiver's ssf.emitOnlyEvents, but a null-valued attribute is skipped rather than removed on client update — so the value set by the emit-only test survived and kept suppressing account-disabled in every later test. Write an empty string instead; ClientStreamStore treats blank as absent. Also drop the earlier warm-up cycle (it was based on a mistaken cold-start-race theory; the "No streams found" messages were all from the benign window between stream delete and recreate) and remove the temporary SSF-DIAG diagnostics now that the cause is known. Signed-off-by: Stan Silvert <ssilvert@redhat.com> Assisted-by: Claude Opus 5
b50681d to
c6ed524
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (1)
ssf/core/src/main/java/org/keycloak/ssf/event/risc/RiscAccountDisabled.java:20
- RISC 1.0 §2.3 restricts
reasontohijackingorbulk-account; unlike CAEPcredential_type, it does not permit mutually agreed extension values. Emittingdisabled-by-admin/disabled-by-bruteforcetherefore makes these advertised RISC events non-conformant—omitreasonfor these causes or carry them in a namespaced extension field, and reservereasonfor the standard values.
* RISC enumerates {@code hijacking} and {@code bulk-account} as reasons. Keycloak
* additionally emits reasons describing its own native disablement triggers
* (administrator action, brute-force lockout, ...) that aren't part of the spec's
* list — mirroring how {@link org.keycloak.ssf.event.caep.CaepCredentialChange#getCredentialType()}
* accepts values beyond its documented set. Receivers that don't recognize a given
Implements #48900
Adds RiscAccountDisabled/RiscAccountEnabled (ssf/core), wired into the SSF
transmitter for two triggers: an admin enabling/disabling a user via the
Admin REST API, and brute-force permanent lockout.
attaches it to the admin event as details (Details.PREVIOUS_ENABLED /
UPDATED_ENABLED), which SecurityEventTokenMapper reads via the new
isEnabledStateChangeAdminEvent gate on the generic "update user" path.
RiscAccountDisabled with reason=disabled-by-bruteforce.
admin path — not USER, since neither is an end-user action.
sessions, credential-change-by-admin, and now these two) were bypassing
the receiver's ssf.emitOnlyEvents gate, unlike the user-event path.
as emittable + natively emitted in DefaultSsfEventProviderFactory.
Known gap, filed separately: SCIM
activewrites and AD federation syncdisable users by calling UserModel.setEnabled() directly, bypassing both
trigger points above, so they don't emit these events yet. See #51317
Assisted-By: Claude Sonnet 5