Skip to content

Emit RISC account-disabled/account-enabled SSF events - #51318

Open
ssilvert wants to merge 8 commits into
keycloak:mainfrom
ssilvert:issue-48900-ssf-enabled-disabled-events
Open

Emit RISC account-disabled/account-enabled SSF events#51318
ssilvert wants to merge 8 commits into
keycloak:mainfrom
ssilvert:issue-48900-ssf-enabled-disabled-events

Conversation

@ssilvert

Copy link
Copy Markdown
Contributor

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.

  • UserResource.updateUser now captures the enabled-state transition and
    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.
  • USER_DISABLED_BY_PERMANENT_LOCKOUT is mapped directly to
    RiscAccountDisabled with reason=disabled-by-bruteforce.
  • initiating_entity is POLICY for brute-force lockout and ADMIN for the
    admin path — not USER, since neither is an end-user action.
  • Fixes a related pre-existing gap: admin-triggered SSF events (logout-all-
    sessions, credential-change-by-admin, and now these two) were bypassing
    the receiver's ssf.emitOnlyEvents gate, unlike the user-event path.
  • New RiscEvent/RiscAccountDisabled/RiscAccountEnabled classes, registered
    as emittable + natively emitted in DefaultSsfEventProviderFactory.

Known gap, filed separately: SCIM active writes and AD federation sync
disable 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

Implements  keycloak#48900

Signed-off-by: Stan Silvert <ssilvert@redhat.com>
Assisted-By: Claude Sonnet 5
@ssilvert
ssilvert requested a review from a team as a code owner July 30, 2026 18:44
Copilot AI review requested due to automatic review settings July 30, 2026 18:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Copilot AI review requested due to automatic review settings July 30, 2026 23:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.

Signed-off-by: Stan Silvert <ssilvert@redhat.com>
Copilot AI review requested due to automatic review settings July 30, 2026 23:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.

…-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
Copilot AI review requested due to automatic review settings July 31, 2026 11:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copilot AI review requested due to automatic review settings July 31, 2026 12:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 an account-disabled SET with the brute-force reason and policy initiator, 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
Copilot AI review requested due to automatic review settings July 31, 2026 13:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 DefaultBruteForceProtector and verifies delivery. Add an end-to-end permanent-lockout case asserting account-disabled, disabled-by-bruteforce, and POLICY so 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
Copilot AI review requested due to automatic review settings July 31, 2026 17:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-DIAG instrumentation (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());

Copilot AI review requested due to automatic review settings July 31, 2026 18:02
@ssilvert
ssilvert force-pushed the issue-48900-ssf-enabled-disabled-events branch from df8d133 to b50681d Compare July 31, 2026 18:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copilot AI review requested due to automatic review settings July 31, 2026 18:54
@ssilvert
ssilvert force-pushed the issue-48900-ssf-enabled-disabled-events branch from b50681d to c6ed524 Compare July 31, 2026 18:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 reason to hijacking or bulk-account; unlike CAEP credential_type, it does not permit mutually agreed extension values. Emitting disabled-by-admin/disabled-by-bruteforce therefore makes these advertised RISC events non-conformant—omit reason for these causes or carry them in a namespaced extension field, and reserve reason for 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants