mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-26 13:50:48 +00:00
Add Shared Signals Framework Transmitter capability (#48901)
Adds Shared Signals Framework support to Keycloak in the **SSF Transmitter** role: Keycloak signs Security Event Tokens (SETs, RFC 8417) describing realm/user/session/credential events and delivers them to OAuth clients registered as **SSF Receivers**, either by HTTP PUSH (RFC 8935) or HTTP POLL (RFC 8936). Targets the OpenID Shared Signals Framework 1.0 (Final) specification plus the CAEP Interoperability Profile 1.0. Ships the legacy SSE CAEP profile alongside for Apple Business Manager / Apple School Manager interop, since Apple device-fleet enrolment is a concrete drive-use case. Gated behind \`Profile.Feature.SSF\` experimental, opt-in. Issue #43614 originally proposed SSF *Receiver* support (Keycloak ingesting SETs from upstream IdPs / risk engines). After exploring both sides, we're shipping the **Transmitter** first (see #48254) because it covers the strongest community asks (federate Keycloak events to downstream SaaS, Apple device fleet revoke flow) and lets us validate the SSF data-plane against real receivers before designing the harder "action mapping" question on the Receiver side. Receiver support remains on the roadmap and is tracked separately via #43614. **In:** - Compliance with SSF 1.0, CAEP 1.0, RISC 1.0, RFC 8935, RFC 8936, RFC 9493, RFC 8417 - SSF Transmitter support (Keycloak Realm can act as a SSF Transmitter) - SSF Stream management (CRUD, status, verification) - SSF Subjects management (subjects) - SET delivery via HTTP PUSH (RFC 8935) and HTTP POLL (RFC 8936) with POLL in a return-immediately form - SSF events temporarily stored in durable outbox with cluster-aware drainer and exponential backoff - SSF Receivers managed as OIDC Clients with client credentials grant or auth code grant (currently only one stream per client) - Support for SSF Stream, CAEP 1.0 and RISC 1.0 events (custom events via SPI) - CAEP credential-change / session-revoked / (device-compliance-change) event mapping from native Keycloak events - Support for RFC 9493 Subject Identifiers for Security Event Tokens - Support for SSF Receiver subject event subscription with subject selection (per-user / per-orgssf.notify.<clientId>attribute, support fordefault_subjectspolicy (ALL, NONE)) - Support for Synthetic event emittance via REST endpoint for non-Keycloak-native event sources (external IAM solution) - Per-receiver "Emit-only events" gate to suppress auto-emit per event type per receiver - Support for legacy SSE CAEP profile for Apple Business Manager / Apple School Manager interop (verified) - Per-realm SSF admin REST + Admin UI for SSF-enabled clients (Receiver / Stream / Subjects / Events) - Prometheus metrics (dispatcher, drainer, poll, verification, outbox depth, delivery metrics) **Out (tracked as separate follow-up issues):** - SSF Receiver role for Keycloak (ingestion of SETs) - POLL long-polling (\`returnImmediately=false\` honoured) - Dedicated SSF signing key (separate from realm OIDC signing key) - Chunked HELD release for very large backlogs - Performance characterization + security review - Formal interop matrix (caep.dev, ABM) - [X] All code gated behind \`Profile.Feature.SSF\` (experimental, off by default) - [X] Per-realm \`ssf.transmitterEnabled\` toggle; per-client \`ssf.enabled\` toggle - [X] SSF event listener registered as global (not user-toggleable per realm) - [X] Receiver-facing endpoints conformant with SSF 1.0 - [X] CAEP credential-change / session-revoked / device-compliance mapping pass interop testing against \`caep.dev\` - [X] SSE CAEP profile narrowed shape works with Apple Business Manager - [X] Integration test coverage for the dispatch / outbox / push / poll pipeline (100+ tests) - [X] Prometheus metrics exposed under \`keycloak_ssf_*\` - [X] Design notes published Fixes #48901 This PR was partially co-authored with Claude AI Signed-off-by: Thomas Darimont <thomas.darimont@googlemail.com>
This commit is contained in:
committed by
Pedro Igor
parent
e743c350c4
commit
a2275c1899
@@ -1257,6 +1257,27 @@ jobs:
|
||||
- name: Run tests
|
||||
run: ./mvnw package -f scim/tests/pom.xml
|
||||
|
||||
ssf-integration-tests:
|
||||
name: SSF IT
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- id: integration-test-setup
|
||||
name: Integration test setup
|
||||
uses: ./.github/actions/integration-test-setup
|
||||
|
||||
# This step is necessary because test/clustering requires building a new Keycloak image built from tar.gz
|
||||
# file that is not part of m2-keycloak.tzts archive
|
||||
- name: Build tar keycloak-quarkus-dist
|
||||
run: ./mvnw package -pl quarkus/server/,quarkus/dist/
|
||||
|
||||
- name: Run tests
|
||||
run: ./mvnw package -f ssf/tests/pom.xml
|
||||
|
||||
authzen-integration-tests:
|
||||
name: AuthZen IT
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -161,6 +161,8 @@ public class Profile {
|
||||
|
||||
DB_TIDB("TiDB database type", Type.EXPERIMENTAL),
|
||||
|
||||
SSF("Shared Signals Framework", Type.EXPERIMENTAL),
|
||||
|
||||
HTTP_OPTIMIZED_SERIALIZERS("Optimized JSON serializers for better performance of the HTTP layer", Type.PREVIEW),
|
||||
|
||||
OPENAPI("OpenAPI specification served at runtime", Type.EXPERIMENTAL, CLIENT_ADMIN_API_V2),
|
||||
|
||||
@@ -58,8 +58,19 @@ public class KeycloakOpenAPI {
|
||||
public static final String USERS = "Users";
|
||||
public static final String ORGANIZATIONS = "Organizations";
|
||||
public static final String WORKFLOWS = "Workflows";
|
||||
public static final String SSF = "SSF";
|
||||
private Tags() { }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static class Ssf {
|
||||
|
||||
private Ssf() { }
|
||||
|
||||
public static class Tags {
|
||||
public static final String TRANSMITTER = "SSF Transmitter";
|
||||
private Tags() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+252
@@ -1751,6 +1751,7 @@ encryptionKeysConfig=Encryption keys config
|
||||
updateClientProfileSuccess=Client profile updated successfully
|
||||
openIDEndpointConfiguration=OpenID Endpoint Configuration
|
||||
oid4vcIssuerMetadata=OpenID4VCI Credential Issuer Metadata
|
||||
ssfConfigurationMetadata=SSF Configuration Metadata
|
||||
prompts.login=Login
|
||||
users=Users
|
||||
keyTabHelp=Location of the Kerberos KeyTab file containing the credentials of the server principal. For example, /etc/krb5.keytab
|
||||
@@ -2930,6 +2931,58 @@ fullName={{givenName}} {{familyName}}
|
||||
deleteConfirm=Are you sure you want to permanently delete the provider '{{provider}}'?
|
||||
compositesRemovedAlertDescription=All the associated roles have been removed
|
||||
aliasHelp=The alias uniquely identifies an identity provider and it is also used to build the redirect uri.
|
||||
ssfTransmitterIssuerHelp=The issuer URL of the SSF Transmitter. This is used to derive the transmitter metadata endpoint.
|
||||
ssfTransmitterAccessToken=Access Token
|
||||
ssfTransmitterAccessTokenHelp=The Transmitter Access Token to perform SSF stream verification.
|
||||
ssfTransmitterToken=Transmitter Token
|
||||
ssfTransmitterTokenHelp=The token used to authenticate with the SSF Transmitter.
|
||||
ssfTransmitterTokenType=Token Type
|
||||
ssfTransmitterTokenTypeHelp=The type of token to use for authenticating with the SSF Transmitter.
|
||||
ssfTransmitterTokenType.accessToken=Access Token
|
||||
ssfTransmitterAuthMethod=Transmitter Authentication
|
||||
ssfTransmitterAuthMethodHelp=How to authenticate with the SSF Transmitter. Use 'Static Token' to provide a pre-configured bearer token, or 'Client Credentials' to obtain tokens dynamically using the OAuth2 client_credentials grant.
|
||||
ssfTransmitterAuthMethod.staticToken=Static Token
|
||||
ssfTransmitterAuthMethod.clientCredentials=Client Credentials
|
||||
ssfTokenUrlHelp=The token endpoint URL of the authorization server associated with the SSF Transmitter, used to obtain access tokens via client_credentials grant.
|
||||
ssfScope=Scope
|
||||
ssfScopeHelp=Space-separated list of OAuth2 scopes to request when obtaining access tokens via client_credentials grant. Leave blank to use the transmitter's default scopes.
|
||||
transmitterMetadataUrl=Metadata URL
|
||||
transmitterMetadataUrlHelp=The SSF Transmitter metadata url, e.g. /.well-known/ssf-configuration. Leave blank to derive from issuer URL.
|
||||
ssfStreamId=Stream ID
|
||||
ssfStreamIdHelp=ID of the SSF stream registered with the Transmitter.
|
||||
ssfStreamDescription=Description
|
||||
ssfStreamDescriptionHelp=Human-readable description supplied by the receiver when it registered the SSF stream.
|
||||
ssfStreamCreatedAt=Created
|
||||
ssfStreamCreatedAtHelp=Timestamp at which the receiver originally registered this SSF stream with the transmitter.
|
||||
ssfStreamUpdatedAt=Last modified
|
||||
ssfStreamUpdatedAtHelp=Timestamp of the most recent change to this SSF stream, including status and configuration updates.
|
||||
ssfStreamLastVerifiedAt=Last verified
|
||||
ssfStreamLastVerifiedAtHelp=Timestamp of the most recent verification event dispatched to this stream — set on both receiver-initiated verification requests and admin-initiated verifications from this tab. Empty if the stream has never been verified.
|
||||
ssfStreamSettings=Stream Settings
|
||||
ssfDescription=Description
|
||||
ssfDescriptionHelp=Free-form notes describing this SSF Receiver — e.g. which downstream system it represents, who owns it, what events it consumes. Operator-facing only; not exposed on the receiver-facing wire.
|
||||
ssfStreamAudience=Audience
|
||||
ssfStreamAudienceHelp=Audience URI stamped on Security Event Tokens delivered to this receiver. If empty the transmitter falls back to a generated value of the form clientId/streamId so a receiver that registers more than one stream over time can disambiguate them on the wire.
|
||||
ssfSupportedEvents=Supported Events
|
||||
ssfSupportedEventsHelp=The SSF event types that this receiver supports (e.g. CaepCredentialChange, CaepSessionRevoked).
|
||||
ssfNativelyEmittedBadge=built-in
|
||||
ssfEmitOnlyEvents=Emit-only events
|
||||
ssfEmitOnlyEventsHelp=Subset of Supported events that Keycloak should NOT auto-emit from native event listeners. Events listed here still travel over the wire when fired explicitly via the synthetic emit endpoint, but Keycloak's automatic mapping skips them. Use this when an event type should reach the receiver only on demand. Empty = every supported event auto-emits (default).
|
||||
ssfEventsDelivered=Events Delivered
|
||||
ssfEventsDeliveredHelp=The set of events that the Transmitter will emit to the stream.
|
||||
ssfEventsDeliveredEmpty=No events are currently being delivered. The delivered set is the intersection of the events the receiver requested and the events it supports.
|
||||
ssfEventsRequested=Events Requested
|
||||
ssfEventsRequestedHelp=The set of event types the receiver asked for when it registered this stream.
|
||||
ssfEventsRequestedEmpty=The receiver did not request any events when it registered the stream.
|
||||
ssfStreamIdEmpty=No SSF stream registered for this client yet.
|
||||
ssfDeliveryMethod=Delivery Method
|
||||
ssfDeliveryMethodHelp=The delivery method used to receive SSF events from the Transmitter.
|
||||
ssfDeliveryMethod.push=Push
|
||||
ssfPushAuthorizationHeader=Push Authorization Header
|
||||
ssfPushAuthorizationHeaderHelp='Authorization' header value expected to be sent by SSF Transmitters when Push delivery via HTTP is used.
|
||||
ssfTriggerVerification=Trigger Verification
|
||||
ssfTriggerVerificationSuccess=Stream verification triggered successfully.
|
||||
ssfTriggerVerificationError=Failed to trigger stream verification: {{error}}
|
||||
selectRealm=Select realm
|
||||
roleNameLdapAttribute=Role name LDAP attribute
|
||||
javaKeystore=java-keystore
|
||||
@@ -3179,6 +3232,205 @@ lightweightAccessToken=Always use lightweight access token
|
||||
lightweightAccessTokenHelp=If it is On, lightweight access tokens are always used. If it is Off, they are not used by default, but it is still possible to enable them with client policy executor.
|
||||
supportJwtClaimInIntrospectionResponse=Support JWT claim in Introspection Response
|
||||
supportJwtClaimInIntrospectionResponseHelp=If it is On, introspection requests which use the header 'Accept: application/jwt' will also contain a claim named "jwt" with the claims of the introspection result encoded as JWT access token.
|
||||
ssf=SSF
|
||||
ssfTransmitterEnabled=SSF Transmitter
|
||||
ssfTransmitterEnabledHelp=If enabled, this realm acts as a Shared Signals Framework (SSF) Transmitter and exposes the SSF Configuration Metadata endpoint. Disabling for a realm with active streams will delete queued events for those receivers on save; pause or delete the streams first if you want receivers notified via a stream-updated SET before going silent.
|
||||
ssfTransmitterDisableConfirmTitle=Disable SSF Transmitter for this realm?
|
||||
ssfTransmitterDisableConfirmIntro=Disabling the SSF Transmitter feature for this realm has the following effects\:
|
||||
ssfTransmitterDisableConfirmBulletEndpoints=All SSF Transmitter endpoints (metadata, streams, subjects, status, SSE) will return 404 to receivers.
|
||||
ssfTransmitterDisableConfirmBulletEvents=No new user or admin events will be recorded for SSF receivers in this realm.
|
||||
ssfTransmitterDisableConfirmBulletDelivery=All queued events for receivers in this realm (PENDING and HELD) will be deleted on save. They cannot be recovered.
|
||||
ssfTransmitterDisableConfirmBulletReceivers=Receivers are not notified that streams went silent. They simply stop seeing events.
|
||||
ssfTransmitterDisableConfirmRecommendation=Recommended\: pause or delete the active SSF streams in this realm before saving so receivers get a proper stream-updated notification before delivery stops.
|
||||
ssfTransmitterDisableConfirmContinue=Disable transmitter
|
||||
ssfTransmitterDisableEventsCleared=Queued SSF events for this realm have been deleted.
|
||||
ssfTransmitterDisableEventsClearFailed=Failed to delete queued SSF events for this realm
|
||||
ssfReceiver=SSF Receiver
|
||||
ssfTabReceiver=Receiver
|
||||
ssfTabStream=Stream
|
||||
ssfTabSubjects=Subjects
|
||||
ssfTabEventSearch=Event Search
|
||||
ssfTabEmitEvents=Emit Events
|
||||
ssfPendingEventsHelp=Look up the delivery state of a specific SSF event by its jti, or emit a synthetic event for this receiver from the admin console.
|
||||
ssfPendingLookupJti=Event jti
|
||||
ssfPendingLookupJtiHelp=JWT id of the Security Event Token — returned from the emit action below or taken from transmitter logs.
|
||||
ssfPendingLookupJtiPlaceholder=jti-of-the-event
|
||||
ssfPendingLookup=Lookup
|
||||
ssfPendingLookupNotFound=No pending event found for this jti on this receiver.
|
||||
ssfPendingLookupResult=Result
|
||||
ssfPendingFieldStatus=Status
|
||||
ssfPendingFieldEventType=Event type
|
||||
ssfPendingFieldDeliveryMethod=Delivery method
|
||||
ssfPendingFieldAttempts=Attempts
|
||||
ssfPendingFieldCreatedAt=Created at
|
||||
ssfPendingFieldNextAttemptAt=Next attempt at
|
||||
ssfPendingFieldDeliveredAt=Delivered at
|
||||
ssfPendingFieldLastError=Last error
|
||||
ssfPendingFieldDecodedSet=Security Event Token (decoded)
|
||||
ssfPendingFieldUserId=Resolved user
|
||||
ssfLookupTitle=Look up event
|
||||
ssfLookupTitleHelp=Inspect the delivery state of a specific Security Event Token by its jti. Scoped to this receiver.
|
||||
ssfEmitTitle=Emit synthetic event
|
||||
ssfEmitTitleHelp=Push a synthetic event to this receiver. The transmitter formats the sub_id per the receiver's configured User Subject Format and applies the same subscription filters as native events.
|
||||
ssfEmitEventType=Event type
|
||||
ssfEmitEventTypeHelp=One of the event types this receiver's transmitter can emit. The payload below is deserialized against the event class registered for the selected type.
|
||||
ssfEmitEventTypeSelectPrompt=-- select an event type --
|
||||
ssfEmitEventTypeRequired=Select an event type to emit.
|
||||
ssfEmitSubjectValueHelp=Resolves to a Keycloak user (user-id / user-email / user-username) or organization (org-alias). For user subjects the transmitter builds the sub_id using the receiver's configured User Subject Format so the emitted SET matches the shape of a native event for this receiver; org subjects emit a complex tenant-only subject. Subject-subscription filters and the format-specific fail-loud rules (no email, no organization for +tenant) still apply.
|
||||
ssfEmitSubjectValueRequired=A subject value is required.
|
||||
ssfEmitPayload=Event payload (JSON)
|
||||
ssfEmitPayloadHelp=Event-specific body — must deserialize against the selected event type's class. Leave empty to emit an empty payload (only meaningful for event types with no required fields). Supported placeholders (expanded before JSON parsing): __now__ resolves to the current Unix time in seconds. Usable unquoted for numeric fields, e.g. "event_timestamp": __now__.
|
||||
ssfEmitPayloadInvalidJson=Payload is not valid JSON: {{error}}
|
||||
ssfEmitEvent=Emit event
|
||||
ssfEmitResult=Emitted — status={{status}}, jti={{jti}}
|
||||
ssfEmitResultLookupLink=Look up this event in Event Search
|
||||
ssfReceiverHelp=Transmitter-side policy applied when Keycloak delivers Security Event Tokens to this receiver.
|
||||
ssfSectionGeneral=General
|
||||
ssfSectionGeneralHelp=Identity, display, and stream behaviour for this SSF receiver: profile and description, the audience targeted, which subjects are delivered for, subject identifier format, verification policy, and the grace window applied when receivers remove subjects. Applied whenever a stream is registered or updated for this client.
|
||||
ssfSectionAuthentication=Authentication
|
||||
ssfSectionAuthenticationHelp=Access rules that gate who can manage this receiver's SSF stream and invoke the synthetic event emission endpoint.
|
||||
ssfSectionDelivery=Delivery
|
||||
ssfSectionDeliveryHelp=Which delivery methods this receiver may use and, when push delivery is allowed, the URL prefixes the transmitter is permitted to push Security Event Tokens to.
|
||||
ssfSectionEvents=Events
|
||||
ssfSectionEventsHelp=Which event types this receiver advertises and delivers, age and inactivity constraints that bound the outbox, and whether admins may emit synthetic events for debugging or workflow integration.
|
||||
ssfAllowedDeliveryMethods=Allowed delivery methods
|
||||
ssfAllowedDeliveryMethodsHelp=Which delivery methods this receiver may select when registering a stream. At least one method must be enabled. The receiver picks one of the allowed methods at stream-create time; restricting this list to poll-only forbids the transmitter from making outbound HTTP calls to this receiver entirely.
|
||||
ssfAllowedDeliveryMethods.push=Push delivery
|
||||
ssfAllowedDeliveryMethods.pushHelp=Transmitter POSTs Security Event Tokens to a receiver-supplied URL (RFC 8935). Requires Valid push URLs to be configured below.
|
||||
ssfAllowedDeliveryMethods.poll=Poll delivery
|
||||
ssfAllowedDeliveryMethods.pollHelp=Receiver pulls queued Security Event Tokens from a transmitter-owned poll endpoint (RFC 8936). The endpoint URL is generated by Keycloak; no per-receiver allow-list is needed.
|
||||
ssfValidPushUrls=Valid push URLs
|
||||
ssfValidPushUrlsHelp=Allow-list of URL patterns the receiver may register as its push endpoint. Each entry is matched against the receiver-supplied URL using exact match or trailing-* suffix wildcard (e.g. https://receiver/push/* permits any tenant + stream id under that path). A URL outside the allow-list is rejected with HTTP 400 — this is the receiver's SSRF defence. Bare * is not honoured.
|
||||
ssfValidPushUrls.add=Add valid push URL
|
||||
ssfReceiverEnabled=SSF Receiver
|
||||
ssfReceiverEnabledHelp=If enabled, this client acts as a Shared Signals Framework (SSF) Receiver. Keycloak will transmit security events to this client according to the configured SSF stream.
|
||||
ssfStream=SSF Stream
|
||||
ssfStreamHelp=The stream registered for this receiver and its event delivery settings.
|
||||
ssfStreamNotRegistered=No SSF stream registered yet
|
||||
ssfStreamNotRegisteredHelp=This receiver client has not registered an SSF stream with the transmitter yet. You can create one on its behalf below, or the receiver can create one itself via the SSF Transmitter API.
|
||||
ssfCreateStream=Create stream
|
||||
ssfCreateStreamReset=Clear
|
||||
ssfCreateStreamSuccess=SSF stream created successfully.
|
||||
ssfCreateStreamError=Could not create SSF stream\: {{error}}
|
||||
ssfCreateStreamAudience=Audience
|
||||
ssfCreateStreamAudienceHelp=Audience URI for the new stream — typically the receiver's feed endpoint. Leave blank to derive a default from the client ID and stream ID.
|
||||
ssfCreateStreamDeliveryMethod=Delivery method
|
||||
ssfCreateStreamDeliveryMethodHelp=The SSF delivery method to use for this stream. Push (RFC 8935) sends signed SETs to a receiver-hosted endpoint; Poll (RFC 8936) hosts the endpoint on the transmitter and the receiver pulls SETs on its own schedule. Push requires the receiver to provide its own endpoint URL; Poll uses a transmitter-generated URL returned in the create response.
|
||||
ssfCreateStreamEndpointUrl=Push endpoint URL
|
||||
ssfCreateStreamEndpointUrlHelp=HTTPS URL on the receiver side that the transmitter will POST Security Event Tokens to.
|
||||
ssfCreateStreamEndpointUrlRequired=A push endpoint URL is required.
|
||||
ssfCreateStreamEndpointUrlInvalid=Push endpoint URL must be an absolute http or https URL.
|
||||
ssfCreateStreamEventsRequested=Events requested
|
||||
ssfCreateStreamEventsRequestedHelp=The SSF event types the receiver should be subscribed to. Pick from the events the transmitter is configured to emit; leave empty to use the transmitter's default supported set.
|
||||
ssfCreateStreamDescription=Description
|
||||
ssfCreateStreamDescriptionHelp=Optional human-readable label for the stream, surfaced in this admin UI for operators.
|
||||
ssfStreamAudienceCurrentHelp=The audience currently configured on the registered SSF stream. Updating the Stream Audience field in the SSF Receiver section only affects streams that the receiver registers after the change.
|
||||
ssfStreamPushEndpointUrl=Push endpoint URL
|
||||
ssfStreamPushEndpointUrlHelp=HTTPS URL on the receiver side that the transmitter POSTs Security Event Tokens to. Only shown for streams using the HTTP push delivery method.
|
||||
ssfStreamPollEndpointUrl=Poll endpoint URL
|
||||
ssfStreamPollEndpointUrlHelp=Transmitter-hosted URL the receiver POSTs to in order to pull pending Security Event Tokens (RFC 8936). The receiver authenticates with its own bearer token; no transmitter-side authorization header is configured.
|
||||
ssfStreamPushAuthHeader=Push Authorization header
|
||||
ssfStreamPushAuthHeaderHelp=Value sent in the Authorization request header on every push (for example a bearer token) as is.
|
||||
ssfDeleteStream=Delete Stream
|
||||
ssfDeleteStreamConfirmTitle=Delete SSF stream?
|
||||
ssfDeleteStreamConfirmMessage=The registered SSF stream for this client will be deleted and the receiver will have to re-create it. This cannot be undone. Continue?
|
||||
ssfDeleteStreamSuccess=SSF stream deleted successfully.
|
||||
ssfSubjects=SSF Subjects
|
||||
ssfSubjectsHelp=Manage which users or organizations this receiver gets events about. Use Check to see whether a subject is currently included.
|
||||
ssfSubjectType=Subject type
|
||||
ssfSubjectTypeHelp=How the value below is interpreted when looking up the subject. User options resolve to a Keycloak user by email, ID, or username; Organization (by alias) resolves to a Keycloak organization. The notification preference is stored as the ssf.notify.${clientId} attribute on the resolved entity.
|
||||
ssfSubjectType.userEmail=User (by email)
|
||||
ssfSubjectType.userId=User (by ID)
|
||||
ssfSubjectType.userUsername=User (by username)
|
||||
ssfSubjectType.orgAlias=Organization (by alias)
|
||||
ssfSubjectValue=Subject value
|
||||
ssfSubjectAdd=Add subject
|
||||
ssfSubjectRemove=Remove subject
|
||||
ssfSubjectCheck=Check
|
||||
ssfSubjectStatusLabel=Subject status
|
||||
ssfSubjectValueRequired=Please enter a subject value.
|
||||
ssfSubjectNotFound=Subject not found. Verify the value and subject type.
|
||||
ssfSubjectIsNotified=Events for this subject are delivered to this stream.
|
||||
ssfSubjectIsNotifiedViaOrg=Subject is included via an organization membership for this stream.
|
||||
ssfSubjectIsNotifiedViaOrgNamed=Subject is included via membership in organization '{{org}}' for this stream.
|
||||
ssfSubjectIsImplicitlyIncluded=Subject is implicitly included in event delivery for this stream (default_subjects=ALL and no explicit exclusion).
|
||||
ssfSubjectIsNotNotified=Subject is not included in event delivery for this stream.
|
||||
ssfSubjectAdded=Subject has been added to this stream.
|
||||
ssfSubjectIgnore=Ignore subject
|
||||
ssfSubjectIgnored=Subject has been ignored for this stream.
|
||||
ssfSubjectIsIgnored=Subject is explicitly excluded from event delivery for this stream.
|
||||
ssfSubjectIsIgnoredViaOrg=Subject is excluded via an organization membership for this stream.
|
||||
ssfSubjectIsIgnoredViaOrgNamed=Subject is excluded via membership in organization '{{org}}' for this stream.
|
||||
ssfSubjectRemoved=Subject has been removed from this stream.
|
||||
ssfSubjectActionError=Could not update subject notification preference\: {{error}}
|
||||
ssfDeleteStreamError=Could not delete SSF stream\: {{error}}
|
||||
ssfVerifyStream=Verify
|
||||
ssfVerifyStreamSuccess=Verification event dispatched to the receiver.
|
||||
ssfVerifyStreamError=Could not send verification event\: {{error}}
|
||||
ssfProfile=Profile
|
||||
ssfProfileHelp=The Shared Signals Framework profile to use when transmitting events to this receiver.
|
||||
ssfProfile.SSF_1_0=SSF 1.0
|
||||
ssfProfile.SSE_CAEP=SSE CAEP 1.0
|
||||
ssfCreateStreamProfileHelp=The SSF profile applied to events sent over this stream. Profile is a per-receiver setting, so changing it here also updates the receiver clients profile attribute before the stream is created.
|
||||
ssfUserSubjectFormat=Subject Format
|
||||
ssfUserSubjectFormatHelp=Subject identifier format the transmitter uses for the user portion of SSF Security Event Tokens. Defaults to iss_sub (realm issuer + user ID). Select email to emit the user's email address instead. The +tenant variants wrap the user subject in a complex subject and add a tenant sibling carrying the user's Keycloak organization alias — use this when the receiver expects multi-tenant routing. Events are dropped (with a clear log entry) when the selected format cannot be produced (e.g. email selected but the user has no email, or +tenant selected but the user belongs to no organization).
|
||||
ssfUserSubjectFormat.iss_sub=iss_sub (Issuer and Subject)
|
||||
ssfUserSubjectFormat.email=email
|
||||
ssfUserSubjectFormat.complex.iss_sub+tenant=complex(iss_sub + tenant organization)
|
||||
ssfUserSubjectFormat.complex.email+tenant=complex(email + tenant organization)
|
||||
ssfDefaultSubjects=Default Subjects
|
||||
ssfDefaultSubjectsHelp=Controls whether the transmitter delivers events for all realm users (ALL) or only for users explicitly added via the add-subject endpoint or the Subjects tab (NONE). Receivers can override this per-stream at creation time.
|
||||
ssfDefaultSubjects.ALL=ALL (deliver events for all subjects)
|
||||
ssfDefaultSubjects.NONE=NONE (deliver only for added subjects)
|
||||
ssfAutoNotifyOnLogin=Add subject on login
|
||||
ssfAutoNotifyOnLoginHelp=When enabled and Default Subjects is set to NONE, users that log in via this client are automatically added to the set of subjects this receiver gets events about — equivalent to clicking Add for them on the Subjects tab. Useful for federated scenarios where only users that authenticate through this client should be included in event delivery.
|
||||
ssfRequireServiceAccount=Require service account
|
||||
ssfRequireServiceAccountHelp=When enabled, only the client's own service account can access the SSF transmitter API. This is the default behavior when not explicitly set. Disable for clients that use the authorization code flow where a real user authenticates and the receiver backend uses the resulting token to manage its SSF stream.
|
||||
ssfRequiredRole=Required role
|
||||
ssfRequiredRoleHelp=When set, the authenticated user must hold this role (realm role or client role on this client) to access the SSF transmitter API. Checked in addition to the service account requirement (if enabled) and the scope requirement. Leave empty to skip the role check.
|
||||
ssfAllowEmitEvents=Emit events enabled
|
||||
ssfAllowEmitEventsHelp=When enabled, a service account holding the role configured below can push synthetic SSF events for this receiver via the admin emit endpoint. Use this for environments where Keycloak cannot natively observe upstream events (e.g. credential changes that happen in LDAP). If no role is configured the endpoint is refused. Callers that have permission to manage this client bypass the role check.
|
||||
ssfEmitEventsRole=Required emit events role
|
||||
ssfEmitEventsRoleHelp=Role the calling service account must hold to push synthetic events. Select a realm role or a client role on this receiver. Only applies when "Synthetic events" is enabled.
|
||||
ssfMinVerificationInterval=Min Verification Interval
|
||||
ssfMinVerificationIntervalHelp=Minimum amount of time in seconds that must pass between receiver-initiated verification requests for this client's stream. Subsequent requests within this window are rejected with HTTP 429. Leave empty to use the transmitter-wide default.
|
||||
ssfMaxEventAge=Max event age
|
||||
ssfMaxEventAgeHelp=Maximum age (in seconds) of pending, held, or dead-letter outbox events for this receiver. Older rows are purged by the transmitter's housekeeping pass before the global dead-letter retention window applies. Use a short value for receivers whose events lose relevance fast (e.g. session-revoked). Leave inherited to fall back to the transmitter-wide retention only.
|
||||
ssfInactivityTimeout=Inactivity timeout
|
||||
ssfInactivityTimeoutHelp=SSF 1.0 inactivity_timeout — if no eligible receiver activity (any hit on the stream-management API, or a poll for POLL streams) arrives within this window, the transmitter automatically pauses the stream and sends a stream-updated event. Activity is tracked at a 5-minute granularity (write-coalesced to avoid hammering the client store on busy pollers), so the effective check may fire up to 5 minutes late — pick timeouts comfortably larger than that. Leave empty for no timeout.
|
||||
ssfSubjectRemovalGrace=Subject removal grace
|
||||
ssfSubjectRemovalGraceHelp=SSF 1.0 ‘Malicious Subject Removal’ defense. After a receiver-driven /streams/subjects/remove the transmitter keeps delivering events for the subject for this many seconds, defending against a compromised receiver bearer token silently silencing events for a target. Admin-driven removes always take effect immediately. Leave empty to inherit the transmitter-wide subject-removal-grace-seconds SPI default; set to 0 to opt this receiver out of the grace window even when the transmitter default is positive (e.g. for receivers that legitimately remove subjects when users churn out of their service).
|
||||
ssfAutoVerifyStream=Auto-Verify Stream
|
||||
ssfAutoVerifyStreamHelp=When enabled, the transmitter automatically dispatches a stream-verification SET shortly after the receiver creates a stream. When disabled, the receiver can perform the verification by sending an explicit verification request to the verification endpoint.
|
||||
ssfVerificationDelay=Verification Delay
|
||||
ssfVerificationDelayHelp=Delay in milliseconds before the transmitter sends the verification event after the stream is created. Only applies when Auto-Verify Stream is enabled.
|
||||
ssfStreamStatus=Status
|
||||
ssfStreamStatusHelp=Current status of the SSF stream for this client. Use the action buttons below to change it — those go through the SSF spec-mandated transition path, which dispatches a stream-updated SET to the receiver and aligns the outbox backlog with the new status.
|
||||
ssfStreamStatus.enabled=Enabled
|
||||
ssfStreamStatus.paused=Paused
|
||||
ssfStreamStatus.disabled=Disabled
|
||||
ssfStreamStatusEnable=Enable stream
|
||||
ssfStreamStatusPause=Pause stream
|
||||
ssfStreamStatusDisable=Disable stream
|
||||
ssfStreamStatusUpdateSuccess=Stream status updated. A stream-updated event has been dispatched to the receiver.
|
||||
ssfStreamStatusUpdateError=Could not update stream status\: {{error}}
|
||||
ssfStreamStatusLabel=Stream status
|
||||
ssfStreamIndicator.enabled=Enabled
|
||||
ssfStreamIndicator.paused=Paused
|
||||
ssfStreamIndicator.disabled=Disabled
|
||||
ssfStreamIndicator.registered=Registered
|
||||
ssfStreamIndicator.unregistered=Not registered
|
||||
ssfStreamStatusReason=Status reason
|
||||
ssfStreamStatusReasonHelp=Human-readable reason associated with the current stream status, if one was supplied when the status was last changed (for example by a receiver pausing or disabling the stream).
|
||||
ssfDelivery=Delivery Method
|
||||
ssfDeliveryHelp=The delivery method the transmitter uses for events on this stream. Push (RFC 8935) sends signed SETs to the receiver's endpoint; Poll (RFC 8936) holds events in the transmitter outbox until the receiver pulls them via the transmitter-hosted poll endpoint.
|
||||
ssfDelivery.PUSH=Push
|
||||
ssfDelivery.POLL=Poll
|
||||
ssfDelivery.PULL=Pull
|
||||
ssfPushEndpointConnectTimeout=Push Connect Timeout
|
||||
ssfPushEndpointConnectTimeoutHelp=Connect timeout in milliseconds when delivering SSF events via HTTP push to the receiver's push endpoint.
|
||||
ssfPushEndpointSocketTimeout=Push Socket Timeout
|
||||
ssfPushEndpointSocketTimeoutHelp=Socket (read) timeout in milliseconds when delivering SSF events via HTTP push to the receiver's push endpoint.
|
||||
welcomeTabTitle=Welcome
|
||||
welcomeTo=Welcome to {{realmDisplayInfo}}
|
||||
welcomeText=Keycloak provides user federation, strong authentication, user management, fine-grained authorization, and more. Add authentication to applications and secure services with minimum effort. No need to deal with storing users or authenticating users.
|
||||
|
||||
@@ -50,6 +50,7 @@ import useToggle from "../utils/useToggle";
|
||||
import { AdvancedTab } from "./AdvancedTab";
|
||||
import { ClientSessions } from "./ClientSessions";
|
||||
import { ClientSettings } from "./ClientSettings";
|
||||
import { SsfTab } from "./ssf/SsfTab";
|
||||
import { AuthorizationEvaluate } from "./authorization/AuthorizationEvaluate";
|
||||
import { AuthorizationExport } from "./authorization/AuthorizationExport";
|
||||
import { AuthorizationPermissions } from "./authorization/Permissions";
|
||||
@@ -67,6 +68,7 @@ import {
|
||||
import { ClientParams, ClientTab, toClient } from "./routes/Client";
|
||||
import { toClientRole } from "./routes/ClientRole";
|
||||
import { ClientScopesTab, toClientScopesTab } from "./routes/ClientScopeTab";
|
||||
import { SsfClientTab, toSsfClientTab } from "./routes/ClientSsfTab";
|
||||
import { toClients } from "./routes/Clients";
|
||||
import { toCreateRole } from "./routes/NewRole";
|
||||
import { ClientScopes } from "./scopes/ClientScopes";
|
||||
@@ -194,7 +196,7 @@ export default function ClientDetails() {
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { addAlert, addError } = useAlerts();
|
||||
const { realm } = useRealm();
|
||||
const { realm, realmRepresentation } = useRealm();
|
||||
const { hasAccess } = useAccess();
|
||||
const isFeatureEnabled = useIsFeatureEnabled();
|
||||
|
||||
@@ -225,6 +227,31 @@ export default function ClientDetails() {
|
||||
defaultValue: "client-secret",
|
||||
});
|
||||
|
||||
const ssfEnabled = useWatch({
|
||||
control: form.control,
|
||||
name: convertAttributeNameToForm<FormFields>("attributes.ssf.enabled"),
|
||||
});
|
||||
|
||||
const ssfAllowEmitEvents = useWatch({
|
||||
control: form.control,
|
||||
name: convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.allowEmitEvents",
|
||||
),
|
||||
});
|
||||
const showSsfEmitEventsTab = ssfAllowEmitEvents?.toString() === "true";
|
||||
|
||||
// Gate every SSF surface on three conditions: server feature flag,
|
||||
// realm-level transmitter toggle, and per-client opt-in. Missing any
|
||||
// one means SSF endpoints aren't available — surfacing the tab would
|
||||
// crash on the first API call (the original bug from the review).
|
||||
const isSsfFeatureEnabled = isFeatureEnabled(Feature.Ssf);
|
||||
const isSsfRealmEnabled =
|
||||
realmRepresentation?.attributes?.["ssf.transmitterEnabled"] === "true";
|
||||
const showSsfTab =
|
||||
isSsfFeatureEnabled &&
|
||||
isSsfRealmEnabled &&
|
||||
ssfEnabled?.toString() === "true";
|
||||
|
||||
const [client, setClient] = useState<ClientRepresentation>();
|
||||
|
||||
const loader = async () => {
|
||||
@@ -249,6 +276,7 @@ export default function ClientDetails() {
|
||||
const sessionsTab = useRoutableTab(tab("sessions"));
|
||||
const permissionsTab = useRoutableTab(tab("permissions"));
|
||||
const advancedTab = useRoutableTab(tab("advanced"));
|
||||
const ssfTab = useRoutableTab(tab("ssf"));
|
||||
const eventsTab = useRoutableTab(tab("events"));
|
||||
|
||||
const [activeEventsTab, setActiveEventsTab] = useState("userEvents");
|
||||
@@ -265,6 +293,18 @@ export default function ClientDetails() {
|
||||
clientScopesTabRoute("evaluate"),
|
||||
);
|
||||
|
||||
const ssfTabRoute = (tab: SsfClientTab) =>
|
||||
toSsfClientTab({
|
||||
realm,
|
||||
clientId,
|
||||
tab,
|
||||
});
|
||||
const ssfReceiverTab = useRoutableTab(ssfTabRoute("receiver"));
|
||||
const ssfStreamTab = useRoutableTab(ssfTabRoute("stream"));
|
||||
const ssfSubjectsTab = useRoutableTab(ssfTabRoute("subjects"));
|
||||
const ssfEventSearchTab = useRoutableTab(ssfTabRoute("event-search"));
|
||||
const ssfEmitEventsTab = useRoutableTab(ssfTabRoute("emit-events"));
|
||||
|
||||
const authorizationTabRoute = (tab: AuthorizationTab) =>
|
||||
toAuthorizationTab({
|
||||
realm,
|
||||
@@ -688,6 +728,85 @@ export default function ClientDetails() {
|
||||
>
|
||||
<AdvancedTab save={save} client={client} />
|
||||
</Tab>
|
||||
{client.protocol === "openid-connect" &&
|
||||
!client.publicClient &&
|
||||
showSsfTab && (
|
||||
<Tab
|
||||
id="ssf"
|
||||
data-testid="ssfTab"
|
||||
title={<TabTitleText>{t("ssf")}</TabTitleText>}
|
||||
{...ssfTab}
|
||||
>
|
||||
<RoutableTabs
|
||||
defaultLocation={ssfTabRoute("receiver")}
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
>
|
||||
<Tab
|
||||
id="ssfReceiverTab"
|
||||
data-testid="ssfReceiverTab"
|
||||
title={<TabTitleText>{t("ssfTabReceiver")}</TabTitleText>}
|
||||
{...ssfReceiverTab}
|
||||
>
|
||||
<SsfTab
|
||||
save={save}
|
||||
client={client}
|
||||
activeTab="receiver"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
id="ssfStreamTab"
|
||||
data-testid="ssfStreamTab"
|
||||
title={<TabTitleText>{t("ssfTabStream")}</TabTitleText>}
|
||||
{...ssfStreamTab}
|
||||
>
|
||||
<SsfTab save={save} client={client} activeTab="stream" />
|
||||
</Tab>
|
||||
<Tab
|
||||
id="ssfSubjectsTab"
|
||||
data-testid="ssfSubjectsTab"
|
||||
title={<TabTitleText>{t("ssfTabSubjects")}</TabTitleText>}
|
||||
{...ssfSubjectsTab}
|
||||
>
|
||||
<SsfTab
|
||||
save={save}
|
||||
client={client}
|
||||
activeTab="subjects"
|
||||
/>
|
||||
</Tab>
|
||||
<Tab
|
||||
id="ssfEventSearchTab"
|
||||
data-testid="ssfEventSearchTab"
|
||||
title={
|
||||
<TabTitleText>{t("ssfTabEventSearch")}</TabTitleText>
|
||||
}
|
||||
{...ssfEventSearchTab}
|
||||
>
|
||||
<SsfTab
|
||||
save={save}
|
||||
client={client}
|
||||
activeTab="event-search"
|
||||
/>
|
||||
</Tab>
|
||||
{showSsfEmitEventsTab && (
|
||||
<Tab
|
||||
id="ssfEmitEventsTab"
|
||||
data-testid="ssfEmitEventsTab"
|
||||
title={
|
||||
<TabTitleText>{t("ssfTabEmitEvents")}</TabTitleText>
|
||||
}
|
||||
{...ssfEmitEventsTab}
|
||||
>
|
||||
<SsfTab
|
||||
save={save}
|
||||
client={client}
|
||||
activeTab="emit-events"
|
||||
/>
|
||||
</Tab>
|
||||
)}
|
||||
</RoutableTabs>
|
||||
</Tab>
|
||||
)}
|
||||
{hasAccess("view-events") && (
|
||||
<Tab
|
||||
data-testid="events-tab"
|
||||
|
||||
@@ -19,6 +19,7 @@ import { FormFields } from "../ClientDetails";
|
||||
import { IdentityProviderSelect } from "../../components/identity-provider/IdentityProviderSelect";
|
||||
import { IdentityProviderType } from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation";
|
||||
import { useAccess } from "../../context/access/Access";
|
||||
import { useRealm } from "../../context/realm-context/RealmContext";
|
||||
|
||||
type CapabilityConfigProps = {
|
||||
unWrap?: boolean;
|
||||
@@ -50,7 +51,15 @@ export const CapabilityConfig = ({
|
||||
);
|
||||
const isFeatureEnabled = useIsFeatureEnabled();
|
||||
const { hasSomeAccess } = useAccess();
|
||||
const { realmRepresentation } = useRealm();
|
||||
const showIdentityProviders = hasSomeAccess("view-identity-providers");
|
||||
// Mirror the gate in ClientDetails: only expose the SSF capability
|
||||
// toggle when both the server feature is enabled and the realm has
|
||||
// opted in. Otherwise enabling the toggle would just render a tab
|
||||
// that crashes on its first API call.
|
||||
const showSsfReceiverToggle =
|
||||
isFeatureEnabled(Feature.Ssf) &&
|
||||
realmRepresentation?.attributes?.["ssf.transmitterEnabled"] === "true";
|
||||
return (
|
||||
<FormAccess
|
||||
isHorizontal
|
||||
@@ -106,6 +115,12 @@ export const CapabilityConfig = ({
|
||||
),
|
||||
false,
|
||||
);
|
||||
setValue(
|
||||
convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.enabled",
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
}}
|
||||
aria-label={t("clientAuthentication")}
|
||||
@@ -498,6 +513,16 @@ export const CapabilityConfig = ({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!clientAuthentication && showSsfReceiverToggle && (
|
||||
<DefaultSwitchControl
|
||||
name={convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.enabled",
|
||||
)}
|
||||
label={t("ssfReceiverEnabled")}
|
||||
labelIcon={t("ssfReceiverEnabledHelp")}
|
||||
stringify
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{protocol === "saml" && (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ClientRegistrationRoute } from "./routes/ClientRegistration";
|
||||
import { ClientRoleRoute } from "./routes/ClientRole";
|
||||
import { ClientsRoute, ClientsRouteWithTab } from "./routes/Clients";
|
||||
import { ClientScopesRoute } from "./routes/ClientScopeTab";
|
||||
import { ClientSsfTabRoute } from "./routes/ClientSsfTab";
|
||||
import { CreateInitialAccessTokenRoute } from "./routes/CreateInitialAccessToken";
|
||||
import {
|
||||
DedicatedScopeDetailsRoute,
|
||||
@@ -50,6 +51,7 @@ const routes: AppRouteObject[] = [
|
||||
DedicatedScopeDetailsRoute,
|
||||
DedicatedScopeDetailsWithTabRoute,
|
||||
ClientScopesRoute,
|
||||
ClientSsfTabRoute,
|
||||
ClientRoleRoute,
|
||||
AuthorizationRoute,
|
||||
NewResourceRoute,
|
||||
|
||||
@@ -15,7 +15,8 @@ export type ClientTab =
|
||||
| "serviceAccount"
|
||||
| "permissions"
|
||||
| "sessions"
|
||||
| "events";
|
||||
| "events"
|
||||
| "ssf";
|
||||
|
||||
export type ClientParams = {
|
||||
realm: string;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { lazy } from "react";
|
||||
import type { Path } from "react-router-dom";
|
||||
import { generateEncodedPath } from "../../utils/generateEncodedPath";
|
||||
import type { AppRouteObject } from "../../routes";
|
||||
|
||||
/**
|
||||
* The five sub-tabs of the SSF view on a client. Mirrors the pattern
|
||||
* used by Client Scopes (setup / evaluate) so the SSF view's sub-tabs
|
||||
* are deep-linkable from URLs and bookmarkable per section.
|
||||
*/
|
||||
export type SsfClientTab =
|
||||
| "receiver"
|
||||
| "stream"
|
||||
| "subjects"
|
||||
| "event-search"
|
||||
| "emit-events";
|
||||
|
||||
export type ClientSsfTabParams = {
|
||||
realm: string;
|
||||
clientId: string;
|
||||
tab: SsfClientTab;
|
||||
};
|
||||
|
||||
const ClientDetails = lazy(() => import("../ClientDetails"));
|
||||
|
||||
export const ClientSsfTabRoute: AppRouteObject = {
|
||||
path: "/:realm/clients/:clientId/ssf/:tab",
|
||||
element: <ClientDetails />,
|
||||
handle: {
|
||||
access: "view-clients",
|
||||
breadcrumb: (t) => t("clientSettings"),
|
||||
},
|
||||
};
|
||||
|
||||
export const toSsfClientTab = (params: ClientSsfTabParams): Partial<Path> => ({
|
||||
pathname: generateEncodedPath(ClientSsfTabRoute.path, params),
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
import { fetchWithError } from "@keycloak/keycloak-admin-client";
|
||||
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
|
||||
import { useFetch } from "@keycloak/keycloak-ui-shared";
|
||||
import { PageSection } from "@patternfly/react-core";
|
||||
import { useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { useAdminClient } from "../../admin-client";
|
||||
import { useRealm } from "../../context/realm-context/RealmContext";
|
||||
import { addTrailingSlash, convertAttributeNameToForm } from "../../util";
|
||||
import { getAuthorizationHeaders } from "../../utils/getAuthorizationHeaders";
|
||||
import type { FormFields, SaveOptions } from "../ClientDetails";
|
||||
import type { SsfClientTab } from "../routes/ClientSsfTab";
|
||||
import { EmitEventsTab } from "./tabs/EmitEventsTab";
|
||||
import { EventSearchTab } from "./tabs/EventSearchTab";
|
||||
import { ReceiverTab } from "./tabs/ReceiverTab";
|
||||
import { StreamTab, type SsfClientStream } from "./tabs/StreamTab";
|
||||
import { SubjectsTab } from "./tabs/SubjectsTab";
|
||||
|
||||
const FALLBACK_DEFAULT_SUPPORTED_EVENTS =
|
||||
"CaepCredentialChange,CaepSessionRevoked";
|
||||
|
||||
const isPollDeliveryMethod = (method: string | undefined): boolean =>
|
||||
method === "urn:ietf:rfc:8936" ||
|
||||
method === "https://schemas.openid.net/secevent/risc/delivery-method/poll";
|
||||
|
||||
type SsfConfig = {
|
||||
defaultSupportedEvents?: string[];
|
||||
availableSupportedEvents?: string[];
|
||||
nativelyEmittedEvents?: string[];
|
||||
defaultPushEndpointConnectTimeoutMillis?: number;
|
||||
defaultPushEndpointSocketTimeoutMillis?: number;
|
||||
defaultUserSubjectFormat?: string;
|
||||
};
|
||||
|
||||
const FALLBACK_DEFAULT_PUSH_CONNECT_TIMEOUT_MILLIS = 1000;
|
||||
const FALLBACK_DEFAULT_PUSH_SOCKET_TIMEOUT_MILLIS = 1000;
|
||||
const FALLBACK_DEFAULT_USER_SUBJECT_FORMAT = "iss_sub";
|
||||
|
||||
export type SsfTabProps = {
|
||||
save: (options?: SaveOptions) => void;
|
||||
client: ClientRepresentation;
|
||||
/**
|
||||
* Which sub-tab to render. The parent (ClientDetails) drives this
|
||||
* from the URL via RoutableTabs so each section is a deep-linkable
|
||||
* page on its own.
|
||||
*/
|
||||
activeTab: SsfClientTab;
|
||||
};
|
||||
|
||||
export const SsfTab = ({ save, client, activeTab }: SsfTabProps) => {
|
||||
const { adminClient } = useAdminClient();
|
||||
const { realm } = useRealm();
|
||||
|
||||
const { watch, setValue } = useFormContext<FormFields>();
|
||||
|
||||
const [defaultSupportedEvents, setDefaultSupportedEvents] = useState<string>(
|
||||
FALLBACK_DEFAULT_SUPPORTED_EVENTS,
|
||||
);
|
||||
const [availableSupportedEvents, setAvailableSupportedEvents] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [nativelyEmittedEvents, setNativelyEmittedEvents] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [defaultPushConnectTimeoutMillis, setDefaultPushConnectTimeoutMillis] =
|
||||
useState<number>(FALLBACK_DEFAULT_PUSH_CONNECT_TIMEOUT_MILLIS);
|
||||
const [defaultPushSocketTimeoutMillis, setDefaultPushSocketTimeoutMillis] =
|
||||
useState<number>(FALLBACK_DEFAULT_PUSH_SOCKET_TIMEOUT_MILLIS);
|
||||
const [defaultUserSubjectFormat, setDefaultUserSubjectFormat] =
|
||||
useState<string>(FALLBACK_DEFAULT_USER_SUBJECT_FORMAT);
|
||||
const [clientStream, setClientStream] = useState<SsfClientStream | null>(
|
||||
null,
|
||||
);
|
||||
const [streamFetchKey, setStreamFetchKey] = useState(0);
|
||||
const [configFetchKey, setConfigFetchKey] = useState(0);
|
||||
|
||||
const refresh = () => {
|
||||
setStreamFetchKey((k) => k + 1);
|
||||
setConfigFetchKey((k) => k + 1);
|
||||
};
|
||||
|
||||
useFetch(
|
||||
async () => {
|
||||
const response = await fetchWithError(
|
||||
`${addTrailingSlash(
|
||||
adminClient.baseUrl,
|
||||
)}admin/realms/${realm}/ssf/config`,
|
||||
{
|
||||
headers: getAuthorizationHeaders(await adminClient.getAccessToken()),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
return (await response.json()) as SsfConfig;
|
||||
},
|
||||
(config) => {
|
||||
if (config?.availableSupportedEvents) {
|
||||
setAvailableSupportedEvents(config.availableSupportedEvents);
|
||||
}
|
||||
|
||||
if (config?.nativelyEmittedEvents) {
|
||||
setNativelyEmittedEvents(config.nativelyEmittedEvents);
|
||||
}
|
||||
|
||||
if (typeof config?.defaultPushEndpointConnectTimeoutMillis === "number") {
|
||||
setDefaultPushConnectTimeoutMillis(
|
||||
config.defaultPushEndpointConnectTimeoutMillis,
|
||||
);
|
||||
const connectField = convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.pushEndpointConnectTimeoutMillis",
|
||||
);
|
||||
const currentConnect = watch(connectField);
|
||||
if (currentConnect === undefined || currentConnect === "") {
|
||||
setValue(
|
||||
connectField,
|
||||
String(config.defaultPushEndpointConnectTimeoutMillis),
|
||||
{ shouldDirty: false },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof config?.defaultPushEndpointSocketTimeoutMillis === "number") {
|
||||
setDefaultPushSocketTimeoutMillis(
|
||||
config.defaultPushEndpointSocketTimeoutMillis,
|
||||
);
|
||||
const socketField = convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.pushEndpointSocketTimeoutMillis",
|
||||
);
|
||||
const currentSocket = watch(socketField);
|
||||
if (currentSocket === undefined || currentSocket === "") {
|
||||
setValue(
|
||||
socketField,
|
||||
String(config.defaultPushEndpointSocketTimeoutMillis),
|
||||
{ shouldDirty: false },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof config?.defaultUserSubjectFormat === "string" &&
|
||||
config.defaultUserSubjectFormat.length > 0
|
||||
) {
|
||||
setDefaultUserSubjectFormat(config.defaultUserSubjectFormat);
|
||||
const userSubjectFormatField = convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.userSubjectFormat",
|
||||
);
|
||||
const currentUserSubjectFormat = watch(userSubjectFormatField);
|
||||
if (
|
||||
currentUserSubjectFormat === undefined ||
|
||||
currentUserSubjectFormat === ""
|
||||
) {
|
||||
setValue(userSubjectFormatField, config.defaultUserSubjectFormat, {
|
||||
shouldDirty: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const events = config?.defaultSupportedEvents;
|
||||
if (!events || events.length === 0) {
|
||||
return;
|
||||
}
|
||||
const joined = events.join(",");
|
||||
setDefaultSupportedEvents(joined);
|
||||
|
||||
const fieldName = convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.supportedEvents",
|
||||
);
|
||||
const currentValue = watch(fieldName);
|
||||
if (currentValue === undefined || currentValue === "") {
|
||||
setValue(fieldName, joined, { shouldDirty: false });
|
||||
}
|
||||
},
|
||||
[configFetchKey],
|
||||
);
|
||||
|
||||
useFetch(
|
||||
async () => {
|
||||
if (!client.id) {
|
||||
return null;
|
||||
}
|
||||
// Use plain fetch instead of fetchWithError so we can handle the
|
||||
// expected 404 (no stream registered yet) without surfacing it as a
|
||||
// global error and redirecting away from the client details page.
|
||||
const response = await fetch(
|
||||
`${addTrailingSlash(
|
||||
adminClient.baseUrl,
|
||||
)}admin/realms/${realm}/ssf/clients/${client.clientId}/stream`,
|
||||
{
|
||||
headers: getAuthorizationHeaders(await adminClient.getAccessToken()),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
return (await response.json()) as SsfClientStream;
|
||||
},
|
||||
(stream) => {
|
||||
setClientStream(stream);
|
||||
if (stream?.status) {
|
||||
// Re-seed the form-bound status field so clicking "Refresh"
|
||||
// picks up receiver-driven status changes (e.g. the receiver
|
||||
// paused the stream via POST /streams/status) instead of
|
||||
// showing the stale value the page loaded with.
|
||||
setValue(
|
||||
convertAttributeNameToForm<FormFields>("attributes.ssf.status"),
|
||||
stream.status,
|
||||
{ shouldDirty: false },
|
||||
);
|
||||
}
|
||||
// Re-seed the form-bound delivery selector from the actual
|
||||
// stream's delivery.method so the dropdown matches reality. The
|
||||
// attribute defaults to "PUSH" for new clients and only gets
|
||||
// overwritten on save — without this, opening a client whose
|
||||
// stream was created as POLL still shows "Push" in the picker.
|
||||
if (stream?.delivery?.method) {
|
||||
setValue(
|
||||
convertAttributeNameToForm<FormFields>("attributes.ssf.delivery"),
|
||||
isPollDeliveryMethod(stream.delivery.method) ? "POLL" : "PUSH",
|
||||
{ shouldDirty: false },
|
||||
);
|
||||
}
|
||||
},
|
||||
[client.id, streamFetchKey],
|
||||
);
|
||||
|
||||
const resetFields = (names: string[]) => {
|
||||
for (const name of names) {
|
||||
setValue(
|
||||
convertAttributeNameToForm<FormFields>(`attributes.${name}`),
|
||||
client.attributes?.[name] || "",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () =>
|
||||
resetFields([
|
||||
"ssf.description",
|
||||
"ssf.streamAudience",
|
||||
"ssf.supportedEvents",
|
||||
"ssf.emitOnlyEvents",
|
||||
"ssf.profile",
|
||||
"ssf.userSubjectFormat",
|
||||
"ssf.defaultSubjects",
|
||||
"ssf.autoNotifyOnLogin",
|
||||
"ssf.requireServiceAccount",
|
||||
"ssf.requiredRole",
|
||||
"ssf.allowEmitEvents",
|
||||
"ssf.emitEventsRole",
|
||||
"ssf.minVerificationInterval",
|
||||
"ssf.autoVerifyStream",
|
||||
"ssf.verificationDelayMillis",
|
||||
"ssf.delivery",
|
||||
"ssf.pushEndpointConnectTimeoutMillis",
|
||||
"ssf.pushEndpointSocketTimeoutMillis",
|
||||
"ssf.maxEventAgeSeconds",
|
||||
"ssf.inactivityTimeoutSeconds",
|
||||
"ssf.subjectRemovalGraceSeconds",
|
||||
]);
|
||||
|
||||
return (
|
||||
<PageSection variant="light" className="pf-v5-u-py-0">
|
||||
{activeTab === "receiver" && (
|
||||
<ReceiverTab
|
||||
client={client}
|
||||
clientStream={clientStream}
|
||||
defaultSupportedEvents={defaultSupportedEvents}
|
||||
availableSupportedEvents={availableSupportedEvents}
|
||||
nativelyEmittedEvents={nativelyEmittedEvents}
|
||||
defaultUserSubjectFormat={defaultUserSubjectFormat}
|
||||
save={save}
|
||||
reset={reset}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "stream" && (
|
||||
<StreamTab
|
||||
client={client}
|
||||
clientStream={clientStream}
|
||||
setClientStream={setClientStream}
|
||||
defaultSupportedEvents={defaultSupportedEvents}
|
||||
nativelyEmittedEvents={nativelyEmittedEvents}
|
||||
defaultPushConnectTimeoutMillis={defaultPushConnectTimeoutMillis}
|
||||
defaultPushSocketTimeoutMillis={defaultPushSocketTimeoutMillis}
|
||||
save={save}
|
||||
reset={reset}
|
||||
refresh={refresh}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "subjects" && <SubjectsTab client={client} />}
|
||||
{activeTab === "event-search" && <EventSearchTab client={client} />}
|
||||
{activeTab === "emit-events" && (
|
||||
<EmitEventsTab
|
||||
client={client}
|
||||
clientStream={clientStream}
|
||||
nativelyEmittedEvents={nativelyEmittedEvents}
|
||||
/>
|
||||
)}
|
||||
</PageSection>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,452 @@
|
||||
import { fetchWithError } from "@keycloak/keycloak-admin-client";
|
||||
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
|
||||
import {
|
||||
HelpItem,
|
||||
KeycloakSelect,
|
||||
SelectVariant,
|
||||
} from "@keycloak/keycloak-ui-shared";
|
||||
import {
|
||||
ActionGroup,
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
FormGroup,
|
||||
Label,
|
||||
SelectOption,
|
||||
Text,
|
||||
TextContent,
|
||||
TextInput,
|
||||
} from "@patternfly/react-core";
|
||||
import debouncePromise from "p-debounce";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { useAdminClient } from "../../../admin-client";
|
||||
import CodeEditor from "../../../components/form/CodeEditor";
|
||||
import { FormAccess } from "../../../components/form/FormAccess";
|
||||
import { useRealm } from "../../../context/realm-context/RealmContext";
|
||||
import { addTrailingSlash } from "../../../util";
|
||||
import { getAuthorizationHeaders } from "../../../utils/getAuthorizationHeaders";
|
||||
import { toSsfClientTab } from "../../routes/ClientSsfTab";
|
||||
import type { SsfClientStream } from "./StreamTab";
|
||||
|
||||
type SsfEmitResult = {
|
||||
status: string;
|
||||
jti?: string;
|
||||
};
|
||||
|
||||
type EmitSubjectType = "user-email" | "user-id" | "user-username" | "org-alias";
|
||||
|
||||
type EmitEventsFormValues = {
|
||||
emitEventType: string;
|
||||
emitSubjectType: EmitSubjectType;
|
||||
emitSubjectValue: string;
|
||||
emitPayload: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces supported text-level placeholders in a raw JSON payload
|
||||
* before it's handed to {@code JSON.parse}. Today only {@code __now__}
|
||||
* is recognized; it expands to the current Unix time in seconds as a
|
||||
* bare integer literal, so it works for both unquoted numeric fields
|
||||
* ({@code "event_timestamp": __now__}) and quoted string fields
|
||||
* ({@code "some_field": "__now__"}). The expansion runs in plain text
|
||||
* so it sidesteps the JSON grammar — no need for a custom parser.
|
||||
*/
|
||||
const substitutePayloadPlaceholders = (raw: string): string =>
|
||||
raw.replace(/__now__/g, String(Math.floor(Date.now() / 1000)));
|
||||
|
||||
export type EmitEventsTabProps = {
|
||||
client: ClientRepresentation;
|
||||
clientStream: SsfClientStream | null;
|
||||
nativelyEmittedEvents: string[];
|
||||
};
|
||||
|
||||
export const EmitEventsTab = ({
|
||||
client,
|
||||
clientStream,
|
||||
nativelyEmittedEvents,
|
||||
}: EmitEventsTabProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { adminClient } = useAdminClient();
|
||||
const { realm } = useRealm();
|
||||
|
||||
// Constrain the emit dropdown to events_delivered — the
|
||||
// authoritative set the dispatcher will actually push to this
|
||||
// receiver. It's already the intersection of receiver-side
|
||||
// events_requested and what the realm registry supports, so
|
||||
// listing anything outside it would just produce filter-dropped
|
||||
// emissions.
|
||||
const emittableEvents = clientStream?.eventsDelivered ?? [];
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<EmitEventsFormValues>({
|
||||
defaultValues: {
|
||||
emitEventType: "",
|
||||
emitSubjectType: "user-email",
|
||||
emitSubjectValue: "",
|
||||
// Default to an empty JSON object with a newline between the
|
||||
// braces so the caret lands on an indented line ready for typing.
|
||||
emitPayload: "{\n \n}",
|
||||
},
|
||||
mode: "onSubmit",
|
||||
});
|
||||
|
||||
// UI-only state for the typeahead/single selects and live feedback
|
||||
// panels — these aren't form values, so they stay in useState.
|
||||
const [emitEventTypeOpen, setEmitEventTypeOpen] = useState(false);
|
||||
const [emitEventTypeFilter, setEmitEventTypeFilter] = useState("");
|
||||
const [emitSubjectTypeOpen, setEmitSubjectTypeOpen] = useState(false);
|
||||
// Live JSON parse-error message — updated on every change so the
|
||||
// operator sees the problem as they type rather than only after
|
||||
// hitting Emit. The submit handler also uses this to short-circuit
|
||||
// before the HTTP call.
|
||||
const [emitPayloadParseError, setEmitPayloadParseError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [emitResult, setEmitResult] = useState<SsfEmitResult | null>(null);
|
||||
const [emitError, setEmitError] = useState<string | null>(null);
|
||||
|
||||
// Watch the subject-type so the TextInput placeholder switches
|
||||
// from "user@example.com" to "user-uuid" / "username" / "org-alias"
|
||||
// without re-rendering the rest of the form.
|
||||
const emitSubjectType = watch("emitSubjectType");
|
||||
|
||||
// Debounce the live JSON parse so a fast typist doesn't pay
|
||||
// JSON.parse on every keystroke against a large payload. 250ms
|
||||
// matches the cadence at which the validation error feels
|
||||
// "instant" without flickering as the user types mid-token.
|
||||
const debouncedValidatePayload = useMemo(
|
||||
() =>
|
||||
debouncePromise((value: string) => {
|
||||
try {
|
||||
JSON.parse(substitutePayloadPlaceholders(value));
|
||||
setEmitPayloadParseError(null);
|
||||
} catch (error) {
|
||||
setEmitPayloadParseError(
|
||||
t("ssfEmitPayloadInvalidJson", { error: String(error) }),
|
||||
);
|
||||
}
|
||||
}, 250),
|
||||
[t],
|
||||
);
|
||||
|
||||
/**
|
||||
* Emits a synthetic SSF event for this receiver via the admin-emit
|
||||
* endpoint. Builds an iss_sub subject from the realm issuer and the
|
||||
* provided userId so the admin only has to supply the user; the event
|
||||
* payload is pasted verbatim as JSON. On success, the result panel
|
||||
* shows a "Look up this event" link that navigates to the Event
|
||||
* Search tab with the returned jti pre-filled.
|
||||
*/
|
||||
const onSubmit = async (values: EmitEventsFormValues) => {
|
||||
if (!client.id) {
|
||||
return;
|
||||
}
|
||||
let parsedPayload: unknown;
|
||||
try {
|
||||
parsedPayload =
|
||||
values.emitPayload.trim() === ""
|
||||
? {}
|
||||
: JSON.parse(substitutePayloadPlaceholders(values.emitPayload));
|
||||
} catch (error) {
|
||||
const message = t("ssfEmitPayloadInvalidJson", { error: String(error) });
|
||||
setEmitError(message);
|
||||
setEmitPayloadParseError(message);
|
||||
return;
|
||||
}
|
||||
setEmitError(null);
|
||||
try {
|
||||
// Send the (subjectType, subjectValue) shorthand — the admin
|
||||
// emit backend resolves it through the same path the
|
||||
// /subjects:add endpoint uses, then for user subjects builds
|
||||
// the sub_id via the receiver-format-aware mapper so the
|
||||
// resulting SET matches the shape native events for this
|
||||
// receiver would have. Org subjects produce a complex tenant-
|
||||
// only subject for org-scoped routing.
|
||||
const response = await fetchWithError(
|
||||
`${addTrailingSlash(adminClient.baseUrl)}admin/realms/${realm}/ssf/clients/${client.clientId}/events/emit`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...getAuthorizationHeaders(await adminClient.getAccessToken()),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
eventType: values.emitEventType,
|
||||
subjectType: values.emitSubjectType,
|
||||
subjectValue: values.emitSubjectValue.trim(),
|
||||
event: parsedPayload,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const result = (await response.json()) as SsfEmitResult;
|
||||
setEmitResult(result);
|
||||
} catch (error) {
|
||||
setEmitError(error instanceof Error ? error.message : String(error));
|
||||
setEmitResult(null);
|
||||
}
|
||||
};
|
||||
|
||||
const eventSearchPath = (jti: string) => {
|
||||
// The route's :clientId path param is the client's internal UUID
|
||||
// (the parent client-details routes use it that way), not the OAuth
|
||||
// client_id. Use client.id; client.clientId would 404.
|
||||
const target = toSsfClientTab({
|
||||
realm,
|
||||
clientId: client.id!,
|
||||
tab: "event-search",
|
||||
});
|
||||
return {
|
||||
...target,
|
||||
search: `?jti=${encodeURIComponent(jti)}`,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<Card isFlat className="pf-v5-u-mt-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("ssfEmitTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<TextContent>
|
||||
<Text>{t("ssfEmitTitleHelp")}</Text>
|
||||
</TextContent>
|
||||
</CardBody>
|
||||
<CardBody>
|
||||
<FormAccess
|
||||
role="manage-clients"
|
||||
fineGrainedAccess={client.access?.configure}
|
||||
isHorizontal
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormGroup
|
||||
label={t("ssfEmitEventType")}
|
||||
fieldId="ssfEmitEventType"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("ssfEmitEventTypeHelp")}
|
||||
fieldLabelId="ssfEmitEventType"
|
||||
/>
|
||||
}
|
||||
isRequired
|
||||
>
|
||||
<Controller
|
||||
name="emitEventType"
|
||||
control={control}
|
||||
rules={{ required: t("ssfEmitEventTypeRequired") }}
|
||||
render={({ field }) => (
|
||||
<KeycloakSelect
|
||||
toggleId="ssfEmitEventType"
|
||||
data-testid="ssfEmitEventType"
|
||||
variant={SelectVariant.typeahead}
|
||||
typeAheadAriaLabel={t("ssfEmitEventType")}
|
||||
placeholderText={t("ssfEmitEventTypeSelectPrompt")}
|
||||
onToggle={setEmitEventTypeOpen}
|
||||
isOpen={emitEventTypeOpen}
|
||||
selections={field.value || undefined}
|
||||
onSelect={(value) => {
|
||||
field.onChange(value.toString());
|
||||
setEmitEventTypeOpen(false);
|
||||
setEmitEventTypeFilter("");
|
||||
}}
|
||||
onClear={() => {
|
||||
field.onChange("");
|
||||
setEmitEventTypeFilter("");
|
||||
}}
|
||||
onFilter={setEmitEventTypeFilter}
|
||||
>
|
||||
{emittableEvents
|
||||
.filter((eventType) =>
|
||||
eventType
|
||||
.toLowerCase()
|
||||
.includes(emitEventTypeFilter.toLowerCase()),
|
||||
)
|
||||
.map((eventType) => (
|
||||
<SelectOption key={eventType} value={eventType}>
|
||||
{eventType}
|
||||
{nativelyEmittedEvents.includes(eventType) && (
|
||||
<Label
|
||||
color="blue"
|
||||
isCompact
|
||||
className="pf-v5-u-ml-sm"
|
||||
>
|
||||
{t("ssfNativelyEmittedBadge")}
|
||||
</Label>
|
||||
)}
|
||||
</SelectOption>
|
||||
))}
|
||||
</KeycloakSelect>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup label={t("ssfSubjectType")} fieldId="ssfEmitSubjectType">
|
||||
<Controller
|
||||
name="emitSubjectType"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<KeycloakSelect
|
||||
toggleId="ssfEmitSubjectType"
|
||||
data-testid="ssfEmitSubjectType"
|
||||
variant={SelectVariant.single}
|
||||
onToggle={setEmitSubjectTypeOpen}
|
||||
isOpen={emitSubjectTypeOpen}
|
||||
selections={field.value}
|
||||
onSelect={(value) => {
|
||||
field.onChange(value as EmitSubjectType);
|
||||
setEmitSubjectTypeOpen(false);
|
||||
}}
|
||||
>
|
||||
<SelectOption value="user-email">
|
||||
{t("ssfSubjectType.userEmail")}
|
||||
</SelectOption>
|
||||
<SelectOption value="user-id">
|
||||
{t("ssfSubjectType.userId")}
|
||||
</SelectOption>
|
||||
<SelectOption value="user-username">
|
||||
{t("ssfSubjectType.userUsername")}
|
||||
</SelectOption>
|
||||
<SelectOption value="org-alias">
|
||||
{t("ssfSubjectType.orgAlias")}
|
||||
</SelectOption>
|
||||
</KeycloakSelect>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label={t("ssfSubjectValue")}
|
||||
fieldId="ssfEmitSubjectValue"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("ssfEmitSubjectValueHelp")}
|
||||
fieldLabelId="ssfEmitSubjectValue"
|
||||
/>
|
||||
}
|
||||
isRequired
|
||||
>
|
||||
<Controller
|
||||
name="emitSubjectValue"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value) =>
|
||||
value.trim() !== "" || t("ssfEmitSubjectValueRequired"),
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
id="ssfEmitSubjectValue"
|
||||
data-testid="ssfEmitSubjectValue"
|
||||
value={field.value}
|
||||
onChange={(_e, value) => field.onChange(value)}
|
||||
placeholder={
|
||||
emitSubjectType === "user-email"
|
||||
? "user@example.com"
|
||||
: emitSubjectType === "user-id"
|
||||
? "user-uuid"
|
||||
: emitSubjectType === "user-username"
|
||||
? "username"
|
||||
: "org-alias"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label={t("ssfEmitPayload")}
|
||||
fieldId="ssfEmitPayload"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("ssfEmitPayloadHelp")}
|
||||
fieldLabelId="ssfEmitPayload"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Controller
|
||||
name="emitPayload"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<CodeEditor
|
||||
data-testid="ssfEmitPayload"
|
||||
aria-label={t("ssfEmitPayload")}
|
||||
language="json"
|
||||
height={220}
|
||||
value={field.value}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
// Live validation: substitute placeholders first
|
||||
// (so unquoted __now__ becomes a valid numeric
|
||||
// literal) then JSON.parse. Blank payload resolves
|
||||
// to {} at submit time and is therefore valid —
|
||||
// clear the error synchronously so the UI feedback
|
||||
// is immediate when the user empties the field.
|
||||
if (value.trim() === "") {
|
||||
setEmitPayloadParseError(null);
|
||||
return;
|
||||
}
|
||||
void debouncedValidatePayload(value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{emitPayloadParseError && (
|
||||
<Text
|
||||
className="pf-v5-u-mt-sm pf-v5-u-color-status-danger--100"
|
||||
data-testid="ssfEmitPayloadParseError"
|
||||
>
|
||||
{emitPayloadParseError}
|
||||
</Text>
|
||||
)}
|
||||
</FormGroup>
|
||||
<ActionGroup>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting || emitPayloadParseError !== null}
|
||||
data-testid="ssfEmitEvent"
|
||||
>
|
||||
{t("ssfEmitEvent")}
|
||||
</Button>
|
||||
</ActionGroup>
|
||||
{emitError && (
|
||||
<Alert
|
||||
variant="danger"
|
||||
isInline
|
||||
className="pf-v5-u-mt-md"
|
||||
data-testid="ssfEmitError"
|
||||
title={emitError}
|
||||
/>
|
||||
)}
|
||||
{emitResult && (
|
||||
<TextContent className="pf-v5-u-mt-md" data-testid="ssfEmitResult">
|
||||
<Text className="pf-v5-u-color-status-success--100">
|
||||
{t("ssfEmitResult", {
|
||||
status: emitResult.status,
|
||||
jti: emitResult.jti,
|
||||
})}
|
||||
</Text>
|
||||
{emitResult.jti && (
|
||||
<Text>
|
||||
<Link
|
||||
to={eventSearchPath(emitResult.jti)}
|
||||
data-testid="ssfEmitResultLookup"
|
||||
>
|
||||
{t("ssfEmitResultLookupLink")}
|
||||
</Link>
|
||||
</Text>
|
||||
)}
|
||||
</TextContent>
|
||||
)}
|
||||
</FormAccess>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,278 @@
|
||||
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
|
||||
import { HelpItem } from "@keycloak/keycloak-ui-shared";
|
||||
import {
|
||||
ActionGroup,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
FormGroup,
|
||||
InputGroup,
|
||||
InputGroupItem,
|
||||
Text,
|
||||
TextContent,
|
||||
TextInput,
|
||||
} from "@patternfly/react-core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { useAdminClient } from "../../../admin-client";
|
||||
import { FormAccess } from "../../../components/form/FormAccess";
|
||||
import { useRealm } from "../../../context/realm-context/RealmContext";
|
||||
import { addTrailingSlash } from "../../../util";
|
||||
import { getAuthorizationHeaders } from "../../../utils/getAuthorizationHeaders";
|
||||
import useFormatDate from "../../../utils/useFormatDate";
|
||||
|
||||
type SsfPendingEvent = {
|
||||
jti: string;
|
||||
eventType?: string;
|
||||
deliveryMethod?: string;
|
||||
status?: string;
|
||||
attempts?: number;
|
||||
createdAt?: number;
|
||||
nextAttemptAt?: number;
|
||||
deliveredAt?: number;
|
||||
lastError?: string;
|
||||
streamId?: string;
|
||||
// Decoded JWS payload of the Security Event Token — the full
|
||||
// claim set the receiver will process (iss/iat/jti/aud/txn plus
|
||||
// the subject and event body). Rendered as formatted JSON in the
|
||||
// lookup result so the operator sees exactly what goes on the
|
||||
// wire.
|
||||
decodedSet?: Record<string, unknown>;
|
||||
// Resolved Keycloak user UUID — server-side maps the SET's
|
||||
// subject through SubjectResolver. Null for org-only /
|
||||
// unresolvable subjects; the UI then omits the user
|
||||
// click-through.
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export type EventSearchTabProps = {
|
||||
client: ClientRepresentation;
|
||||
};
|
||||
|
||||
export const EventSearchTab = ({ client }: EventSearchTabProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { adminClient } = useAdminClient();
|
||||
const { realm } = useRealm();
|
||||
const formatDate = useFormatDate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [pendingLookupJti, setPendingLookupJti] = useState(
|
||||
() => searchParams.get("jti") ?? "",
|
||||
);
|
||||
const [pendingLookupResult, setPendingLookupResult] =
|
||||
useState<SsfPendingEvent | null>(null);
|
||||
const [pendingLookupError, setPendingLookupError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [pendingActionLoading, setPendingActionLoading] = useState(false);
|
||||
|
||||
/**
|
||||
* Looks up a single outbox row by jti scoped to this receiver — calls
|
||||
* GET /admin/realms/{realm}/ssf/clients/{uuid}/pending-events/{jti}.
|
||||
* 404 is rendered as a "not found" message rather than an alert; any
|
||||
* other failure surfaces both inline and via addError.
|
||||
*/
|
||||
const handlePendingLookup = async (jti?: string) => {
|
||||
const lookupJti = (jti ?? pendingLookupJti).trim();
|
||||
if (!client.id || !lookupJti) {
|
||||
return;
|
||||
}
|
||||
// Don't wipe the previous result/error here — that causes the
|
||||
// result block to unmount during the request, which collapses the
|
||||
// container height and makes the layout jump on repeated clicks.
|
||||
// We update them in place once the new fetch resolves.
|
||||
setPendingActionLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${addTrailingSlash(adminClient.baseUrl)}admin/realms/${realm}/ssf/clients/${client.clientId}/pending-events/${encodeURIComponent(lookupJti)}`,
|
||||
{
|
||||
headers: getAuthorizationHeaders(await adminClient.getAccessToken()),
|
||||
},
|
||||
);
|
||||
if (response.status === 404) {
|
||||
setPendingLookupError(t("ssfPendingLookupNotFound"));
|
||||
setPendingLookupResult(null);
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
setPendingLookupError(text || `HTTP ${response.status}`);
|
||||
setPendingLookupResult(null);
|
||||
return;
|
||||
}
|
||||
setPendingLookupResult((await response.json()) as SsfPendingEvent);
|
||||
setPendingLookupError(null);
|
||||
} catch (error) {
|
||||
setPendingLookupError(String(error));
|
||||
setPendingLookupResult(null);
|
||||
} finally {
|
||||
setPendingActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-run the lookup once on mount when the URL carries ?jti=... —
|
||||
// the emit success panel uses this as a one-click handoff into the
|
||||
// search tab. After consuming the param, drop it from the URL so a
|
||||
// page refresh doesn't re-trigger the lookup with a now-stale value.
|
||||
useEffect(() => {
|
||||
const jti = searchParams.get("jti");
|
||||
if (!jti) return;
|
||||
void handlePendingLookup(jti);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("jti");
|
||||
setSearchParams(next, { replace: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card isFlat className="pf-v5-u-mt-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("ssfLookupTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<TextContent>
|
||||
<Text>{t("ssfLookupTitleHelp")}</Text>
|
||||
</TextContent>
|
||||
</CardBody>
|
||||
<CardBody>
|
||||
<FormAccess
|
||||
role="manage-clients"
|
||||
fineGrainedAccess={client.access?.configure}
|
||||
isHorizontal
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void handlePendingLookup();
|
||||
}}
|
||||
>
|
||||
<FormGroup
|
||||
label={t("ssfPendingLookupJti")}
|
||||
fieldId="ssfPendingLookupJti"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("ssfPendingLookupJtiHelp")}
|
||||
fieldLabelId="ssfPendingLookupJti"
|
||||
/>
|
||||
}
|
||||
isRequired
|
||||
>
|
||||
<InputGroup>
|
||||
<InputGroupItem isFill>
|
||||
<TextInput
|
||||
id="ssfPendingLookupJti"
|
||||
data-testid="ssfPendingLookupJti"
|
||||
value={pendingLookupJti}
|
||||
onChange={(_e, value) => setPendingLookupJti(value)}
|
||||
placeholder={t("ssfPendingLookupJtiPlaceholder")}
|
||||
/>
|
||||
</InputGroupItem>
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
<ActionGroup>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => handlePendingLookup()}
|
||||
isDisabled={pendingActionLoading || !pendingLookupJti?.trim()}
|
||||
data-testid="ssfPendingLookup"
|
||||
>
|
||||
{t("ssfPendingLookup")}
|
||||
</Button>
|
||||
</ActionGroup>
|
||||
{pendingLookupError && (
|
||||
<Text
|
||||
className="pf-v5-u-mt-md pf-v5-u-color-status-danger--100"
|
||||
data-testid="ssfPendingLookupError"
|
||||
>
|
||||
{pendingLookupError}
|
||||
</Text>
|
||||
)}
|
||||
{pendingLookupResult && (
|
||||
<FormGroup
|
||||
label={t("ssfPendingLookupResult")}
|
||||
fieldId="ssfPendingLookupResult"
|
||||
>
|
||||
<TextContent data-testid="ssfPendingLookupResult">
|
||||
<Text>
|
||||
<strong>{t("ssfPendingFieldStatus")}:</strong>{" "}
|
||||
{pendingLookupResult.status ?? "-"}
|
||||
</Text>
|
||||
<Text>
|
||||
<strong>{t("ssfPendingFieldEventType")}:</strong>{" "}
|
||||
{pendingLookupResult.eventType ?? "-"}
|
||||
</Text>
|
||||
<Text>
|
||||
<strong>{t("ssfPendingFieldDeliveryMethod")}:</strong>{" "}
|
||||
{pendingLookupResult.deliveryMethod ?? "-"}
|
||||
</Text>
|
||||
{/* Attempts + Next attempt at are PUSH drainer
|
||||
retry state — for POLL the receiver pulls on its
|
||||
own cadence and these fields carry no useful
|
||||
information. Hide them for POLL rows to avoid
|
||||
operator confusion. */}
|
||||
{pendingLookupResult.deliveryMethod !== "POLL" && (
|
||||
<Text>
|
||||
<strong>{t("ssfPendingFieldAttempts")}:</strong>{" "}
|
||||
{pendingLookupResult.attempts ?? 0}
|
||||
</Text>
|
||||
)}
|
||||
<Text>
|
||||
<strong>{t("ssfPendingFieldCreatedAt")}:</strong>{" "}
|
||||
{pendingLookupResult.createdAt
|
||||
? formatDate(new Date(pendingLookupResult.createdAt * 1000))
|
||||
: "-"}
|
||||
</Text>
|
||||
{pendingLookupResult.deliveryMethod !== "POLL" && (
|
||||
<Text>
|
||||
<strong>{t("ssfPendingFieldNextAttemptAt")}:</strong>{" "}
|
||||
{pendingLookupResult.nextAttemptAt
|
||||
? formatDate(
|
||||
new Date(pendingLookupResult.nextAttemptAt * 1000),
|
||||
)
|
||||
: "-"}
|
||||
</Text>
|
||||
)}
|
||||
<Text>
|
||||
<strong>{t("ssfPendingFieldDeliveredAt")}:</strong>{" "}
|
||||
{pendingLookupResult.deliveredAt
|
||||
? formatDate(
|
||||
new Date(pendingLookupResult.deliveredAt * 1000),
|
||||
)
|
||||
: "-"}
|
||||
</Text>
|
||||
{pendingLookupResult.lastError && (
|
||||
<Text>
|
||||
<strong>{t("ssfPendingFieldLastError")}:</strong>{" "}
|
||||
{pendingLookupResult.lastError}
|
||||
</Text>
|
||||
)}
|
||||
{pendingLookupResult.userId && (
|
||||
<Text>
|
||||
<strong>{t("ssfPendingFieldUserId")}:</strong>{" "}
|
||||
{pendingLookupResult.userId}
|
||||
</Text>
|
||||
)}
|
||||
{pendingLookupResult.decodedSet && (
|
||||
<>
|
||||
<Text>
|
||||
<strong>{t("ssfPendingFieldDecodedSet")}:</strong>
|
||||
</Text>
|
||||
<pre
|
||||
data-testid="ssfPendingFieldDecodedSetJson"
|
||||
className="pf-v5-u-font-family-monospace"
|
||||
>
|
||||
{JSON.stringify(pendingLookupResult.decodedSet, null, 2)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</TextContent>
|
||||
</FormGroup>
|
||||
)}
|
||||
</FormAccess>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,936 @@
|
||||
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
|
||||
import {
|
||||
HelpItem,
|
||||
KeycloakSelect,
|
||||
NumberControl,
|
||||
ScrollForm,
|
||||
SelectControl,
|
||||
SelectVariant,
|
||||
TextAreaControl,
|
||||
TextControl,
|
||||
} from "@keycloak/keycloak-ui-shared";
|
||||
import {
|
||||
ActionGroup,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Checkbox,
|
||||
Chip,
|
||||
FormGroup,
|
||||
Label,
|
||||
SelectOption,
|
||||
Split,
|
||||
SplitItem,
|
||||
Stack,
|
||||
StackItem,
|
||||
Text,
|
||||
TextContent,
|
||||
} from "@patternfly/react-core";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
InfoCircleIcon,
|
||||
MinusCircleIcon,
|
||||
PauseCircleIcon,
|
||||
TimesCircleIcon,
|
||||
} from "@patternfly/react-icons";
|
||||
import { useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FormAccess } from "../../../components/form/FormAccess";
|
||||
import { MultiLineInput } from "../../../components/multi-line-input/MultiLineInput";
|
||||
import {
|
||||
AddRoleButton,
|
||||
AddRoleMappingModal,
|
||||
FilterType,
|
||||
} from "../../../components/role-mapping/AddRoleMappingModal";
|
||||
import { ServiceRole } from "../../../components/role-mapping/RoleMapping";
|
||||
import { DefaultSwitchControl } from "../../../components/SwitchControl";
|
||||
import { TimeSelector } from "../../../components/time-selector/TimeSelector";
|
||||
import { convertAttributeNameToForm } from "../../../util";
|
||||
import type { FormFields, SaveOptions } from "../../ClientDetails";
|
||||
import type { SsfClientStream } from "./StreamTab";
|
||||
|
||||
const splitSupportedEvents = (value: unknown): string[] => {
|
||||
if (!value || typeof value !== "string") {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits a {@code ssf.allowedDeliveryMethods} client attribute value
|
||||
* into its canonical lowercase tokens. The server uses
|
||||
* {@code ##}-separated storage (see {@code Constants.CFG_DELIMITER});
|
||||
* empty / unset attribute returns an empty array which the UI treats
|
||||
* as "both push and poll allowed".
|
||||
*/
|
||||
const splitDeliveryMethods = (value: unknown): string[] => {
|
||||
if (!value || typeof value !== "string") {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.split("##")
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter((s) => s.length > 0);
|
||||
};
|
||||
|
||||
export type ReceiverTabProps = {
|
||||
client: ClientRepresentation;
|
||||
clientStream: SsfClientStream | null;
|
||||
defaultSupportedEvents: string;
|
||||
availableSupportedEvents: string[];
|
||||
nativelyEmittedEvents: string[];
|
||||
defaultUserSubjectFormat: string;
|
||||
save: (options?: SaveOptions) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
export const ReceiverTab = ({
|
||||
client,
|
||||
clientStream,
|
||||
defaultSupportedEvents,
|
||||
availableSupportedEvents,
|
||||
nativelyEmittedEvents,
|
||||
defaultUserSubjectFormat,
|
||||
save,
|
||||
reset,
|
||||
}: ReceiverTabProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { control, watch, setValue } = useFormContext<FormFields>();
|
||||
|
||||
const [supportedEventsOpen, setSupportedEventsOpen] = useState(false);
|
||||
const [supportedEventsFilter, setSupportedEventsFilter] = useState("");
|
||||
const [emitOnlyEventsOpen, setEmitOnlyEventsOpen] = useState(false);
|
||||
const [emitOnlyEventsFilter, setEmitOnlyEventsFilter] = useState("");
|
||||
|
||||
// --- SSF Required Role picker state ---
|
||||
const [rolePickerOpen, setRolePickerOpen] = useState(false);
|
||||
const [roleFilterType, setRoleFilterType] = useState<FilterType>("clients");
|
||||
|
||||
// --- SSF Emit Events Role picker state ---
|
||||
// Independent modal/filter state so both pickers can coexist in the
|
||||
// same form without clobbering each other's open state.
|
||||
const [emitRolePickerOpen, setEmitRolePickerOpen] = useState(false);
|
||||
const [emitRoleFilterType, setEmitRoleFilterType] =
|
||||
useState<FilterType>("clients");
|
||||
|
||||
const requiredRoleFieldName = convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.requiredRole",
|
||||
);
|
||||
|
||||
const emitEventsRoleFieldName = convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.emitEventsRole",
|
||||
);
|
||||
|
||||
const parseRoleValue = (value: string | undefined) => {
|
||||
if (!value?.includes(".")) return ["", value || ""];
|
||||
return value.split(".");
|
||||
};
|
||||
|
||||
// DefaultSwitchControl with stringify persists "true" / "false" —
|
||||
// compare as string so the delay-millis input toggles in sync with
|
||||
// the switch rather than interpreting the raw boolean.
|
||||
const ssfAutoVerifyStream = watch(
|
||||
convertAttributeNameToForm<FormFields>("attributes.ssf.autoVerifyStream"),
|
||||
);
|
||||
// DefaultSwitchControl with stringify persists "true" / "false" —
|
||||
// compare as string so the role picker toggles in sync with the
|
||||
// switch rather than interpreting the raw boolean.
|
||||
const ssfAllowEmitEvents = watch(
|
||||
convertAttributeNameToForm<FormFields>("attributes.ssf.allowEmitEvents"),
|
||||
);
|
||||
|
||||
// Drive the emit-only multi-select options off the live value of
|
||||
// supportedEvents so adding / removing a supported event immediately
|
||||
// adjusts what the operator can mark emit-only. No standalone
|
||||
// registry list — the emit-only set is a strict subset.
|
||||
const ssfSupportedEvents = watch(
|
||||
convertAttributeNameToForm<FormFields>("attributes.ssf.supportedEvents"),
|
||||
);
|
||||
const ssfEmitOnlyEvents = watch(
|
||||
convertAttributeNameToForm<FormFields>("attributes.ssf.emitOnlyEvents"),
|
||||
);
|
||||
|
||||
const supportedEventsSelected = splitSupportedEvents(
|
||||
ssfSupportedEvents ?? defaultSupportedEvents,
|
||||
);
|
||||
const emitOnlyEventsSelected = splitSupportedEvents(ssfEmitOnlyEvents).filter(
|
||||
(e) => supportedEventsSelected.includes(e),
|
||||
);
|
||||
|
||||
// Allowed delivery methods is stored as a ##-separated client attribute
|
||||
// matching ssf.allowedDeliveryMethods on the server. Empty / unset means
|
||||
// "both push and poll allowed" (transmitter default), which is also what
|
||||
// the checkboxes render as the initial state on a freshly registered
|
||||
// receiver. The two checkboxes drive the same form field via setValue —
|
||||
// the conditional reveal of "Valid push URLs" below keys off whether
|
||||
// "push" is selected here.
|
||||
const allowedDeliveryMethodsField = convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.allowedDeliveryMethods",
|
||||
);
|
||||
const ssfAllowedDeliveryMethodsRaw = watch(allowedDeliveryMethodsField);
|
||||
const allowedDeliveryMethodTokens = splitDeliveryMethods(
|
||||
ssfAllowedDeliveryMethodsRaw,
|
||||
);
|
||||
const pushDeliveryAllowed =
|
||||
allowedDeliveryMethodTokens.length === 0 ||
|
||||
allowedDeliveryMethodTokens.includes("push");
|
||||
const pollDeliveryAllowed =
|
||||
allowedDeliveryMethodTokens.length === 0 ||
|
||||
allowedDeliveryMethodTokens.includes("poll");
|
||||
|
||||
const updateAllowedDeliveryMethod = (
|
||||
method: "push" | "poll",
|
||||
checked: boolean,
|
||||
) => {
|
||||
const current =
|
||||
allowedDeliveryMethodTokens.length === 0
|
||||
? new Set<string>(["push", "poll"])
|
||||
: new Set<string>(allowedDeliveryMethodTokens);
|
||||
if (checked) {
|
||||
current.add(method);
|
||||
} else {
|
||||
current.delete(method);
|
||||
}
|
||||
// At least one method must remain selected — silently re-enable the
|
||||
// toggled-off method if the user attempted to uncheck both. The
|
||||
// server treats blank as "both allowed" too, but driving the user
|
||||
// through that path makes the UI ambiguous (cleared → defaults).
|
||||
if (current.size === 0) {
|
||||
current.add(method);
|
||||
}
|
||||
setValue(
|
||||
allowedDeliveryMethodsField,
|
||||
// Canonical wire order push,poll → join in the same order so a
|
||||
// round-trip through storage doesn't churn the value.
|
||||
["push", "poll"].filter((m) => current.has(m)).join("##"),
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card isFlat className="pf-v5-u-mt-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("ssfReceiver")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<TextContent>
|
||||
<Text>{t("ssfReceiverHelp")}</Text>
|
||||
</TextContent>
|
||||
</CardBody>
|
||||
<CardBody>
|
||||
<FormAccess
|
||||
role="manage-clients"
|
||||
fineGrainedAccess={client.access?.configure}
|
||||
isHorizontal
|
||||
>
|
||||
<ScrollForm
|
||||
label={t("jumpToSection")}
|
||||
sections={[
|
||||
{
|
||||
title: t("ssfSectionGeneral"),
|
||||
panel: (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
rowGap: "1.5rem",
|
||||
}}
|
||||
>
|
||||
<Text className="pf-v5-u-pb-lg">
|
||||
{t("ssfSectionGeneralHelp")}
|
||||
</Text>
|
||||
<FormGroup
|
||||
label={t("ssfStreamStatusLabel")}
|
||||
fieldId="ssfStreamStatusIndicator"
|
||||
>
|
||||
{!clientStream && (
|
||||
<Label
|
||||
color="grey"
|
||||
icon={<MinusCircleIcon />}
|
||||
data-testid="ssfStreamIndicator.unregistered"
|
||||
>
|
||||
{t("ssfStreamIndicator.unregistered")}
|
||||
</Label>
|
||||
)}
|
||||
{clientStream?.status === "enabled" && (
|
||||
<Label
|
||||
color="green"
|
||||
icon={<CheckCircleIcon />}
|
||||
data-testid="ssfStreamIndicator.enabled"
|
||||
>
|
||||
{t("ssfStreamIndicator.enabled")}
|
||||
</Label>
|
||||
)}
|
||||
{clientStream?.status === "paused" && (
|
||||
<Label
|
||||
color="orange"
|
||||
icon={<PauseCircleIcon />}
|
||||
data-testid="ssfStreamIndicator.paused"
|
||||
>
|
||||
{t("ssfStreamIndicator.paused")}
|
||||
</Label>
|
||||
)}
|
||||
{clientStream?.status === "disabled" && (
|
||||
<Label
|
||||
color="red"
|
||||
icon={<TimesCircleIcon />}
|
||||
data-testid="ssfStreamIndicator.disabled"
|
||||
>
|
||||
{t("ssfStreamIndicator.disabled")}
|
||||
</Label>
|
||||
)}
|
||||
{clientStream && !clientStream.status && (
|
||||
<Label
|
||||
color="blue"
|
||||
icon={<InfoCircleIcon />}
|
||||
data-testid="ssfStreamIndicator.registered"
|
||||
>
|
||||
{t("ssfStreamIndicator.registered")}
|
||||
</Label>
|
||||
)}
|
||||
</FormGroup>
|
||||
<SelectControl
|
||||
name={convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.profile",
|
||||
)}
|
||||
label={t("ssfProfile")}
|
||||
labelIcon={t("ssfProfileHelp")}
|
||||
controller={{
|
||||
defaultValue: "SSF_1_0",
|
||||
}}
|
||||
options={[
|
||||
{ key: "SSF_1_0", value: t("ssfProfile.SSF_1_0") },
|
||||
{
|
||||
key: "SSE_CAEP",
|
||||
value: t("ssfProfile.SSE_CAEP"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<TextAreaControl
|
||||
name={convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.description",
|
||||
)}
|
||||
label={t("ssfDescription")}
|
||||
labelIcon={t("ssfDescriptionHelp")}
|
||||
rules={{
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: t("maxLength", { length: 255 }),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextControl
|
||||
name={convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.streamAudience",
|
||||
)}
|
||||
label={t("ssfStreamAudience")}
|
||||
labelIcon={t("ssfStreamAudienceHelp")}
|
||||
/>
|
||||
<SelectControl
|
||||
name={convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.defaultSubjects",
|
||||
)}
|
||||
label={t("ssfDefaultSubjects")}
|
||||
labelIcon={t("ssfDefaultSubjectsHelp")}
|
||||
controller={{
|
||||
defaultValue: "NONE",
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
key: "ALL",
|
||||
value: t("ssfDefaultSubjects.ALL"),
|
||||
},
|
||||
{
|
||||
key: "NONE",
|
||||
value: t("ssfDefaultSubjects.NONE"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<SelectControl
|
||||
name={convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.userSubjectFormat",
|
||||
)}
|
||||
label={t("ssfUserSubjectFormat")}
|
||||
labelIcon={t("ssfUserSubjectFormatHelp")}
|
||||
controller={{
|
||||
defaultValue: defaultUserSubjectFormat,
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
key: "iss_sub",
|
||||
value: t("ssfUserSubjectFormat.iss_sub"),
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
value: t("ssfUserSubjectFormat.email"),
|
||||
},
|
||||
{
|
||||
key: "complex.iss_sub+tenant",
|
||||
value: t(
|
||||
"ssfUserSubjectFormat.complex.iss_sub+tenant",
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "complex.email+tenant",
|
||||
value: t("ssfUserSubjectFormat.complex.email+tenant"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<DefaultSwitchControl
|
||||
name={convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.autoNotifyOnLogin",
|
||||
)}
|
||||
label={t("ssfAutoNotifyOnLogin")}
|
||||
labelIcon={t("ssfAutoNotifyOnLoginHelp")}
|
||||
stringify
|
||||
/>
|
||||
<FormGroup
|
||||
label={t("ssfSubjectRemovalGrace")}
|
||||
fieldId="ssfSubjectRemovalGrace"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("ssfSubjectRemovalGraceHelp")}
|
||||
fieldLabelId="ssfSubjectRemovalGrace"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Controller
|
||||
name={convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.subjectRemovalGraceSeconds",
|
||||
)}
|
||||
defaultValue=""
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TimeSelector
|
||||
data-testid="ssfSubjectRemovalGrace"
|
||||
value={field.value!}
|
||||
onChange={field.onChange}
|
||||
units={["second", "minute", "hour", "day"]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<DefaultSwitchControl
|
||||
name={convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.autoVerifyStream",
|
||||
)}
|
||||
label={t("ssfAutoVerifyStream")}
|
||||
labelIcon={t("ssfAutoVerifyStreamHelp")}
|
||||
stringify
|
||||
/>
|
||||
{ssfAutoVerifyStream === "true" && (
|
||||
<NumberControl
|
||||
name={convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.verificationDelayMillis",
|
||||
)}
|
||||
label={t("ssfVerificationDelay")}
|
||||
labelIcon={t("ssfVerificationDelayHelp")}
|
||||
controller={{
|
||||
defaultValue: 1500,
|
||||
rules: {
|
||||
min: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<NumberControl
|
||||
name={convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.minVerificationInterval",
|
||||
)}
|
||||
label={t("ssfMinVerificationInterval")}
|
||||
labelIcon={t("ssfMinVerificationIntervalHelp")}
|
||||
controller={{
|
||||
defaultValue: "",
|
||||
rules: {
|
||||
min: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<FormGroup
|
||||
label={t("ssfMaxEventAge")}
|
||||
fieldId="ssfMaxEventAge"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("ssfMaxEventAgeHelp")}
|
||||
fieldLabelId="ssfMaxEventAge"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Controller
|
||||
name={convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.maxEventAgeSeconds",
|
||||
)}
|
||||
defaultValue=""
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TimeSelector
|
||||
data-testid="ssfMaxEventAge"
|
||||
value={field.value!}
|
||||
onChange={field.onChange}
|
||||
units={["second", "minute", "hour", "day"]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label={t("ssfInactivityTimeout")}
|
||||
fieldId="ssfInactivityTimeout"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("ssfInactivityTimeoutHelp")}
|
||||
fieldLabelId="ssfInactivityTimeout"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Controller
|
||||
name={convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.inactivityTimeoutSeconds",
|
||||
)}
|
||||
defaultValue=""
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TimeSelector
|
||||
data-testid="ssfInactivityTimeout"
|
||||
value={field.value!}
|
||||
onChange={field.onChange}
|
||||
units={["minute", "hour", "day"]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("ssfSectionAuthentication"),
|
||||
panel: (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
rowGap: "1.5rem",
|
||||
}}
|
||||
>
|
||||
<Text className="pf-v5-u-pb-lg">
|
||||
{t("ssfSectionAuthenticationHelp")}
|
||||
</Text>
|
||||
<DefaultSwitchControl
|
||||
name={convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.requireServiceAccount",
|
||||
)}
|
||||
label={t("ssfRequireServiceAccount")}
|
||||
labelIcon={t("ssfRequireServiceAccountHelp")}
|
||||
stringify
|
||||
/>
|
||||
<FormGroup
|
||||
label={t("ssfRequiredRole")}
|
||||
fieldId="ssfRequiredRole"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("ssfRequiredRoleHelp")}
|
||||
fieldLabelId="ssfRequiredRole"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Controller
|
||||
name={requiredRoleFieldName}
|
||||
defaultValue=""
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Split>
|
||||
{rolePickerOpen && (
|
||||
<AddRoleMappingModal
|
||||
id="ssfRequiredRolePicker"
|
||||
type="roles"
|
||||
filterType={roleFilterType}
|
||||
name="ssfRequiredRole"
|
||||
onAssign={(rows) => {
|
||||
const row = rows[0];
|
||||
const value = row.client?.clientId
|
||||
? `${row.client.clientId}.${row.role.name}`
|
||||
: row.role.name;
|
||||
field.onChange(value);
|
||||
setRolePickerOpen(false);
|
||||
}}
|
||||
onClose={() => setRolePickerOpen(false)}
|
||||
isRadio
|
||||
/>
|
||||
)}
|
||||
{field.value && field.value !== "" && (
|
||||
<SplitItem>
|
||||
<Chip
|
||||
textMaxWidth="500px"
|
||||
onClick={() => field.onChange("")}
|
||||
>
|
||||
<ServiceRole
|
||||
role={{
|
||||
name: parseRoleValue(field.value)[1],
|
||||
}}
|
||||
client={{
|
||||
clientId: parseRoleValue(field.value)[0],
|
||||
}}
|
||||
/>
|
||||
</Chip>
|
||||
</SplitItem>
|
||||
)}
|
||||
<SplitItem>
|
||||
<AddRoleButton
|
||||
label="selectRole.label"
|
||||
onFilerTypeChange={(type) => {
|
||||
setRoleFilterType(type);
|
||||
setRolePickerOpen(true);
|
||||
}}
|
||||
variant="secondary"
|
||||
data-testid="ssfRequiredRoleSelect"
|
||||
isDisabled={false}
|
||||
/>
|
||||
</SplitItem>
|
||||
</Split>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("ssfSectionDelivery"),
|
||||
panel: (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
rowGap: "1.5rem",
|
||||
}}
|
||||
>
|
||||
<Text className="pf-v5-u-pb-lg">
|
||||
{t("ssfSectionDeliveryHelp")}
|
||||
</Text>
|
||||
<FormGroup
|
||||
label={t("ssfAllowedDeliveryMethods")}
|
||||
fieldId="ssfAllowedDeliveryMethods"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("ssfAllowedDeliveryMethodsHelp")}
|
||||
fieldLabelId="ssfAllowedDeliveryMethods"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Stack hasGutter>
|
||||
<StackItem>
|
||||
<Checkbox
|
||||
id="ssfAllowedDeliveryMethodsPush"
|
||||
data-testid="ssfAllowedDeliveryMethods.push"
|
||||
label={t("ssfAllowedDeliveryMethods.push")}
|
||||
description={t(
|
||||
"ssfAllowedDeliveryMethods.pushHelp",
|
||||
)}
|
||||
isChecked={pushDeliveryAllowed}
|
||||
onChange={(_event, checked) =>
|
||||
updateAllowedDeliveryMethod("push", checked)
|
||||
}
|
||||
/>
|
||||
</StackItem>
|
||||
<StackItem>
|
||||
<Checkbox
|
||||
id="ssfAllowedDeliveryMethodsPoll"
|
||||
data-testid="ssfAllowedDeliveryMethods.poll"
|
||||
label={t("ssfAllowedDeliveryMethods.poll")}
|
||||
description={t(
|
||||
"ssfAllowedDeliveryMethods.pollHelp",
|
||||
)}
|
||||
isChecked={pollDeliveryAllowed}
|
||||
onChange={(_event, checked) =>
|
||||
updateAllowedDeliveryMethod("poll", checked)
|
||||
}
|
||||
/>
|
||||
</StackItem>
|
||||
</Stack>
|
||||
</FormGroup>
|
||||
{pushDeliveryAllowed && (
|
||||
<FormGroup
|
||||
label={t("ssfValidPushUrls")}
|
||||
fieldId="ssfValidPushUrls"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("ssfValidPushUrlsHelp")}
|
||||
fieldLabelId="ssfValidPushUrls"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MultiLineInput
|
||||
id="ssfValidPushUrls"
|
||||
name={convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.validPushUrls",
|
||||
)}
|
||||
aria-label={t("ssfValidPushUrls")}
|
||||
addButtonLabel="ssfValidPushUrls.add"
|
||||
stringify
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("ssfSectionEvents"),
|
||||
panel: (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
rowGap: "1.5rem",
|
||||
}}
|
||||
>
|
||||
<Text className="pf-v5-u-pb-lg">
|
||||
{t("ssfSectionEventsHelp")}
|
||||
</Text>
|
||||
<FormGroup
|
||||
label={t("ssfSupportedEvents")}
|
||||
fieldId="ssfSupportedEvents"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("ssfSupportedEventsHelp")}
|
||||
fieldLabelId="ssfSupportedEvents"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<KeycloakSelect
|
||||
toggleId="ssfSupportedEvents"
|
||||
data-testid="ssfSupportedEvents"
|
||||
variant={SelectVariant.typeaheadMulti}
|
||||
chipGroupProps={{
|
||||
numChips: 5,
|
||||
expandedText: t("hide"),
|
||||
collapsedText: t("showRemaining"),
|
||||
}}
|
||||
typeAheadAriaLabel={t("ssfSupportedEvents")}
|
||||
onToggle={setSupportedEventsOpen}
|
||||
isOpen={supportedEventsOpen}
|
||||
selections={supportedEventsSelected}
|
||||
onSelect={(value) => {
|
||||
const option = value.toString();
|
||||
if (!option) return;
|
||||
const next = supportedEventsSelected.includes(option)
|
||||
? supportedEventsSelected.filter(
|
||||
(s) => s !== option,
|
||||
)
|
||||
: [...supportedEventsSelected, option];
|
||||
setValue(
|
||||
convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.supportedEvents",
|
||||
),
|
||||
next.join(","),
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
setSupportedEventsFilter("");
|
||||
}}
|
||||
onClear={() => {
|
||||
setValue(
|
||||
convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.supportedEvents",
|
||||
),
|
||||
"",
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
setSupportedEventsFilter("");
|
||||
}}
|
||||
onFilter={setSupportedEventsFilter}
|
||||
>
|
||||
{availableSupportedEvents
|
||||
.filter((event) =>
|
||||
event
|
||||
.toLowerCase()
|
||||
.includes(supportedEventsFilter.toLowerCase()),
|
||||
)
|
||||
.map((event) => (
|
||||
<SelectOption key={event} value={event}>
|
||||
{event}
|
||||
{nativelyEmittedEvents.includes(event) && (
|
||||
<Label
|
||||
color="blue"
|
||||
isCompact
|
||||
className="pf-v5-u-ml-sm"
|
||||
>
|
||||
{t("ssfNativelyEmittedBadge")}
|
||||
</Label>
|
||||
)}
|
||||
</SelectOption>
|
||||
))}
|
||||
</KeycloakSelect>
|
||||
</FormGroup>
|
||||
<DefaultSwitchControl
|
||||
name={convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.allowEmitEvents",
|
||||
)}
|
||||
label={t("ssfAllowEmitEvents")}
|
||||
labelIcon={t("ssfAllowEmitEventsHelp")}
|
||||
stringify
|
||||
/>
|
||||
<FormGroup
|
||||
label={t("ssfEmitOnlyEvents")}
|
||||
fieldId="ssfEmitOnlyEvents"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("ssfEmitOnlyEventsHelp")}
|
||||
fieldLabelId="ssfEmitOnlyEvents"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<KeycloakSelect
|
||||
toggleId="ssfEmitOnlyEvents"
|
||||
data-testid="ssfEmitOnlyEvents"
|
||||
variant={SelectVariant.typeaheadMulti}
|
||||
chipGroupProps={{
|
||||
numChips: 5,
|
||||
expandedText: t("hide"),
|
||||
collapsedText: t("showRemaining"),
|
||||
}}
|
||||
typeAheadAriaLabel={t("ssfEmitOnlyEvents")}
|
||||
onToggle={setEmitOnlyEventsOpen}
|
||||
isOpen={emitOnlyEventsOpen}
|
||||
selections={emitOnlyEventsSelected}
|
||||
onSelect={(value) => {
|
||||
const option = value.toString();
|
||||
if (!option) return;
|
||||
const next = emitOnlyEventsSelected.includes(option)
|
||||
? emitOnlyEventsSelected.filter((s) => s !== option)
|
||||
: [...emitOnlyEventsSelected, option];
|
||||
setValue(
|
||||
convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.emitOnlyEvents",
|
||||
),
|
||||
next.join(","),
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
setEmitOnlyEventsFilter("");
|
||||
}}
|
||||
onClear={() => {
|
||||
setValue(
|
||||
convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.emitOnlyEvents",
|
||||
),
|
||||
"",
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
setEmitOnlyEventsFilter("");
|
||||
}}
|
||||
onFilter={setEmitOnlyEventsFilter}
|
||||
isDisabled={supportedEventsSelected.length === 0}
|
||||
>
|
||||
{supportedEventsSelected
|
||||
.filter((event) =>
|
||||
event
|
||||
.toLowerCase()
|
||||
.includes(emitOnlyEventsFilter.toLowerCase()),
|
||||
)
|
||||
.map((event) => (
|
||||
<SelectOption key={event} value={event}>
|
||||
{event}
|
||||
</SelectOption>
|
||||
))}
|
||||
</KeycloakSelect>
|
||||
</FormGroup>
|
||||
{String(ssfAllowEmitEvents) === "true" && (
|
||||
<FormGroup
|
||||
label={t("ssfEmitEventsRole")}
|
||||
fieldId="ssfEmitEventsRole"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("ssfEmitEventsRoleHelp")}
|
||||
fieldLabelId="ssfEmitEventsRole"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Controller
|
||||
name={emitEventsRoleFieldName}
|
||||
defaultValue=""
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Split>
|
||||
{emitRolePickerOpen && (
|
||||
<AddRoleMappingModal
|
||||
id="ssfEmitEventsRolePicker"
|
||||
type="roles"
|
||||
filterType={emitRoleFilterType}
|
||||
name="ssfEmitEventsRole"
|
||||
onAssign={(rows) => {
|
||||
const row = rows[0];
|
||||
const value = row.client?.clientId
|
||||
? `${row.client.clientId}.${row.role.name}`
|
||||
: row.role.name;
|
||||
field.onChange(value);
|
||||
setEmitRolePickerOpen(false);
|
||||
}}
|
||||
onClose={() => setEmitRolePickerOpen(false)}
|
||||
isRadio
|
||||
/>
|
||||
)}
|
||||
{field.value && field.value !== "" && (
|
||||
<SplitItem>
|
||||
<Chip
|
||||
textMaxWidth="500px"
|
||||
onClick={() => field.onChange("")}
|
||||
>
|
||||
<ServiceRole
|
||||
role={{
|
||||
name: parseRoleValue(field.value)[1],
|
||||
}}
|
||||
client={{
|
||||
clientId: parseRoleValue(
|
||||
field.value,
|
||||
)[0],
|
||||
}}
|
||||
/>
|
||||
</Chip>
|
||||
</SplitItem>
|
||||
)}
|
||||
<SplitItem>
|
||||
<AddRoleButton
|
||||
label="selectRole.label"
|
||||
onFilerTypeChange={(type) => {
|
||||
setEmitRoleFilterType(type);
|
||||
setEmitRolePickerOpen(true);
|
||||
}}
|
||||
variant="secondary"
|
||||
data-testid="ssfEmitEventsRoleSelect"
|
||||
isDisabled={false}
|
||||
/>
|
||||
</SplitItem>
|
||||
</Split>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ActionGroup>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => save()}
|
||||
data-testid="ssfReceiverSave"
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={reset}
|
||||
data-testid="ssfReceiverRevert"
|
||||
>
|
||||
{t("revert")}
|
||||
</Button>
|
||||
</ActionGroup>
|
||||
</FormAccess>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,358 @@
|
||||
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
|
||||
import { HelpItem, useAlerts } from "@keycloak/keycloak-ui-shared";
|
||||
import {
|
||||
ActionGroup,
|
||||
AlertVariant,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
FormGroup,
|
||||
FormHelperText,
|
||||
HelperText,
|
||||
HelperTextItem,
|
||||
Label,
|
||||
Text,
|
||||
TextContent,
|
||||
TextInput,
|
||||
} from "@patternfly/react-core";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
InfoCircleIcon,
|
||||
TimesCircleIcon,
|
||||
} from "@patternfly/react-icons";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useAdminClient } from "../../../admin-client";
|
||||
import { FormAccess } from "../../../components/form/FormAccess";
|
||||
import { useRealm } from "../../../context/realm-context/RealmContext";
|
||||
import { addTrailingSlash } from "../../../util";
|
||||
import { getAuthorizationHeaders } from "../../../utils/getAuthorizationHeaders";
|
||||
|
||||
type SubjectType = "user-id" | "user-email" | "user-username" | "org-alias";
|
||||
type SubjectState =
|
||||
| "notified"
|
||||
| "ignored"
|
||||
| "notified_via_org"
|
||||
| "ignored_via_org"
|
||||
| "implicitly_included"
|
||||
| "not_notified";
|
||||
|
||||
export type SubjectsTabProps = {
|
||||
client: ClientRepresentation;
|
||||
};
|
||||
|
||||
export const SubjectsTab = ({ client }: SubjectsTabProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { adminClient } = useAdminClient();
|
||||
const { realm } = useRealm();
|
||||
const { addAlert, addError } = useAlerts();
|
||||
|
||||
const [subjectType, setSubjectType] = useState<SubjectType>("user-email");
|
||||
const [subjectValue, setSubjectValue] = useState("");
|
||||
const [subjectStatus, setSubjectStatus] = useState<{
|
||||
variant: "success" | "danger" | "info";
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [subjectLoading, setSubjectLoading] = useState(false);
|
||||
const [subjectValueError, setSubjectValueError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const callSubjectAdminEndpoint = async (
|
||||
action:
|
||||
| "subjects/add"
|
||||
| "subjects/remove"
|
||||
| "subjects/ignore"
|
||||
| "subjects/check",
|
||||
) => {
|
||||
const baseUrl = addTrailingSlash(adminClient.baseUrl);
|
||||
const res = await fetch(
|
||||
`${baseUrl}admin/realms/${realm}/ssf/clients/${client.clientId}/${action}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...getAuthorizationHeaders(await adminClient.getAccessToken()),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ type: subjectType, value: subjectValue }),
|
||||
},
|
||||
);
|
||||
return res;
|
||||
};
|
||||
|
||||
const checkSubjectViaAdminApi = async (): Promise<{
|
||||
state: SubjectState;
|
||||
sourceOrgAlias?: string;
|
||||
} | null> => {
|
||||
// Authoritative read: the server runs the same gate logic the
|
||||
// dispatcher uses (per-user / per-org notify, default_subjects
|
||||
// fallback, org-membership inheritance) so the displayed status
|
||||
// matches what the dispatcher would actually decide.
|
||||
const res = await callSubjectAdminEndpoint("subjects/check");
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) return null;
|
||||
const body = await res.json();
|
||||
return {
|
||||
state: body?.status as SubjectState,
|
||||
sourceOrgAlias: body?.source_org_alias as string | undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const applySubjectState = (state: SubjectState, sourceOrgAlias?: string) => {
|
||||
if (state === "notified") {
|
||||
setSubjectStatus({
|
||||
variant: "success",
|
||||
message: t("ssfSubjectIsNotified"),
|
||||
});
|
||||
} else if (state === "notified_via_org") {
|
||||
setSubjectStatus({
|
||||
variant: "success",
|
||||
message: sourceOrgAlias
|
||||
? t("ssfSubjectIsNotifiedViaOrgNamed", { org: sourceOrgAlias })
|
||||
: t("ssfSubjectIsNotifiedViaOrg"),
|
||||
});
|
||||
} else if (state === "implicitly_included") {
|
||||
setSubjectStatus({
|
||||
variant: "success",
|
||||
message: t("ssfSubjectIsImplicitlyIncluded"),
|
||||
});
|
||||
} else if (state === "ignored") {
|
||||
setSubjectStatus({
|
||||
variant: "danger",
|
||||
message: t("ssfSubjectIsIgnored"),
|
||||
});
|
||||
} else if (state === "ignored_via_org") {
|
||||
setSubjectStatus({
|
||||
variant: "danger",
|
||||
message: sourceOrgAlias
|
||||
? t("ssfSubjectIsIgnoredViaOrgNamed", { org: sourceOrgAlias })
|
||||
: t("ssfSubjectIsIgnoredViaOrg"),
|
||||
});
|
||||
} else {
|
||||
setSubjectStatus({
|
||||
variant: "info",
|
||||
message: t("ssfSubjectIsNotNotified"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubjectAction = async (
|
||||
action: "add" | "remove" | "ignore" | "check",
|
||||
) => {
|
||||
if (!subjectValue.trim()) {
|
||||
setSubjectValueError(t("ssfSubjectValueRequired"));
|
||||
return;
|
||||
}
|
||||
setSubjectValueError(null);
|
||||
setSubjectLoading(true);
|
||||
setSubjectStatus(null);
|
||||
try {
|
||||
if (action === "check") {
|
||||
const result = await checkSubjectViaAdminApi();
|
||||
if (result === null) {
|
||||
setSubjectValueError(t("ssfSubjectNotFound"));
|
||||
} else {
|
||||
applySubjectState(result.state, result.sourceOrgAlias);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint =
|
||||
action === "add"
|
||||
? "subjects/add"
|
||||
: action === "ignore"
|
||||
? "subjects/ignore"
|
||||
: "subjects/remove";
|
||||
const res = await callSubjectAdminEndpoint(endpoint);
|
||||
|
||||
if (res.status === 404) {
|
||||
setSubjectValueError(t("ssfSubjectNotFound"));
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`${res.status} ${body || res.statusText}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const state = data?.status as SubjectState | undefined;
|
||||
if (state) {
|
||||
applySubjectState(state);
|
||||
}
|
||||
|
||||
addAlert(
|
||||
action === "add"
|
||||
? t("ssfSubjectAdded")
|
||||
: action === "ignore"
|
||||
? t("ssfSubjectIgnored")
|
||||
: t("ssfSubjectRemoved"),
|
||||
AlertVariant.success,
|
||||
);
|
||||
} catch (error) {
|
||||
addError("ssfSubjectActionError", error);
|
||||
} finally {
|
||||
setSubjectLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card isFlat className="pf-v5-u-mt-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("ssfTabSubjects")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<TextContent>
|
||||
<Text>{t("ssfSubjectsHelp")}</Text>
|
||||
</TextContent>
|
||||
</CardBody>
|
||||
<CardBody>
|
||||
<FormAccess
|
||||
role="manage-clients"
|
||||
fineGrainedAccess={client.access?.configure}
|
||||
isHorizontal
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void handleSubjectAction("check");
|
||||
}}
|
||||
>
|
||||
<FormGroup
|
||||
label={t("ssfSubjectType")}
|
||||
fieldId="ssfSubjectType"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={t("ssfSubjectTypeHelp")}
|
||||
fieldLabelId="ssfSubjectType"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<select
|
||||
id="ssfSubjectType"
|
||||
data-testid="ssfSubjectType"
|
||||
value={subjectType}
|
||||
onChange={(e) => setSubjectType(e.target.value as SubjectType)}
|
||||
className="pf-v5-c-form-control"
|
||||
>
|
||||
<option value="user-email">
|
||||
{t("ssfSubjectType.userEmail")}
|
||||
</option>
|
||||
<option value="user-id">{t("ssfSubjectType.userId")}</option>
|
||||
<option value="user-username">
|
||||
{t("ssfSubjectType.userUsername")}
|
||||
</option>
|
||||
<option value="org-alias">{t("ssfSubjectType.orgAlias")}</option>
|
||||
</select>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label={t("ssfSubjectValue")}
|
||||
fieldId="ssfSubjectValue"
|
||||
isRequired
|
||||
>
|
||||
<TextInput
|
||||
id="ssfSubjectValue"
|
||||
data-testid="ssfSubjectValue"
|
||||
value={subjectValue}
|
||||
validated={subjectValueError ? "error" : "default"}
|
||||
onChange={(_e, value) => {
|
||||
setSubjectValue(value);
|
||||
if (subjectValueError) {
|
||||
setSubjectValueError(null);
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
subjectType === "user-email"
|
||||
? "user@example.com"
|
||||
: subjectType === "user-id"
|
||||
? "user-uuid"
|
||||
: subjectType === "user-username"
|
||||
? "username"
|
||||
: "org-alias"
|
||||
}
|
||||
/>
|
||||
|
||||
{subjectValueError && (
|
||||
<FormHelperText>
|
||||
<HelperText>
|
||||
<HelperTextItem
|
||||
variant="error"
|
||||
data-testid="ssfSubjectValueError"
|
||||
>
|
||||
{subjectValueError}
|
||||
</HelperTextItem>
|
||||
</HelperText>
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormGroup>
|
||||
{subjectStatus && (
|
||||
<FormGroup
|
||||
label={t("ssfSubjectStatusLabel")}
|
||||
fieldId="ssfSubjectStatus"
|
||||
>
|
||||
<Label
|
||||
color={
|
||||
subjectStatus.variant === "success"
|
||||
? "green"
|
||||
: subjectStatus.variant === "danger"
|
||||
? "red"
|
||||
: "grey"
|
||||
}
|
||||
icon={
|
||||
subjectStatus.variant === "success" ? (
|
||||
<CheckCircleIcon />
|
||||
) : subjectStatus.variant === "danger" ? (
|
||||
<TimesCircleIcon />
|
||||
) : (
|
||||
<InfoCircleIcon />
|
||||
)
|
||||
}
|
||||
data-testid="ssfSubjectStatus"
|
||||
>
|
||||
{subjectStatus.message}
|
||||
</Label>
|
||||
</FormGroup>
|
||||
)}
|
||||
<ActionGroup>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => handleSubjectAction("add")}
|
||||
isDisabled={subjectLoading}
|
||||
data-testid="ssfSubjectAdd"
|
||||
>
|
||||
{t("ssfSubjectAdd")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => handleSubjectAction("ignore")}
|
||||
isDisabled={subjectLoading}
|
||||
data-testid="ssfSubjectIgnore"
|
||||
>
|
||||
{t("ssfSubjectIgnore")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => handleSubjectAction("remove")}
|
||||
isDisabled={subjectLoading}
|
||||
data-testid="ssfSubjectRemove"
|
||||
>
|
||||
{t("ssfSubjectRemove")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary"
|
||||
onClick={() => handleSubjectAction("check")}
|
||||
isDisabled={subjectLoading}
|
||||
data-testid="ssfSubjectCheck"
|
||||
>
|
||||
{t("ssfSubjectCheck")}
|
||||
</Button>
|
||||
</ActionGroup>
|
||||
</FormAccess>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
KeycloakSpinner,
|
||||
SelectControl,
|
||||
TextControl,
|
||||
useAlerts,
|
||||
useEnvironment,
|
||||
useFetch,
|
||||
} from "@keycloak/keycloak-ui-shared";
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
StackItem,
|
||||
} from "@patternfly/react-core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { Controller, FormProvider, useForm, useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAdminClient } from "../admin-client";
|
||||
import { DefaultSwitchControl } from "../components/SwitchControl";
|
||||
@@ -38,6 +39,10 @@ import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled";
|
||||
import { UIRealmRepresentation } from "./RealmSettingsTabs";
|
||||
import { SIGNATURE_ALGORITHMS } from "../clients/add/SamlSignature";
|
||||
import type { RealmLoAMappingType } from "../components/realm-loa-mapping/RealmLoAMapping";
|
||||
import {
|
||||
deleteRealmSsfQueuedEvents,
|
||||
useSsfTransmitterDisableConfirmDialog,
|
||||
} from "./ssf/SsfTransmitterDisableConfirmDialog";
|
||||
|
||||
type RealmSettingsGeneralTabProps = {
|
||||
realm: UIRealmRepresentation;
|
||||
@@ -103,6 +108,8 @@ function RealmSettingsGeneralTabForm({
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { realm: realmName } = useRealm();
|
||||
const { adminClient } = useAdminClient();
|
||||
const { addAlert, addError } = useAlerts();
|
||||
const form = useForm<FormFields>();
|
||||
const {
|
||||
control,
|
||||
@@ -121,6 +128,38 @@ function RealmSettingsGeneralTabForm({
|
||||
);
|
||||
const isScimApiEnabled = isFeatureEnabled(Feature.ScimApi);
|
||||
|
||||
const isSsfEnabled = isFeatureEnabled(Feature.Ssf);
|
||||
|
||||
const ssfTransmitterEnabled = useWatch({
|
||||
control,
|
||||
name: convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.transmitterEnabled",
|
||||
) as any,
|
||||
});
|
||||
|
||||
// Disabling the transmitter at the realm level has cascading effects
|
||||
// (silent receiver pause, queued events deferred or dead-lettered after
|
||||
// outbox-pending-max-age, all SSF endpoints 404). Surface those to the
|
||||
// admin so the off transition is a deliberate choice rather than an
|
||||
// accidental flip. The toggle's onChange below opens this dialog when
|
||||
// going from on to off; cancelling reverts the field back to "true".
|
||||
const [toggleSsfDisableDialog, SsfDisableConfirm] =
|
||||
useSsfTransmitterDisableConfirmDialog({
|
||||
onConfirm: () => {
|
||||
// No-op: the inner Controller already flipped the field to "false"
|
||||
// when the admin clicked the switch; confirming just lets that
|
||||
// value stand until the user hits the form's Save button.
|
||||
},
|
||||
onCancel: () => {
|
||||
setValue(
|
||||
convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.transmitterEnabled",
|
||||
) as any,
|
||||
"true",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const setupForm = () => {
|
||||
convertToFormValues(realm, setValue);
|
||||
setValue(
|
||||
@@ -157,6 +196,31 @@ function RealmSettingsGeneralTabForm({
|
||||
upConfig.unmanagedAttributePolicy = unmanagedAttributePolicy;
|
||||
}
|
||||
|
||||
// Detect a true -> false transition on the SSF Transmitter realm
|
||||
// toggle so we can drop queued events as part of the same save
|
||||
// flow. Compare the persisted previous state to the new form
|
||||
// value — captured before save() so the comparison is well-defined
|
||||
// regardless of when the actual write happens.
|
||||
const wasSsfTransmitterEnabled =
|
||||
realm.attributes?.["ssf.transmitterEnabled"] === "true";
|
||||
const isSsfTransmitterEnabledAfter =
|
||||
ssfTransmitterEnabled?.toString() === "true";
|
||||
|
||||
if (wasSsfTransmitterEnabled && !isSsfTransmitterEnabledAfter) {
|
||||
// Cleanup runs BEFORE save while the SSF admin resource is
|
||||
// still reachable. Once save() persists transmitterEnabled=false,
|
||||
// SsfAdminRealmResourceProviderFactory gates /ssf/* off and the
|
||||
// DELETE would 404. Best-effort: a cleanup failure surfaces as
|
||||
// a toast but doesn't block the disable — outbox-pending-max-age
|
||||
// backstops any leftover PENDING rows.
|
||||
try {
|
||||
await deleteRealmSsfQueuedEvents(adminClient, realmName);
|
||||
addAlert(t("ssfTransmitterDisableEventsCleared"));
|
||||
} catch (error) {
|
||||
addError("ssfTransmitterDisableEventsClearFailed", error);
|
||||
}
|
||||
}
|
||||
|
||||
await save({ ...data, upConfig });
|
||||
},
|
||||
);
|
||||
@@ -271,6 +335,26 @@ function RealmSettingsGeneralTabForm({
|
||||
labelIcon={t("scimApiEnabledHelp")}
|
||||
/>
|
||||
)}
|
||||
{isSsfEnabled && (
|
||||
<>
|
||||
<DefaultSwitchControl
|
||||
name={convertAttributeNameToForm<FormFields>(
|
||||
"attributes.ssf.transmitterEnabled",
|
||||
)}
|
||||
label={t("ssfTransmitterEnabled")}
|
||||
labelIcon={t("ssfTransmitterEnabledHelp")}
|
||||
stringify
|
||||
onChange={(_e, checked) => {
|
||||
// Off transition only — surface the consequences before
|
||||
// the admin commits the form save. Cancelling reverts.
|
||||
if (!checked) {
|
||||
toggleSsfDisableDialog();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SsfDisableConfirm />
|
||||
</>
|
||||
)}
|
||||
<SelectControl
|
||||
name="unmanagedAttributePolicy"
|
||||
label={t("unmanagedAttributes")}
|
||||
@@ -344,6 +428,16 @@ function RealmSettingsGeneralTabForm({
|
||||
/>
|
||||
</StackItem>
|
||||
)}
|
||||
{isSsfEnabled && ssfTransmitterEnabled?.toString() === "true" && (
|
||||
<StackItem>
|
||||
<FormattedLink
|
||||
href={`${addTrailingSlash(
|
||||
serverBaseUrl,
|
||||
)}realms/${realmName}/.well-known/ssf-configuration`}
|
||||
title={t("ssfConfigurationMetadata")}
|
||||
/>
|
||||
</StackItem>
|
||||
)}
|
||||
</Stack>
|
||||
</FormGroup>
|
||||
<FixedButtonsGroup
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { fetchWithError } from "@keycloak/keycloak-admin-client";
|
||||
import type KeycloakAdminClient from "@keycloak/keycloak-admin-client";
|
||||
import {
|
||||
ButtonVariant,
|
||||
ModalVariant,
|
||||
Text,
|
||||
TextContent,
|
||||
TextList,
|
||||
TextListItem,
|
||||
} from "@patternfly/react-core";
|
||||
import { ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
|
||||
import { addTrailingSlash } from "../../util";
|
||||
import { getAuthorizationHeaders } from "../../utils/getAuthorizationHeaders";
|
||||
|
||||
type UseSsfTransmitterDisableConfirmDialogProps = {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drops queued SSF events (PENDING and HELD) for a realm via the SSF
|
||||
* admin REST API. Called from the realm-settings save flow before the
|
||||
* realm save persists {@code ssf.transmitterEnabled=false} — once the
|
||||
* flag is off, SsfAdminRealmResourceProviderFactory gates the SSF
|
||||
* admin paths and they return 404, so the cleanup must run while the
|
||||
* resource is still reachable.
|
||||
*
|
||||
* <p>Lives next to the confirm-dialog hook so the realm-settings tab
|
||||
* just imports a single SSF helper rather than re-implementing the
|
||||
* URL/auth/HTTP plumbing inline. Failures are propagated to the
|
||||
* caller; the realm-settings tab surfaces them as non-fatal toasts
|
||||
* and proceeds with the save (the {@code outbox-pending-max-age}
|
||||
* backstop will sweep any leftover PENDING rows on its own).
|
||||
*/
|
||||
export const deleteRealmSsfQueuedEvents = async (
|
||||
adminClient: KeycloakAdminClient,
|
||||
realmName: string,
|
||||
): Promise<void> => {
|
||||
const url = `${addTrailingSlash(adminClient.baseUrl)}admin/realms/${realmName}/ssf/events/queued`;
|
||||
const headers = {
|
||||
...getAuthorizationHeaders(await adminClient.getAccessToken()),
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
await fetchWithError(url, { method: "DELETE", headers });
|
||||
};
|
||||
|
||||
/**
|
||||
* Confirm dialog shown when an admin toggles the realm-level SSF
|
||||
* Transmitter feature off. Surfaces the cascading effects (silent
|
||||
* receiver pause, all SSF endpoints 404, queued events deleted on
|
||||
* save) so the off transition is a deliberate choice rather than an
|
||||
* accidental flip.
|
||||
*
|
||||
* <p>Returns the same {@code [toggleDialog, Dialog]} tuple as
|
||||
* {@link useConfirmDialog} — the caller wires {@code toggleDialog}
|
||||
* into the realm-settings switch's onChange and renders {@code Dialog}
|
||||
* once in the surrounding tree.
|
||||
*/
|
||||
export const useSsfTransmitterDisableConfirmDialog = ({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: UseSsfTransmitterDisableConfirmDialogProps): [
|
||||
() => void,
|
||||
() => ReactElement,
|
||||
] => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useConfirmDialog({
|
||||
titleKey: "ssfTransmitterDisableConfirmTitle",
|
||||
continueButtonLabel: "ssfTransmitterDisableConfirmContinue",
|
||||
continueButtonVariant: ButtonVariant.danger,
|
||||
variant: ModalVariant.medium,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
children: (
|
||||
<TextContent>
|
||||
<Text>{t("ssfTransmitterDisableConfirmIntro")}</Text>
|
||||
<TextList>
|
||||
<TextListItem>
|
||||
{t("ssfTransmitterDisableConfirmBulletEndpoints")}
|
||||
</TextListItem>
|
||||
<TextListItem>
|
||||
{t("ssfTransmitterDisableConfirmBulletEvents")}
|
||||
</TextListItem>
|
||||
<TextListItem>
|
||||
{t("ssfTransmitterDisableConfirmBulletDelivery")}
|
||||
</TextListItem>
|
||||
<TextListItem>
|
||||
{t("ssfTransmitterDisableConfirmBulletReceivers")}
|
||||
</TextListItem>
|
||||
</TextList>
|
||||
<Text>{t("ssfTransmitterDisableConfirmRecommendation")}</Text>
|
||||
</TextContent>
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -21,6 +21,7 @@ export enum Feature {
|
||||
ClientAuthFederated = "CLIENT_AUTH_FEDERATED",
|
||||
Workflows = "WORKFLOWS",
|
||||
StepUpAuthenticationSaml = "STEP_UP_AUTHENTICATION_SAML",
|
||||
Ssf = "SSF",
|
||||
ScimApi = "SCIM_API",
|
||||
IdentityBrokeringAPIV2 = "IDENTITY_BROKERING_API_V2",
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export const HelpItem = ({
|
||||
<>
|
||||
{!unWrap && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`help-label-${fieldLabelId}`}
|
||||
aria-label={fieldLabelId}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.events.outbox;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
/**
|
||||
* Computes the exponential-backoff next-attempt timestamp for a failed
|
||||
* outbox delivery, and decides when a row has exhausted its budget and
|
||||
* should be transitioned to {@code DEAD_LETTER}.
|
||||
*
|
||||
* <p>The backoff curve and the dead-letter threshold are configured per
|
||||
* {@link OutboxConfig#backoff()}, so different consumers (SSF push,
|
||||
* webhooks, ...) can pick curves that match their delivery semantics.
|
||||
*
|
||||
* <p>A small uniform jitter (±25% of the base delay) is mixed in on
|
||||
* each computation so a flood of rows enqueued in the same tick don't
|
||||
* all wake up to retry in the same millisecond — spreading the retry
|
||||
* load in clustered deployments and across receivers.
|
||||
*/
|
||||
public class OutboxBackoff {
|
||||
|
||||
public static final int DEFAULT_MAX_ATTEMPTS = 8;
|
||||
|
||||
/**
|
||||
* Default HTTP-push curve — sensible for receivers that respond to
|
||||
* an HTTP POST. Consumers with different delivery semantics
|
||||
* (e.g. internal queue write) should provide their own.
|
||||
*/
|
||||
public static final List<Duration> DEFAULT_HTTP_PUSH_CURVE = List.of(
|
||||
Duration.ofSeconds(1),
|
||||
Duration.ofSeconds(5),
|
||||
Duration.ofSeconds(30),
|
||||
Duration.ofMinutes(2),
|
||||
Duration.ofMinutes(10),
|
||||
Duration.ofHours(1),
|
||||
Duration.ofHours(6),
|
||||
Duration.ofHours(24)
|
||||
);
|
||||
|
||||
protected final int maxAttempts;
|
||||
protected final List<Duration> curve;
|
||||
|
||||
public OutboxBackoff() {
|
||||
this(DEFAULT_MAX_ATTEMPTS, DEFAULT_HTTP_PUSH_CURVE);
|
||||
}
|
||||
|
||||
public OutboxBackoff(int maxAttempts, List<Duration> curve) {
|
||||
if (maxAttempts < 1) {
|
||||
throw new IllegalArgumentException("maxAttempts must be >= 1, got " + maxAttempts);
|
||||
}
|
||||
Objects.requireNonNull(curve, "curve");
|
||||
if (curve.isEmpty()) {
|
||||
throw new IllegalArgumentException("curve must not be empty");
|
||||
}
|
||||
this.maxAttempts = maxAttempts;
|
||||
this.curve = curve;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true} if the row has burned through its retry
|
||||
* budget and should be transitioned to {@code DEAD_LETTER} instead
|
||||
* of scheduling another attempt.
|
||||
*
|
||||
* @param attempts the value of {@code attempts} after the current
|
||||
* failure has been accounted for.
|
||||
*/
|
||||
public boolean isExhausted(int attempts) {
|
||||
return attempts >= maxAttempts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the {@code next_attempt_at} timestamp for a row whose
|
||||
* {@code attempts} counter has just been incremented to the given
|
||||
* value after a failure. Callers should only invoke this when
|
||||
* {@link #isExhausted(int)} returns false.
|
||||
*/
|
||||
public Instant computeNextAttemptAt(Instant now, int attempts) {
|
||||
Duration base = baseDelayFor(attempts);
|
||||
long baseMillis = base.toMillis();
|
||||
long jitterRangeMillis = Math.max(1, baseMillis / 4);
|
||||
long jitterMillis = ThreadLocalRandom.current()
|
||||
.nextLong(-jitterRangeMillis, jitterRangeMillis + 1);
|
||||
long delayMillis = Math.max(0, baseMillis + jitterMillis);
|
||||
return now.plusMillis(delayMillis);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum of the curve up to {@link #maxAttempts} entries — the
|
||||
* worst-case time a row can spend in PENDING under the natural
|
||||
* retry path before exhausting attempts. Operators tuning
|
||||
* {@code pendingMaxAge} backstops should keep that value
|
||||
* comfortably above this floor.
|
||||
*/
|
||||
public Duration getMaxNaturalRetryDuration() {
|
||||
Duration total = Duration.ZERO;
|
||||
int n = Math.min(maxAttempts, curve.size());
|
||||
for (int i = 0; i < n; i++) {
|
||||
total = total.plus(curve.get(i));
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
protected Duration baseDelayFor(int attempts) {
|
||||
// attempts is 1-based after the first failed try.
|
||||
int idx = Math.min(Math.max(attempts, 1), curve.size()) - 1;
|
||||
return curve.get(idx);
|
||||
}
|
||||
|
||||
public List<Duration> getCurve() {
|
||||
return curve;
|
||||
}
|
||||
|
||||
public int getMaxAttempts() {
|
||||
return maxAttempts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.events.outbox;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
/**
|
||||
* Background cleanup task that drains outbox rows owned by a removed
|
||||
* realm or owner (e.g. receiver client). Runs in a single bounded
|
||||
* transaction so the admin's original removal transaction can commit
|
||||
* immediately instead of carrying a six-digit {@code DELETE} on its
|
||||
* back.
|
||||
*
|
||||
* <p>Submitted by a feature's lifecycle listener (e.g. SSF's
|
||||
* {@code RealmRemovedEvent} / {@code ClientRemovedEvent} handler) to
|
||||
* a Keycloak-managed executor. The task itself opens a fresh session
|
||||
* via {@link KeycloakModelUtils#runJobInTransaction} so it doesn't
|
||||
* inherit the caller's transaction or session lifecycle.
|
||||
*
|
||||
* <p>Crash safety: if the node running this task dies mid-flight, the
|
||||
* remaining rows are orphaned. That's bounded by the
|
||||
* {@code pendingMaxAge} backstop on the drainer for queued rows and
|
||||
* by the configured retention windows for terminal rows — orphan rows
|
||||
* eventually get swept either way.
|
||||
*/
|
||||
public class OutboxCleanupTask implements Runnable {
|
||||
|
||||
private static final Logger log = Logger.getLogger(OutboxCleanupTask.class);
|
||||
|
||||
public enum Scope {
|
||||
REALM, OWNER
|
||||
}
|
||||
|
||||
protected final KeycloakSessionFactory factory;
|
||||
protected final Function<KeycloakSession, OutboxStore> storeFactory;
|
||||
protected final String entryKind;
|
||||
protected final Scope scope;
|
||||
protected final String key;
|
||||
|
||||
public OutboxCleanupTask(KeycloakSessionFactory factory,
|
||||
Function<KeycloakSession, OutboxStore> storeFactory,
|
||||
String entryKind,
|
||||
Scope scope,
|
||||
String key) {
|
||||
this.factory = Objects.requireNonNull(factory, "factory");
|
||||
this.storeFactory = Objects.requireNonNull(storeFactory, "storeFactory");
|
||||
this.entryKind = Objects.requireNonNull(entryKind, "entryKind");
|
||||
this.scope = Objects.requireNonNull(scope, "scope");
|
||||
this.key = Objects.requireNonNull(key, "key");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
try {
|
||||
int[] deletedHolder = new int[1];
|
||||
KeycloakModelUtils.runJobInTransaction(factory, session -> {
|
||||
OutboxStore store = storeFactory.apply(session);
|
||||
deletedHolder[0] = scope == Scope.REALM
|
||||
? store.deleteByRealm(entryKind, key)
|
||||
: store.deleteByOwner(entryKind, key);
|
||||
});
|
||||
int deleted = deletedHolder[0];
|
||||
if (deleted > 0) {
|
||||
log.debugf("Outbox cleanup complete. entryKind=%s scope=%s key=%s deleted=%d",
|
||||
entryKind, scope, key, deleted);
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
// Don't re-throw: the executor would log it as an
|
||||
// uncaught exception. Orphan rows will be reaped by the
|
||||
// drainer's retention purges or the pendingMaxAge
|
||||
// backstop on a future tick.
|
||||
log.warnf(e, "Outbox cleanup task failed. entryKind=%s scope=%s key=%s", entryKind, scope, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.events.outbox;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Per-kind tuning parameters for {@link OutboxDrainerTask} and the
|
||||
* accompanying retention purges. One {@code OutboxConfig} is supplied
|
||||
* per registered {@code entryKind}, so SSF and webhooks can pick
|
||||
* different batch sizes, backoff curves, and retention windows
|
||||
* independently.
|
||||
*
|
||||
* <p>{@code deadLetterRetention}, {@code deliveredRetention}, and
|
||||
* {@code pendingMaxAge} accept {@code null} or a non-positive
|
||||
* {@link Duration} to disable the corresponding purge or backstop
|
||||
* (kept retained indefinitely).
|
||||
*
|
||||
* <p>{@code pendingMaxAge} is a backstop that promotes {@code QUEUED}
|
||||
* rows older than this duration to {@code DEAD_LETTER}. Bounds the
|
||||
* worst case where rows would otherwise sit forever (e.g. handler
|
||||
* repeatedly skipping, no per-receiver age cap, no realm/owner
|
||||
* removal). Should be comfortably above
|
||||
* {@link OutboxBackoff#getMaxNaturalRetryDuration()} so rows in
|
||||
* legitimate backoff aren't prematurely promoted, and shorter than
|
||||
* {@code deadLetterRetention} so promoted rows retain a meaningful
|
||||
* forensic window before the dead-letter purge deletes them.
|
||||
*/
|
||||
public record OutboxConfig(
|
||||
String entryKind,
|
||||
int batchSize,
|
||||
OutboxBackoff backoff,
|
||||
Duration deadLetterRetention,
|
||||
Duration deliveredRetention,
|
||||
Duration pendingMaxAge) {
|
||||
|
||||
public OutboxConfig {
|
||||
if (entryKind == null || entryKind.isBlank()) {
|
||||
throw new IllegalArgumentException("entryKind must not be blank");
|
||||
}
|
||||
if (batchSize <= 0) {
|
||||
throw new IllegalArgumentException("batchSize must be positive, got " + batchSize);
|
||||
}
|
||||
if (backoff == null) {
|
||||
throw new IllegalArgumentException("backoff must not be null");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.events.outbox;
|
||||
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.jpa.entities.OutboxEntryEntity;
|
||||
|
||||
/**
|
||||
* Per-kind plug-in that knows how to actually deliver an
|
||||
* {@link OutboxEntryEntity}'s payload to its destination. The drainer
|
||||
* is generic — for each due row it calls {@link #deliver(KeycloakSession, OutboxEntryEntity)}
|
||||
* and transitions the row based on the returned {@link OutboxDeliveryResult}.
|
||||
*
|
||||
* <p>One handler per registered {@code entryKind}; the drainer locates
|
||||
* a handler by the row's {@code entryKind} value. Implementations are
|
||||
* free to interpret the {@code payload} and {@code metadata} columns
|
||||
* however they like — the store treats both as opaque text.
|
||||
*
|
||||
* <p>Synchronous by design — the handler returns when delivery has
|
||||
* either succeeded, failed retryably, or failed terminally. Long-poll
|
||||
* or fire-and-forget delivery semantics should be modelled by
|
||||
* returning {@link OutboxDeliveryResult#delivered()} as soon as the
|
||||
* payload has been handed off (e.g. enqueued in an external broker).
|
||||
*/
|
||||
public interface OutboxDeliveryHandler {
|
||||
|
||||
/**
|
||||
* The {@code entryKind} this handler is responsible for. Must
|
||||
* match the {@code entry_kind} column of every row this handler
|
||||
* will be invoked for; the drainer uses this to map locked rows
|
||||
* back to a handler.
|
||||
*/
|
||||
String entryKind();
|
||||
|
||||
/**
|
||||
* Attempts delivery for one outbox row. The drainer holds a
|
||||
* pessimistic write lock on the row for the duration of the call;
|
||||
* implementations should keep the call bounded (no indefinite
|
||||
* blocking) and avoid touching unrelated database rows so the
|
||||
* lock window stays tight.
|
||||
*
|
||||
* <p>Implementations may throw {@link RuntimeException}; the
|
||||
* drainer treats an uncaught exception as
|
||||
* {@link OutboxDeliveryOutcome#RETRY} and records the exception
|
||||
* class + message in {@code last_error}.
|
||||
*
|
||||
* <p>The returned {@link OutboxDeliveryResult}'s
|
||||
* {@code errorMessage} (if any) is persisted into the row's
|
||||
* {@code last_error} column. Handlers should pack as much
|
||||
* diagnostic detail (HTTP status, response body excerpt, exception
|
||||
* class) into that single string as fits the column
|
||||
* ({@code VARCHAR(2048)}).
|
||||
*/
|
||||
OutboxDeliveryResult deliver(KeycloakSession session, OutboxEntryEntity row);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.events.outbox;
|
||||
|
||||
/**
|
||||
* Per-row result of an {@link OutboxDeliveryHandler#deliver} invocation.
|
||||
* The drainer maps each outcome to a row transition:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #DELIVERED} → status {@code DELIVERED}, {@code deliveredAt} stamped.</li>
|
||||
* <li>{@link #RETRY} → attempts++, {@code next_attempt_at} pushed forward
|
||||
* per the kind's backoff curve. Promoted to {@link #DEAD_LETTER} once
|
||||
* attempts are exhausted.</li>
|
||||
* <li>{@link #DEAD_LETTER} → terminal failure regardless of remaining
|
||||
* attempt budget (e.g. permanent destination error). Status set to
|
||||
* {@code DEAD_LETTER} immediately.</li>
|
||||
* <li>{@link #ORPHANED} → handler decided the row is no longer
|
||||
* deliverable because the destination doesn't exist (e.g. the
|
||||
* receiver client was deleted). Treated as a non-retryable
|
||||
* terminal failure and recorded distinctly in metrics so
|
||||
* operators can spot stream/owner leakage.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum OutboxDeliveryOutcome {
|
||||
DELIVERED,
|
||||
RETRY,
|
||||
DEAD_LETTER,
|
||||
ORPHANED
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.events.outbox;
|
||||
|
||||
/**
|
||||
* Per-row result returned by an {@link OutboxDeliveryHandler#deliver}
|
||||
* invocation. Bundles the {@link OutboxDeliveryOutcome outcome} with a
|
||||
* single operator-facing {@code errorMessage} that the drainer
|
||||
* persists into the row's {@code last_error} column.
|
||||
*
|
||||
* <p>Handlers should put as much diagnostic detail into
|
||||
* {@code errorMessage} as fits the column ({@code VARCHAR(2048)}) —
|
||||
* status code, response body excerpt, exception class — so admin
|
||||
* dashboards and log scans show the failure cause without having to
|
||||
* cross-reference timestamps in server logs.
|
||||
*
|
||||
* <p>{@link #delivered()} / {@link #orphaned()} return null for the
|
||||
* message; the drainer clears {@code last_error} on a successful
|
||||
* delivery.
|
||||
*/
|
||||
public record OutboxDeliveryResult(OutboxDeliveryOutcome outcome,
|
||||
String errorMessage) {
|
||||
|
||||
public static OutboxDeliveryResult delivered() {
|
||||
return new OutboxDeliveryResult(OutboxDeliveryOutcome.DELIVERED, null);
|
||||
}
|
||||
|
||||
public static OutboxDeliveryResult retry(String errorMessage) {
|
||||
return new OutboxDeliveryResult(OutboxDeliveryOutcome.RETRY, errorMessage);
|
||||
}
|
||||
|
||||
public static OutboxDeliveryResult deadLetter(String errorMessage) {
|
||||
return new OutboxDeliveryResult(OutboxDeliveryOutcome.DEAD_LETTER, errorMessage);
|
||||
}
|
||||
|
||||
public static OutboxDeliveryResult orphaned() {
|
||||
return new OutboxDeliveryResult(OutboxDeliveryOutcome.ORPHANED, null);
|
||||
}
|
||||
|
||||
public static OutboxDeliveryResult orphaned(String errorMessage) {
|
||||
return new OutboxDeliveryResult(OutboxDeliveryOutcome.ORPHANED, errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.events.outbox;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.jpa.entities.OutboxEntryEntity;
|
||||
import org.keycloak.timer.ScheduledTask;
|
||||
import org.keycloak.utils.KeycloakSessionUtil;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
/**
|
||||
* Drains the generic outbox for one registered {@code entryKind}: locks
|
||||
* due PENDING rows, hands them off to the kind's
|
||||
* {@link OutboxDeliveryHandler}, and transitions each row based on the
|
||||
* returned {@link OutboxDeliveryOutcome}.
|
||||
*
|
||||
* <p>One drainer instance per registered kind. Each is wrapped in a
|
||||
* {@code ClusterAwareScheduledTaskRunner} at scheduling time so in an
|
||||
* HA deployment only one node drains a given kind per interval, even
|
||||
* though every node schedules the timer.
|
||||
*
|
||||
* <p>Concurrency within a single tick is cheap because rows are locked
|
||||
* {@code PESSIMISTIC_WRITE} via {@code FOR UPDATE SKIP LOCKED} by the
|
||||
* store, and each row is transitioned to a terminal state (DELIVERED /
|
||||
* back to PENDING with a future {@code next_attempt_at} / DEAD_LETTER)
|
||||
* before the transaction commits.
|
||||
*
|
||||
* <p>Per-tick housekeeping after the drain pass:
|
||||
* <ul>
|
||||
* <li>Promote rows whose {@code createdAt} is older than
|
||||
* {@link OutboxConfig#pendingMaxAge()} to DEAD_LETTER (backstop
|
||||
* so stuck rows can't sit forever).</li>
|
||||
* <li>Purge DELIVERED rows past {@link OutboxConfig#deliveredRetention()}.</li>
|
||||
* <li>Purge DEAD_LETTER rows past {@link OutboxConfig#deadLetterRetention()}.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class OutboxDrainerTask implements ScheduledTask {
|
||||
|
||||
private static final Logger log = Logger.getLogger(OutboxDrainerTask.class);
|
||||
|
||||
protected final OutboxConfig config;
|
||||
protected final OutboxDeliveryHandler handler;
|
||||
protected final Function<KeycloakSession, OutboxStore> storeFactory;
|
||||
|
||||
public OutboxDrainerTask(OutboxConfig config,
|
||||
OutboxDeliveryHandler handler,
|
||||
Function<KeycloakSession, OutboxStore> storeFactory) {
|
||||
this.config = Objects.requireNonNull(config, "config");
|
||||
this.handler = Objects.requireNonNull(handler, "handler");
|
||||
this.storeFactory = Objects.requireNonNull(storeFactory, "storeFactory");
|
||||
if (!Objects.equals(config.entryKind(), handler.entryKind())) {
|
||||
throw new IllegalArgumentException(
|
||||
"config.entryKind=" + config.entryKind()
|
||||
+ " does not match handler.entryKind=" + handler.entryKind());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(KeycloakSession session) {
|
||||
// Publish the drainer's KeycloakSession into the thread-local
|
||||
// so handler collaborators that haven't been refactored to
|
||||
// take an explicit session parameter still resolve correctly.
|
||||
KeycloakSession previous = KeycloakSessionUtil.getKeycloakSession();
|
||||
KeycloakSessionUtil.setKeycloakSession(session);
|
||||
try {
|
||||
OutboxStore store = storeFactory.apply(session);
|
||||
drain(session, store);
|
||||
promoteStaleQueuedToDeadLetter(store);
|
||||
purgeDeliveredOlderThanRetention(store);
|
||||
purgeDeadLetterOlderThanRetention(store);
|
||||
} finally {
|
||||
KeycloakSessionUtil.setKeycloakSession(previous);
|
||||
}
|
||||
}
|
||||
|
||||
protected void drain(KeycloakSession session, OutboxStore store) {
|
||||
List<OutboxEntryEntity> due = store.lockDueForDrain(config.entryKind(), config.batchSize());
|
||||
if (due.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
log.debugf("Outbox drainer processing %d due row(s) for entryKind=%s", due.size(), config.entryKind());
|
||||
for (OutboxEntryEntity row : due) {
|
||||
processOne(session, store, row);
|
||||
}
|
||||
}
|
||||
|
||||
protected void processOne(KeycloakSession session, OutboxStore store, OutboxEntryEntity row) {
|
||||
OutboxDeliveryResult result;
|
||||
try {
|
||||
result = handler.deliver(session, row);
|
||||
if (result == null) {
|
||||
result = OutboxDeliveryResult.retry("delivery handler returned null result");
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
log.warnf(e, "Outbox delivery handler threw — treating as RETRY. id=%s entryKind=%s correlationId=%s",
|
||||
row.getId(), row.getEntryKind(), row.getCorrelationId());
|
||||
String message = e.getMessage() == null
|
||||
? e.getClass().getSimpleName()
|
||||
: e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||
result = OutboxDeliveryResult.retry(message);
|
||||
}
|
||||
|
||||
switch (result.outcome()) {
|
||||
case DELIVERED -> {
|
||||
store.markDelivered(row);
|
||||
log.debugf("Outbox delivered. id=%s entryKind=%s correlationId=%s attempts=%d",
|
||||
row.getId(), row.getEntryKind(), row.getCorrelationId(), row.getAttempts());
|
||||
}
|
||||
case RETRY -> handleRetry(store, row, result.errorMessage());
|
||||
case DEAD_LETTER -> {
|
||||
String reason = result.errorMessage() != null ? result.errorMessage()
|
||||
: "handler returned DEAD_LETTER (attempt " + (row.getAttempts() + 1) + ")";
|
||||
store.markDeadLetter(row, reason);
|
||||
log.warnf("Outbox dead-lettered by handler. id=%s entryKind=%s correlationId=%s reason=%s",
|
||||
row.getId(), row.getEntryKind(), row.getCorrelationId(), reason);
|
||||
}
|
||||
case ORPHANED -> {
|
||||
String reason = result.errorMessage() != null ? result.errorMessage()
|
||||
: "handler returned ORPHANED (destination no longer exists)";
|
||||
store.markDeadLetter(row, reason);
|
||||
log.warnf("Outbox dead-lettered as orphan. id=%s entryKind=%s correlationId=%s",
|
||||
row.getId(), row.getEntryKind(), row.getCorrelationId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void handleRetry(OutboxStore store, OutboxEntryEntity row, String errorMessage) {
|
||||
int nextAttempts = row.getAttempts() + 1;
|
||||
String reason = errorMessage != null ? errorMessage : "delivery failed";
|
||||
if (config.backoff().isExhausted(nextAttempts)) {
|
||||
log.warnf("Outbox dead-lettered after %d attempts. id=%s entryKind=%s correlationId=%s",
|
||||
nextAttempts, row.getId(), row.getEntryKind(), row.getCorrelationId());
|
||||
store.markDeadLetter(row, reason);
|
||||
return;
|
||||
}
|
||||
Instant nextAttemptAt = config.backoff().computeNextAttemptAt(Instant.now(), nextAttempts);
|
||||
log.debugf("Outbox scheduling retry. id=%s attempts=%d nextAttemptAt=%s",
|
||||
row.getId(), nextAttempts, nextAttemptAt);
|
||||
store.recordFailure(row, nextAttemptAt, reason);
|
||||
}
|
||||
|
||||
protected void promoteStaleQueuedToDeadLetter(OutboxStore store) {
|
||||
Duration pendingMaxAge = config.pendingMaxAge();
|
||||
if (pendingMaxAge == null || pendingMaxAge.isZero() || pendingMaxAge.isNegative()) {
|
||||
return;
|
||||
}
|
||||
Instant cutoff = Instant.now().minus(pendingMaxAge);
|
||||
int promoted = store.promoteStaleQueuedToDeadLetter(config.entryKind(), cutoff,
|
||||
"queued exceeded pendingMaxAge");
|
||||
if (promoted > 0) {
|
||||
log.infof("Outbox promoted %d stale queued row(s) to DEAD_LETTER (entryKind=%s, pendingMaxAge=%s)",
|
||||
promoted, config.entryKind(), pendingMaxAge);
|
||||
}
|
||||
}
|
||||
|
||||
protected void purgeDeliveredOlderThanRetention(OutboxStore store) {
|
||||
Duration retention = config.deliveredRetention();
|
||||
if (retention == null || retention.isZero() || retention.isNegative()) {
|
||||
return;
|
||||
}
|
||||
store.purgeDeliveredOlderThan(config.entryKind(), Instant.now().minus(retention));
|
||||
}
|
||||
|
||||
protected void purgeDeadLetterOlderThanRetention(OutboxStore store) {
|
||||
Duration retention = config.deadLetterRetention();
|
||||
if (retention == null || retention.isZero() || retention.isNegative()) {
|
||||
return;
|
||||
}
|
||||
store.purgeDeadLetterOlderThan(config.entryKind(), Instant.now().minus(retention));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.events.outbox;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.EnumMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import jakarta.persistence.NoResultException;
|
||||
|
||||
import org.keycloak.connections.jpa.JpaConnectionProvider;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.jpa.entities.OutboxEntryEntity;
|
||||
import org.keycloak.models.jpa.entities.OutboxEntryStatus;
|
||||
|
||||
import org.hibernate.LockMode;
|
||||
import org.hibernate.query.SelectionQuery;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
/**
|
||||
* DAO over {@link OutboxEntryEntity}. Every method takes the row's
|
||||
* {@code entryKind} so the same store instance can serve multiple
|
||||
* subsystems sharing the underlying {@code OUTBOX_ENTRY} table — the
|
||||
* compound indexes on {@code (ENTRY_KIND, ...)} keep cross-kind
|
||||
* traffic from interfering with each other's hot paths.
|
||||
*
|
||||
* <p>Read patterns (drainer, admin stats, retention purges) and write
|
||||
* patterns (enqueue, transition, bulk delete) are split here so the
|
||||
* runtime drainer / cleanup tasks compose primitives rather than
|
||||
* inlining queries.
|
||||
*/
|
||||
public class OutboxStore {
|
||||
|
||||
private static final Logger log = Logger.getLogger(OutboxStore.class);
|
||||
|
||||
/**
|
||||
* Hard cap on the {@code last_error} column width — matches the
|
||||
* VARCHAR(2048) defined in the changelog. Truncation is applied
|
||||
* here so callers can pass arbitrarily long exception messages
|
||||
* without worrying about persistence-layer rejection.
|
||||
*/
|
||||
public static final int MAX_LAST_ERROR_LENGTH = 2048;
|
||||
|
||||
protected final KeycloakSession session;
|
||||
|
||||
public OutboxStore(KeycloakSession session) {
|
||||
this.session = Objects.requireNonNull(session, "session");
|
||||
}
|
||||
|
||||
protected EntityManager getEntityManager() {
|
||||
return session.getProvider(JpaConnectionProvider.class).getEntityManager();
|
||||
}
|
||||
|
||||
// -- Enqueue -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Inserts a fresh PENDING row, deduplicating on
|
||||
* {@code (entryKind, ownerId, correlationId)}. Returns the id of
|
||||
* the persisted (or pre-existing) row so the caller can correlate
|
||||
* across at-least-once enqueue paths.
|
||||
*/
|
||||
public String enqueuePending(String entryKind,
|
||||
String realmId,
|
||||
String ownerId,
|
||||
String containerId,
|
||||
String correlationId,
|
||||
String entryType,
|
||||
String payload,
|
||||
String metadata) {
|
||||
return enqueueInStatus(OutboxEntryStatus.PENDING, entryKind, realmId, ownerId, containerId,
|
||||
correlationId, entryType, payload, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a fresh HELD row — used when the upstream channel is in
|
||||
* a paused state at enqueue time (e.g. SSF stream paused) and the
|
||||
* row should not be drained until {@link #releaseHeldForOwner} is
|
||||
* called. Same dedup contract as {@link #enqueuePending}.
|
||||
*/
|
||||
public String enqueueHeld(String entryKind,
|
||||
String realmId,
|
||||
String ownerId,
|
||||
String containerId,
|
||||
String correlationId,
|
||||
String entryType,
|
||||
String payload,
|
||||
String metadata) {
|
||||
return enqueueInStatus(OutboxEntryStatus.HELD, entryKind, realmId, ownerId, containerId,
|
||||
correlationId, entryType, payload, metadata);
|
||||
}
|
||||
|
||||
protected String enqueueInStatus(OutboxEntryStatus status,
|
||||
String entryKind,
|
||||
String realmId,
|
||||
String ownerId,
|
||||
String containerId,
|
||||
String correlationId,
|
||||
String entryType,
|
||||
String payload,
|
||||
String metadata) {
|
||||
Objects.requireNonNull(status, "status");
|
||||
Objects.requireNonNull(entryKind, "entryKind");
|
||||
Objects.requireNonNull(realmId, "realmId");
|
||||
Objects.requireNonNull(ownerId, "ownerId");
|
||||
Objects.requireNonNull(correlationId, "correlationId");
|
||||
Objects.requireNonNull(entryType, "entryType");
|
||||
Objects.requireNonNull(payload, "payload");
|
||||
|
||||
// Optimistic local fast-path: if we already wrote the row in this
|
||||
// transaction (or it's recent enough to be in the row cache),
|
||||
// skip the INSERT and return the existing id. Correctness does
|
||||
// not depend on this — the storage-engine ON CONFLICT DO NOTHING
|
||||
// below dedups regardless. Cheap savings for retry-style callers.
|
||||
OutboxEntryEntity existing = findByOwnerAndCorrelationId(entryKind, ownerId, correlationId);
|
||||
if (existing != null) {
|
||||
log.debugf("Outbox enqueue deduplicated. entryKind=%s ownerId=%s correlationId=%s existingId=%s status=%s",
|
||||
entryKind, ownerId, correlationId, existing.getId(), existing.getStatus());
|
||||
return existing.getId();
|
||||
}
|
||||
|
||||
Instant now = Instant.now();
|
||||
String id = generateEntryId();
|
||||
// Race-safe insert. ON CONFLICT DO NOTHING (HQL, Hibernate 6.5+)
|
||||
// resolves the dedup race at the storage engine: a concurrent
|
||||
// sibling insert of the same (entryKind, ownerId, correlationId)
|
||||
// triple causes our INSERT to no-op (executeUpdate returns 0)
|
||||
// instead of throwing ConstraintViolationException and marking
|
||||
// the JTA transaction rollback-only. The caller's surrounding
|
||||
// transaction therefore survives the race cleanly.
|
||||
//
|
||||
// next_attempt_at is meaningful only for PENDING rows the drainer
|
||||
// locks; HELD rows ignore it but the column is NOT NULL, so we
|
||||
// set it to "now" as a harmless seed.
|
||||
int inserted = getEntityManager()
|
||||
.createNamedQuery("OutboxEntryEntity.insertIfAbsent")
|
||||
.setParameter("id", id)
|
||||
.setParameter("entryKind", entryKind)
|
||||
.setParameter("realmId", realmId)
|
||||
.setParameter("ownerId", ownerId)
|
||||
.setParameter("containerId", containerId)
|
||||
.setParameter("correlationId", correlationId)
|
||||
.setParameter("entryType", entryType)
|
||||
.setParameter("payload", payload)
|
||||
.setParameter("metadata", metadata)
|
||||
.setParameter("status", status)
|
||||
.setParameter("attempts", 0)
|
||||
.setParameter("nextAttemptAt", now)
|
||||
.setParameter("createdAt", now)
|
||||
.executeUpdate();
|
||||
|
||||
if (inserted == 0) {
|
||||
// Lost the dedup race against a sibling. Their row is in
|
||||
// storage and will be drained, so the event is captured
|
||||
// at-most-once as intended. Re-fetch and return their id
|
||||
// so the caller can correlate.
|
||||
OutboxEntryEntity racingRow = findByOwnerAndCorrelationId(entryKind, ownerId, correlationId);
|
||||
log.debugf("Outbox enqueue lost dedup race; sibling already inserted. "
|
||||
+ "entryKind=%s realmId=%s ownerId=%s correlationId=%s racingId=%s",
|
||||
entryKind, realmId, ownerId, correlationId,
|
||||
racingRow != null ? racingRow.getId() : "(unresolved)");
|
||||
return racingRow != null ? racingRow.getId() : id;
|
||||
}
|
||||
|
||||
log.debugf("Outbox enqueued. id=%s status=%s entryKind=%s realmId=%s ownerId=%s containerId=%s correlationId=%s entryType=%s",
|
||||
id, status, entryKind, realmId, ownerId, containerId, correlationId, entryType);
|
||||
return id;
|
||||
}
|
||||
|
||||
protected String generateEntryId() {
|
||||
return UUID.randomUUID().toString();
|
||||
}
|
||||
|
||||
public OutboxEntryEntity findById(String id) {
|
||||
return getEntityManager().find(OutboxEntryEntity.class, id);
|
||||
}
|
||||
|
||||
public OutboxEntryEntity findByOwnerAndCorrelationId(String entryKind, String ownerId, String correlationId) {
|
||||
try {
|
||||
return getEntityManager()
|
||||
.createNamedQuery("OutboxEntryEntity.findByOwnerAndCorrelationId", OutboxEntryEntity.class)
|
||||
.setParameter("entryKind", entryKind)
|
||||
.setParameter("ownerId", ownerId)
|
||||
.setParameter("correlationId", correlationId)
|
||||
.getSingleResult();
|
||||
} catch (NoResultException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// -- Drainer reads -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Locks up to {@code limit} due PENDING rows for delivery in the
|
||||
* current transaction. Uses {@code FOR UPDATE SKIP LOCKED} so
|
||||
* cluster-aware drainers don't fight for the same rows.
|
||||
*/
|
||||
public List<OutboxEntryEntity> lockDueForDrain(String entryKind, int limit) {
|
||||
Objects.requireNonNull(entryKind, "entryKind");
|
||||
if (limit <= 0) {
|
||||
throw new IllegalArgumentException("limit must be positive, got " + limit);
|
||||
}
|
||||
var query = getEntityManager()
|
||||
.createNamedQuery("OutboxEntryEntity.findDueForDrain", OutboxEntryEntity.class)
|
||||
.setParameter("entryKind", entryKind)
|
||||
.setParameter("status", OutboxEntryStatus.PENDING)
|
||||
.setParameter("now", Instant.now())
|
||||
.setMaxResults(limit)
|
||||
.setLockMode(LockModeType.PESSIMISTIC_WRITE);
|
||||
// Skip rows another tick / node already holds — a sibling
|
||||
// drainer will get them on its own pass without blocking us.
|
||||
try {
|
||||
query.unwrap(SelectionQuery.class).setHibernateLockMode(LockMode.UPGRADE_SKIPLOCKED);
|
||||
} catch (RuntimeException e) {
|
||||
log.debugf(e, "Could not set UPGRADE_SKIPLOCKED on outbox drain query — proceeding without skip-locked");
|
||||
}
|
||||
return query.getResultList();
|
||||
}
|
||||
|
||||
// -- Row transitions ---------------------------------------------------
|
||||
|
||||
public void markDelivered(OutboxEntryEntity entity) {
|
||||
entity.setAttempts(entity.getAttempts() + 1);
|
||||
entity.setStatus(OutboxEntryStatus.DELIVERED);
|
||||
entity.setDeliveredAt(Instant.now());
|
||||
entity.setLastError(null);
|
||||
getEntityManager().merge(entity);
|
||||
}
|
||||
|
||||
public void recordFailure(OutboxEntryEntity entity, Instant nextAttemptAt, String lastError) {
|
||||
entity.setAttempts(entity.getAttempts() + 1);
|
||||
entity.setNextAttemptAt(nextAttemptAt);
|
||||
entity.setLastError(truncateError(lastError));
|
||||
getEntityManager().merge(entity);
|
||||
}
|
||||
|
||||
public void markDeadLetter(OutboxEntryEntity entity, String lastError) {
|
||||
entity.setAttempts(entity.getAttempts() + 1);
|
||||
entity.setStatus(OutboxEntryStatus.DEAD_LETTER);
|
||||
entity.setLastError(truncateError(lastError));
|
||||
getEntityManager().merge(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-promotes every {@link OutboxEntryStatus#QUEUED queued} row
|
||||
* in the given kind whose {@code createdAt} is older than the
|
||||
* supplied cutoff to {@code DEAD_LETTER}. Used by the drainer as a
|
||||
* backstop so rows that get stuck in PENDING/HELD eventually
|
||||
* graduate to a terminal state and are caught by the dead-letter
|
||||
* retention purge.
|
||||
*
|
||||
* <p>Does not bump {@code attempts}: these rows didn't actually
|
||||
* retry, they aged out. The {@code last_error} captures the reason.
|
||||
*/
|
||||
public int promoteStaleQueuedToDeadLetter(String entryKind, Instant cutoff, String reason) {
|
||||
Objects.requireNonNull(entryKind, "entryKind");
|
||||
Objects.requireNonNull(cutoff, "cutoff");
|
||||
return getEntityManager()
|
||||
.createNamedQuery("OutboxEntryEntity.promoteStaleQueuedToDeadLetter")
|
||||
.setParameter("entryKind", entryKind)
|
||||
.setParameter("dead", OutboxEntryStatus.DEAD_LETTER)
|
||||
.setParameter("statuses", OutboxEntryStatus.QUEUED)
|
||||
.setParameter("olderThan", cutoff)
|
||||
.setParameter("reason", truncateError(reason))
|
||||
.executeUpdate();
|
||||
}
|
||||
|
||||
// -- Stats (admin endpoints) -------------------------------------------
|
||||
|
||||
public Map<OutboxEntryStatus, Long> countStatusesForRealm(String entryKind, String realmId) {
|
||||
return groupedCountQuery("OutboxEntryEntity.countByEntryKindRealmAndStatus",
|
||||
entryKind, "realmId", realmId);
|
||||
}
|
||||
|
||||
public Map<OutboxEntryStatus, Long> countStatusesForOwner(String entryKind, String ownerId) {
|
||||
return groupedCountQuery("OutboxEntryEntity.countByEntryKindOwnerAndStatus",
|
||||
entryKind, "ownerId", ownerId);
|
||||
}
|
||||
|
||||
public Map<OutboxEntryStatus, Instant> oldestCreatedAtPerStatusForRealm(String entryKind, String realmId) {
|
||||
return groupedInstantQuery("OutboxEntryEntity.oldestCreatedAtByEntryKindRealmAndStatus",
|
||||
entryKind, "realmId", realmId);
|
||||
}
|
||||
|
||||
public Map<OutboxEntryStatus, Instant> oldestCreatedAtPerStatusForOwner(String entryKind, String ownerId) {
|
||||
return groupedInstantQuery("OutboxEntryEntity.oldestCreatedAtByEntryKindOwnerAndStatus",
|
||||
entryKind, "ownerId", ownerId);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<OutboxEntryStatus, Long> groupedCountQuery(String namedQuery, String entryKind,
|
||||
String scopeParam, String scopeValue) {
|
||||
Objects.requireNonNull(entryKind, "entryKind");
|
||||
Objects.requireNonNull(scopeValue, scopeParam);
|
||||
List<Object[]> rows = getEntityManager()
|
||||
.createNamedQuery(namedQuery)
|
||||
.setParameter("entryKind", entryKind)
|
||||
.setParameter(scopeParam, scopeValue)
|
||||
.getResultList();
|
||||
Map<OutboxEntryStatus, Long> counts = new EnumMap<>(OutboxEntryStatus.class);
|
||||
for (Object[] row : rows) {
|
||||
counts.put((OutboxEntryStatus) row[0], ((Number) row[1]).longValue());
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<OutboxEntryStatus, Instant> groupedInstantQuery(String namedQuery, String entryKind,
|
||||
String scopeParam, String scopeValue) {
|
||||
Objects.requireNonNull(entryKind, "entryKind");
|
||||
Objects.requireNonNull(scopeValue, scopeParam);
|
||||
List<Object[]> rows = getEntityManager()
|
||||
.createNamedQuery(namedQuery)
|
||||
.setParameter("entryKind", entryKind)
|
||||
.setParameter(scopeParam, scopeValue)
|
||||
.getResultList();
|
||||
Map<OutboxEntryStatus, Instant> oldest = new EnumMap<>(OutboxEntryStatus.class);
|
||||
for (Object[] row : rows) {
|
||||
oldest.put((OutboxEntryStatus) row[0], (Instant) row[1]);
|
||||
}
|
||||
return oldest;
|
||||
}
|
||||
|
||||
// -- Receiver-driven reads (POLL) --------------------------------------
|
||||
|
||||
/**
|
||||
* Locks up to {@code limit} PENDING rows for a receiver-driven
|
||||
* read (e.g. SSF POLL). Uses {@code FOR UPDATE SKIP LOCKED} so a
|
||||
* concurrent receiver request to the same owner doesn't block.
|
||||
* Unlike {@link #lockDueForDrain(String, int)} this does not gate
|
||||
* on {@code next_attempt_at} — receiver-pulled rows are served
|
||||
* on demand regardless of any backoff schedule.
|
||||
*/
|
||||
public List<OutboxEntryEntity> lockPendingForOwner(String entryKind, String ownerId, int limit) {
|
||||
Objects.requireNonNull(entryKind, "entryKind");
|
||||
Objects.requireNonNull(ownerId, "ownerId");
|
||||
if (limit <= 0) {
|
||||
throw new IllegalArgumentException("limit must be positive, got " + limit);
|
||||
}
|
||||
var query = getEntityManager()
|
||||
.createNamedQuery("OutboxEntryEntity.findPendingForOwner", OutboxEntryEntity.class)
|
||||
.setParameter("entryKind", entryKind)
|
||||
.setParameter("ownerId", ownerId)
|
||||
.setParameter("status", OutboxEntryStatus.PENDING)
|
||||
.setMaxResults(limit)
|
||||
.setLockMode(LockModeType.PESSIMISTIC_WRITE);
|
||||
try {
|
||||
query.unwrap(SelectionQuery.class).setHibernateLockMode(LockMode.UPGRADE_SKIPLOCKED);
|
||||
} catch (RuntimeException e) {
|
||||
log.debugf(e, "Could not set UPGRADE_SKIPLOCKED on owner-pending query — proceeding without skip-locked");
|
||||
}
|
||||
return query.getResultList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts an owner's rows in a given status. Used by receiver-driven
|
||||
* read paths to decide whether to advertise more available items
|
||||
* after returning a short batch.
|
||||
*/
|
||||
public long countForOwnerByStatus(String entryKind, String ownerId, OutboxEntryStatus status) {
|
||||
Objects.requireNonNull(entryKind, "entryKind");
|
||||
Objects.requireNonNull(ownerId, "ownerId");
|
||||
Objects.requireNonNull(status, "status");
|
||||
return getEntityManager()
|
||||
.createNamedQuery("OutboxEntryEntity.countByEntryKindOwnerStatus", Long.class)
|
||||
.setParameter("entryKind", entryKind)
|
||||
.setParameter("ownerId", ownerId)
|
||||
.setParameter("status", status)
|
||||
.getSingleResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver-driven ACK for the supplied correlation ids. Matching
|
||||
* PENDING rows owned by the given owner transition to DELIVERED.
|
||||
* Idempotent and silently scoped: ids the receiver doesn't own
|
||||
* (different owner) and ids already terminal don't appear in the
|
||||
* lookup result, so no error and no leakage of row existence.
|
||||
*
|
||||
* @return the set of correlation ids that were transitioned to
|
||||
* DELIVERED.
|
||||
*/
|
||||
public Set<String> ackPendingForOwner(String entryKind, String ownerId, Collection<String> correlationIds) {
|
||||
Objects.requireNonNull(entryKind, "entryKind");
|
||||
Objects.requireNonNull(ownerId, "ownerId");
|
||||
if (correlationIds == null || correlationIds.isEmpty()) {
|
||||
return Set.of();
|
||||
}
|
||||
List<OutboxEntryEntity> rows = getEntityManager()
|
||||
.createNamedQuery("OutboxEntryEntity.findPendingForOwnerByCorrelationIds", OutboxEntryEntity.class)
|
||||
.setParameter("entryKind", entryKind)
|
||||
.setParameter("ownerId", ownerId)
|
||||
.setParameter("correlationIds", correlationIds)
|
||||
.setParameter("status", OutboxEntryStatus.PENDING)
|
||||
.getResultList();
|
||||
if (rows.isEmpty()) {
|
||||
return Set.of();
|
||||
}
|
||||
Set<String> acked = new LinkedHashSet<>(rows.size());
|
||||
for (OutboxEntryEntity row : rows) {
|
||||
markDelivered(row);
|
||||
acked.add(row.getCorrelationId());
|
||||
}
|
||||
log.debugf("Outbox ack. entryKind=%s ownerId=%s ackedCount=%d", entryKind, ownerId, acked.size());
|
||||
return acked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver-driven NACK. Matching PENDING rows owned by the given
|
||||
* owner transition to DEAD_LETTER carrying the receiver-supplied
|
||||
* reason. For receiver-pulled flows, DEAD_LETTER is reached only
|
||||
* via this explicit NACK path (no transmitter-side retry-exhaustion
|
||||
* counter to bump). Idempotent and silently scoped, like
|
||||
* {@link #ackPendingForOwner}.
|
||||
*
|
||||
* @return the set of correlation ids that were transitioned to
|
||||
* DEAD_LETTER.
|
||||
*/
|
||||
public Set<String> nackPendingForOwner(String entryKind, String ownerId, Map<String, String> reasonByCorrelationId) {
|
||||
Objects.requireNonNull(entryKind, "entryKind");
|
||||
Objects.requireNonNull(ownerId, "ownerId");
|
||||
if (reasonByCorrelationId == null || reasonByCorrelationId.isEmpty()) {
|
||||
return Set.of();
|
||||
}
|
||||
List<OutboxEntryEntity> rows = getEntityManager()
|
||||
.createNamedQuery("OutboxEntryEntity.findPendingForOwnerByCorrelationIds", OutboxEntryEntity.class)
|
||||
.setParameter("entryKind", entryKind)
|
||||
.setParameter("ownerId", ownerId)
|
||||
.setParameter("correlationIds", reasonByCorrelationId.keySet())
|
||||
.setParameter("status", OutboxEntryStatus.PENDING)
|
||||
.getResultList();
|
||||
if (rows.isEmpty()) {
|
||||
return Set.of();
|
||||
}
|
||||
Set<String> nacked = new LinkedHashSet<>(rows.size());
|
||||
for (OutboxEntryEntity row : rows) {
|
||||
String reason = reasonByCorrelationId.get(row.getCorrelationId());
|
||||
markDeadLetter(row, reason != null ? reason : "receiver nack");
|
||||
nacked.add(row.getCorrelationId());
|
||||
}
|
||||
log.debugf("Outbox nack. entryKind=%s ownerId=%s nackedCount=%d", entryKind, ownerId, nacked.size());
|
||||
return nacked;
|
||||
}
|
||||
|
||||
// -- Owner-scoped lifecycle (pause/resume/disable/migrate) -------------
|
||||
|
||||
/**
|
||||
* Bulk-transitions every {@link OutboxEntryStatus#HELD HELD} row
|
||||
* for the owner back to {@link OutboxEntryStatus#PENDING PENDING}
|
||||
* with {@code next_attempt_at = now} so the drainer picks them up
|
||||
* on its next tick. Symmetric to {@link #holdPendingForOwner}.
|
||||
*
|
||||
* @return the number of rows that transitioned out of HELD.
|
||||
*/
|
||||
public int releaseHeldForOwner(String entryKind, String ownerId) {
|
||||
Objects.requireNonNull(entryKind, "entryKind");
|
||||
Objects.requireNonNull(ownerId, "ownerId");
|
||||
int released = getEntityManager()
|
||||
.createNamedQuery("OutboxEntryEntity.releaseHeldForOwner")
|
||||
.setParameter("entryKind", entryKind)
|
||||
.setParameter("ownerId", ownerId)
|
||||
.setParameter("pending", OutboxEntryStatus.PENDING)
|
||||
.setParameter("held", OutboxEntryStatus.HELD)
|
||||
.setParameter("now", Instant.now())
|
||||
.executeUpdate();
|
||||
if (released > 0) {
|
||||
log.debugf("Outbox released %d held row(s) for entryKind=%s ownerId=%s", released, entryKind, ownerId);
|
||||
}
|
||||
return released;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-transitions every PENDING row for the owner to HELD —
|
||||
* "park" the queue when the upstream channel pauses (e.g. SSF
|
||||
* stream paused / disabled).
|
||||
*
|
||||
* @return the number of rows that transitioned PENDING → HELD.
|
||||
*/
|
||||
public int holdPendingForOwner(String entryKind, String ownerId) {
|
||||
Objects.requireNonNull(entryKind, "entryKind");
|
||||
Objects.requireNonNull(ownerId, "ownerId");
|
||||
int held = getEntityManager()
|
||||
.createNamedQuery("OutboxEntryEntity.holdPendingForOwner")
|
||||
.setParameter("entryKind", entryKind)
|
||||
.setParameter("ownerId", ownerId)
|
||||
.setParameter("held", OutboxEntryStatus.HELD)
|
||||
.setParameter("pending", OutboxEntryStatus.PENDING)
|
||||
.executeUpdate();
|
||||
if (held > 0) {
|
||||
log.debugf("Outbox held %d pending row(s) for entryKind=%s ownerId=%s", held, entryKind, ownerId);
|
||||
}
|
||||
return held;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dead-letters every queued (PENDING + HELD) row for the owner
|
||||
* with the supplied reason. Used when the upstream forbids
|
||||
* holding (e.g. SSF stream disabled) and the rows must be
|
||||
* discarded rather than parked.
|
||||
*/
|
||||
public int deadLetterQueuedForOwner(String entryKind, String ownerId, String reason) {
|
||||
Objects.requireNonNull(entryKind, "entryKind");
|
||||
Objects.requireNonNull(ownerId, "ownerId");
|
||||
Objects.requireNonNull(reason, "reason");
|
||||
int updated = getEntityManager()
|
||||
.createNamedQuery("OutboxEntryEntity.deadLetterQueuedForOwner")
|
||||
.setParameter("entryKind", entryKind)
|
||||
.setParameter("ownerId", ownerId)
|
||||
.setParameter("dead", OutboxEntryStatus.DEAD_LETTER)
|
||||
.setParameter("statuses", OutboxEntryStatus.QUEUED)
|
||||
.setParameter("reason", truncateError(reason))
|
||||
.executeUpdate();
|
||||
if (updated > 0) {
|
||||
log.debugf("Outbox dead-lettered %d queued row(s) for entryKind=%s ownerId=%s", updated, entryKind, ownerId);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dead-letters queued rows for the owner whose {@code entryType}
|
||||
* is not in {@code allowedTypes}. Used when the upstream narrows
|
||||
* its accepted-type set (e.g. SSF receiver narrowing
|
||||
* {@code events_requested}) so already-signed rows of dropped
|
||||
* types stop being delivered without losing the audit trail.
|
||||
*
|
||||
* <p>If {@code allowedTypes} is empty, this method falls back to
|
||||
* {@link #deadLetterQueuedForOwner} since SQL {@code NOT IN ()}
|
||||
* is implementation-defined.
|
||||
*/
|
||||
public int deadLetterQueuedForOwnerNotMatchingTypes(String entryKind, String ownerId,
|
||||
Collection<String> allowedTypes,
|
||||
String reason) {
|
||||
Objects.requireNonNull(entryKind, "entryKind");
|
||||
Objects.requireNonNull(ownerId, "ownerId");
|
||||
Objects.requireNonNull(allowedTypes, "allowedTypes");
|
||||
Objects.requireNonNull(reason, "reason");
|
||||
if (allowedTypes.isEmpty()) {
|
||||
return deadLetterQueuedForOwner(entryKind, ownerId, reason);
|
||||
}
|
||||
int updated = getEntityManager()
|
||||
.createNamedQuery("OutboxEntryEntity.deadLetterQueuedForOwnerNotMatchingTypes")
|
||||
.setParameter("entryKind", entryKind)
|
||||
.setParameter("ownerId", ownerId)
|
||||
.setParameter("dead", OutboxEntryStatus.DEAD_LETTER)
|
||||
.setParameter("statuses", OutboxEntryStatus.QUEUED)
|
||||
.setParameter("allowedTypes", allowedTypes)
|
||||
.setParameter("reason", truncateError(reason))
|
||||
.executeUpdate();
|
||||
if (updated > 0) {
|
||||
log.debugf("Outbox dead-lettered %d queued row(s) for entryKind=%s ownerId=%s with entryType outside the allow-list",
|
||||
updated, entryKind, ownerId);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates queued rows for the owner from one entryKind to another
|
||||
* (e.g. SSF receiver flipping push ↔ poll). Terminal rows
|
||||
* (DELIVERED, DEAD_LETTER) are left under the previous kind — they
|
||||
* are audit / dedup artifacts of the old channel.
|
||||
*
|
||||
* @return the number of rows whose entryKind was migrated.
|
||||
*/
|
||||
public int migrateEntryKindForOwner(String currentKind, String newKind, String ownerId) {
|
||||
Objects.requireNonNull(currentKind, "currentKind");
|
||||
Objects.requireNonNull(newKind, "newKind");
|
||||
Objects.requireNonNull(ownerId, "ownerId");
|
||||
int migrated = getEntityManager()
|
||||
.createNamedQuery("OutboxEntryEntity.migrateEntryKindForOwner")
|
||||
.setParameter("currentKind", currentKind)
|
||||
.setParameter("newKind", newKind)
|
||||
.setParameter("ownerId", ownerId)
|
||||
.setParameter("statuses", OutboxEntryStatus.QUEUED)
|
||||
.executeUpdate();
|
||||
if (migrated > 0) {
|
||||
log.debugf("Outbox migrated %d row(s) for ownerId=%s from %s to %s", migrated, ownerId, currentKind, newKind);
|
||||
}
|
||||
return migrated;
|
||||
}
|
||||
|
||||
// -- Admin / cascade deletes -------------------------------------------
|
||||
|
||||
public int deleteByRealm(String entryKind, String realmId) {
|
||||
return scopedDelete("OutboxEntryEntity.deleteByEntryKindAndRealm",
|
||||
entryKind, "realmId", realmId);
|
||||
}
|
||||
|
||||
public int deleteByOwner(String entryKind, String ownerId) {
|
||||
return scopedDelete("OutboxEntryEntity.deleteByEntryKindAndOwner",
|
||||
entryKind, "ownerId", ownerId);
|
||||
}
|
||||
|
||||
public int deleteByRealmAndStatus(String entryKind, String realmId, OutboxEntryStatus status) {
|
||||
Objects.requireNonNull(status, "status");
|
||||
return getEntityManager()
|
||||
.createNamedQuery("OutboxEntryEntity.deleteByEntryKindRealmAndStatus")
|
||||
.setParameter("entryKind", entryKind)
|
||||
.setParameter("realmId", realmId)
|
||||
.setParameter("status", status)
|
||||
.executeUpdate();
|
||||
}
|
||||
|
||||
public int deleteByRealmAndStatusOlderThan(String entryKind, String realmId,
|
||||
OutboxEntryStatus status, Instant cutoff) {
|
||||
Objects.requireNonNull(status, "status");
|
||||
Objects.requireNonNull(cutoff, "cutoff");
|
||||
return getEntityManager()
|
||||
.createNamedQuery("OutboxEntryEntity.deleteByEntryKindRealmAndStatusOlderThan")
|
||||
.setParameter("entryKind", entryKind)
|
||||
.setParameter("realmId", realmId)
|
||||
.setParameter("status", status)
|
||||
.setParameter("olderThan", cutoff)
|
||||
.executeUpdate();
|
||||
}
|
||||
|
||||
public int deleteByOwnerAndStatus(String entryKind, String ownerId, OutboxEntryStatus status) {
|
||||
Objects.requireNonNull(status, "status");
|
||||
return getEntityManager()
|
||||
.createNamedQuery("OutboxEntryEntity.deleteByEntryKindOwnerAndStatus")
|
||||
.setParameter("entryKind", entryKind)
|
||||
.setParameter("ownerId", ownerId)
|
||||
.setParameter("status", status)
|
||||
.executeUpdate();
|
||||
}
|
||||
|
||||
public int deleteByOwnerAndStatusOlderThan(String entryKind, String ownerId,
|
||||
OutboxEntryStatus status, Instant cutoff) {
|
||||
Objects.requireNonNull(status, "status");
|
||||
Objects.requireNonNull(cutoff, "cutoff");
|
||||
return getEntityManager()
|
||||
.createNamedQuery("OutboxEntryEntity.deleteByEntryKindOwnerAndStatusOlderThan")
|
||||
.setParameter("entryKind", entryKind)
|
||||
.setParameter("ownerId", ownerId)
|
||||
.setParameter("status", status)
|
||||
.setParameter("olderThan", cutoff)
|
||||
.executeUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-deletes every {@link OutboxEntryStatus#QUEUED queued} row
|
||||
* in the realm. Single-DML counterpart used by the realm-scoped
|
||||
* "purge queued" admin endpoint.
|
||||
*/
|
||||
public int deleteQueuedByRealm(String entryKind, String realmId) {
|
||||
Objects.requireNonNull(entryKind, "entryKind");
|
||||
Objects.requireNonNull(realmId, "realmId");
|
||||
return getEntityManager()
|
||||
.createNamedQuery("OutboxEntryEntity.deleteQueuedByEntryKindAndRealm")
|
||||
.setParameter("entryKind", entryKind)
|
||||
.setParameter("realmId", realmId)
|
||||
.setParameter("statuses", OutboxEntryStatus.QUEUED)
|
||||
.executeUpdate();
|
||||
}
|
||||
|
||||
public int deleteQueuedByOwner(String entryKind, String ownerId) {
|
||||
Objects.requireNonNull(entryKind, "entryKind");
|
||||
Objects.requireNonNull(ownerId, "ownerId");
|
||||
return getEntityManager()
|
||||
.createNamedQuery("OutboxEntryEntity.deleteQueuedByEntryKindAndOwner")
|
||||
.setParameter("entryKind", entryKind)
|
||||
.setParameter("ownerId", ownerId)
|
||||
.setParameter("statuses", OutboxEntryStatus.QUEUED)
|
||||
.executeUpdate();
|
||||
}
|
||||
|
||||
// -- Retention purges (drainer housekeeping) ---------------------------
|
||||
|
||||
public int purgeDeliveredOlderThan(String entryKind, Instant cutoff) {
|
||||
Objects.requireNonNull(entryKind, "entryKind");
|
||||
Objects.requireNonNull(cutoff, "cutoff");
|
||||
int purged = getEntityManager()
|
||||
.createNamedQuery("OutboxEntryEntity.purgeByEntryKindStatusOlderThanDelivered")
|
||||
.setParameter("entryKind", entryKind)
|
||||
.setParameter("status", OutboxEntryStatus.DELIVERED)
|
||||
.setParameter("olderThan", cutoff)
|
||||
.executeUpdate();
|
||||
if (purged > 0) {
|
||||
log.debugf("Outbox purged %d DELIVERED row(s) older than %s for entryKind=%s", purged, cutoff, entryKind);
|
||||
}
|
||||
return purged;
|
||||
}
|
||||
|
||||
public int purgeDeadLetterOlderThan(String entryKind, Instant cutoff) {
|
||||
Objects.requireNonNull(entryKind, "entryKind");
|
||||
Objects.requireNonNull(cutoff, "cutoff");
|
||||
int purged = getEntityManager()
|
||||
.createNamedQuery("OutboxEntryEntity.purgeByEntryKindStatusOlderThanCreated")
|
||||
.setParameter("entryKind", entryKind)
|
||||
.setParameter("status", OutboxEntryStatus.DEAD_LETTER)
|
||||
.setParameter("olderThan", cutoff)
|
||||
.executeUpdate();
|
||||
if (purged > 0) {
|
||||
log.debugf("Outbox purged %d DEAD_LETTER row(s) older than %s for entryKind=%s", purged, cutoff, entryKind);
|
||||
}
|
||||
return purged;
|
||||
}
|
||||
|
||||
// -- Helpers -----------------------------------------------------------
|
||||
|
||||
private int scopedDelete(String namedQuery, String entryKind, String scopeParam, String scopeValue) {
|
||||
Objects.requireNonNull(entryKind, "entryKind");
|
||||
Objects.requireNonNull(scopeValue, scopeParam);
|
||||
return getEntityManager()
|
||||
.createNamedQuery(namedQuery)
|
||||
.setParameter("entryKind", entryKind)
|
||||
.setParameter(scopeParam, scopeValue)
|
||||
.executeUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates an error message to the column width with an ellipsis
|
||||
* marker. Returns {@code null} unchanged so an explicit "no error"
|
||||
* value (used by {@code markDelivered}) survives.
|
||||
*/
|
||||
protected String truncateError(String error) {
|
||||
if (error == null) {
|
||||
return null;
|
||||
}
|
||||
if (error.length() <= MAX_LAST_ERROR_LENGTH) {
|
||||
return error;
|
||||
}
|
||||
return error.substring(0, MAX_LAST_ERROR_LENGTH - 3) + "...";
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,11 @@ public class JpaClientProviderFactory implements ClientProviderFactory {
|
||||
if (searchableAttrsArr != null) {
|
||||
s.addAll(Arrays.asList(searchableAttrsArr));
|
||||
}
|
||||
if (Profile.isFeatureEnabled(Profile.Feature.SSF)) {
|
||||
// needed to find SSF streams configured on SSF Receiver clients
|
||||
s.add("ssf.enabled");
|
||||
s.add("ssf.streamId");
|
||||
}
|
||||
clientSearchableAttributes = Collections.unmodifiableSet(s);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.models.jpa.entities;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.NamedQueries;
|
||||
import jakarta.persistence.NamedQuery;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import org.hibernate.annotations.Nationalized;
|
||||
|
||||
/**
|
||||
* Generic durable outbox row: a single message persisted for asynchronous,
|
||||
* at-least-once delivery by a feature-scoped drainer.
|
||||
*
|
||||
* <p>Backed by the {@code OUTBOX_ENTRY} table created in
|
||||
* {@code META-INF/jpa-changelog-26.7.0.xml}. Multiple subsystems
|
||||
* (SSF, webhooks, ...) share this table; the {@code entryKind}
|
||||
* discriminator scopes every read and write so cross-consumer
|
||||
* contention is eliminated by the {@code (ENTRY_KIND, ...)} compound
|
||||
* indexes rather than by separate tables.
|
||||
*
|
||||
* <p>Two-axis classification on every row:
|
||||
* <ul>
|
||||
* <li>{@code entryKind} — the broad subsystem ("ssf", "webhook", ...).</li>
|
||||
* <li>{@code entryType} — the concrete type within that subsystem (for
|
||||
* SSF the SET event_type; for webhooks the hook event name; etc.).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Scoping columns:
|
||||
* <ul>
|
||||
* <li>{@code ownerId} — primary scoping key (clientId for SSF
|
||||
* receivers, hookId for webhooks). Drives per-owner stats /
|
||||
* delete / cleanup endpoints.</li>
|
||||
* <li>{@code containerId} — optional sub-grouping within
|
||||
* {@code (entryKind, ownerId)} (the receiver's stream id for
|
||||
* SSF). Lets stream-scoped lifecycle operations stay
|
||||
* SQL-filterable rather than hidden in {@code metadata}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The wire shape of the {@code payload} (signed JWS, JSON, opaque blob)
|
||||
* and the contents of the optional {@code metadata} JSON are owned by the
|
||||
* subsystem's {@code OutboxDeliveryHandler} — the entity treats both as
|
||||
* opaque text.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "OUTBOX_ENTRY")
|
||||
@NamedQueries({
|
||||
// Drainer hot path. Uses the IDX_OUTBOX_DRAIN compound index.
|
||||
// The store layers PESSIMISTIC_WRITE + SKIP_LOCKED on top of
|
||||
// this select so cluster-aware drainers don't fight for the
|
||||
// same rows.
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.findDueForDrain",
|
||||
query = "SELECT e FROM OutboxEntryEntity e"
|
||||
+ " WHERE e.entryKind = :entryKind"
|
||||
+ " AND e.status = :status"
|
||||
+ " AND e.nextAttemptAt <= :now"
|
||||
+ " ORDER BY e.nextAttemptAt ASC"),
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.findByOwnerAndCorrelationId",
|
||||
query = "SELECT e FROM OutboxEntryEntity e"
|
||||
+ " WHERE e.entryKind = :entryKind"
|
||||
+ " AND e.ownerId = :ownerId"
|
||||
+ " AND e.correlationId = :correlationId"),
|
||||
// Race-safe enqueue. The ON CONFLICT DO NOTHING clause (HQL,
|
||||
// available since Hibernate 6.5) makes the unique-constraint
|
||||
// dedup happen at the storage engine — concurrent inserts of
|
||||
// the same (entryKind, ownerId, correlationId) triple from
|
||||
// sibling nodes silently no-op instead of throwing
|
||||
// ConstraintViolationException and marking the JTA transaction
|
||||
// rollback-only. executeUpdate() returns 0 on conflict, 1 on
|
||||
// successful insert; the caller distinguishes via that count
|
||||
// and re-fetches via findByOwnerAndCorrelationId on conflict
|
||||
// to return the racing row's id. See OutboxStore#enqueueInStatus.
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.insertIfAbsent",
|
||||
query = "INSERT INTO OutboxEntryEntity"
|
||||
+ " (id, entryKind, realmId, ownerId, containerId, correlationId,"
|
||||
+ " entryType, payload, metadata, status, attempts, nextAttemptAt, createdAt)"
|
||||
+ " VALUES (:id, :entryKind, :realmId, :ownerId, :containerId, :correlationId,"
|
||||
+ " :entryType, :payload, :metadata, :status, :attempts, :nextAttemptAt, :createdAt)"
|
||||
+ " ON CONFLICT DO NOTHING"),
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.countByEntryKindRealmAndStatus",
|
||||
query = "SELECT e.status, COUNT(e) FROM OutboxEntryEntity e"
|
||||
+ " WHERE e.entryKind = :entryKind"
|
||||
+ " AND e.realmId = :realmId"
|
||||
+ " GROUP BY e.status"),
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.countByEntryKindOwnerAndStatus",
|
||||
query = "SELECT e.status, COUNT(e) FROM OutboxEntryEntity e"
|
||||
+ " WHERE e.entryKind = :entryKind"
|
||||
+ " AND e.ownerId = :ownerId"
|
||||
+ " GROUP BY e.status"),
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.oldestCreatedAtByEntryKindRealmAndStatus",
|
||||
query = "SELECT e.status, MIN(e.createdAt) FROM OutboxEntryEntity e"
|
||||
+ " WHERE e.entryKind = :entryKind"
|
||||
+ " AND e.realmId = :realmId"
|
||||
+ " GROUP BY e.status"),
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.oldestCreatedAtByEntryKindOwnerAndStatus",
|
||||
query = "SELECT e.status, MIN(e.createdAt) FROM OutboxEntryEntity e"
|
||||
+ " WHERE e.entryKind = :entryKind"
|
||||
+ " AND e.ownerId = :ownerId"
|
||||
+ " GROUP BY e.status"),
|
||||
// Bulk DML primitives — all scoped on (feature) so multi-consumer
|
||||
// tables stay isolated.
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.deleteByEntryKindAndRealm",
|
||||
query = "DELETE FROM OutboxEntryEntity e"
|
||||
+ " WHERE e.entryKind = :entryKind AND e.realmId = :realmId"),
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.deleteByEntryKindRealmAndStatus",
|
||||
query = "DELETE FROM OutboxEntryEntity e"
|
||||
+ " WHERE e.entryKind = :entryKind"
|
||||
+ " AND e.realmId = :realmId"
|
||||
+ " AND e.status = :status"),
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.deleteByEntryKindRealmAndStatusOlderThan",
|
||||
query = "DELETE FROM OutboxEntryEntity e"
|
||||
+ " WHERE e.entryKind = :entryKind"
|
||||
+ " AND e.realmId = :realmId"
|
||||
+ " AND e.status = :status"
|
||||
+ " AND e.createdAt < :olderThan"),
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.deleteByEntryKindAndOwner",
|
||||
query = "DELETE FROM OutboxEntryEntity e"
|
||||
+ " WHERE e.entryKind = :entryKind AND e.ownerId = :ownerId"),
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.deleteByEntryKindOwnerAndStatus",
|
||||
query = "DELETE FROM OutboxEntryEntity e"
|
||||
+ " WHERE e.entryKind = :entryKind"
|
||||
+ " AND e.ownerId = :ownerId"
|
||||
+ " AND e.status = :status"),
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.deleteByEntryKindOwnerAndStatusOlderThan",
|
||||
query = "DELETE FROM OutboxEntryEntity e"
|
||||
+ " WHERE e.entryKind = :entryKind"
|
||||
+ " AND e.ownerId = :ownerId"
|
||||
+ " AND e.status = :status"
|
||||
+ " AND e.createdAt < :olderThan"),
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.deleteQueuedByEntryKindAndRealm",
|
||||
query = "DELETE FROM OutboxEntryEntity e"
|
||||
+ " WHERE e.entryKind = :entryKind"
|
||||
+ " AND e.realmId = :realmId"
|
||||
+ " AND e.status IN :statuses"),
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.deleteQueuedByEntryKindAndOwner",
|
||||
query = "DELETE FROM OutboxEntryEntity e"
|
||||
+ " WHERE e.entryKind = :entryKind"
|
||||
+ " AND e.ownerId = :ownerId"
|
||||
+ " AND e.status IN :statuses"),
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.purgeByEntryKindStatusOlderThanDelivered",
|
||||
query = "DELETE FROM OutboxEntryEntity e"
|
||||
+ " WHERE e.entryKind = :entryKind"
|
||||
+ " AND e.status = :status"
|
||||
+ " AND e.deliveredAt < :olderThan"),
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.purgeByEntryKindStatusOlderThanCreated",
|
||||
query = "DELETE FROM OutboxEntryEntity e"
|
||||
+ " WHERE e.entryKind = :entryKind"
|
||||
+ " AND e.status = :status"
|
||||
+ " AND e.createdAt < :olderThan"),
|
||||
// Backstop: promote rows that have been waiting too long to a
|
||||
// terminal state so the dead-letter retention purge can sweep
|
||||
// them. Bumps neither attempts nor deliveredAt — these rows
|
||||
// never actually retried; they aged out.
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.promoteStaleQueuedToDeadLetter",
|
||||
query = "UPDATE OutboxEntryEntity e"
|
||||
+ " SET e.status = :dead, e.lastError = :reason"
|
||||
+ " WHERE e.entryKind = :entryKind"
|
||||
+ " AND e.status IN :statuses"
|
||||
+ " AND e.createdAt < :olderThan"),
|
||||
// Receiver-driven read paths (e.g. SSF POLL endpoint). The
|
||||
// per-owner read is on-demand — no next_attempt_at gate; the
|
||||
// ack/nack lookup additionally filters by correlation id set
|
||||
// so receivers can't poke at rows they don't own.
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.findPendingForOwner",
|
||||
query = "SELECT e FROM OutboxEntryEntity e"
|
||||
+ " WHERE e.entryKind = :entryKind"
|
||||
+ " AND e.ownerId = :ownerId"
|
||||
+ " AND e.status = :status"
|
||||
+ " ORDER BY e.createdAt ASC"),
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.findPendingForOwnerByCorrelationIds",
|
||||
query = "SELECT e FROM OutboxEntryEntity e"
|
||||
+ " WHERE e.entryKind = :entryKind"
|
||||
+ " AND e.ownerId = :ownerId"
|
||||
+ " AND e.correlationId IN :correlationIds"
|
||||
+ " AND e.status = :status"),
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.countByEntryKindOwnerStatus",
|
||||
query = "SELECT COUNT(e) FROM OutboxEntryEntity e"
|
||||
+ " WHERE e.entryKind = :entryKind"
|
||||
+ " AND e.ownerId = :ownerId"
|
||||
+ " AND e.status = :status"),
|
||||
// Owner-scoped lifecycle: pause/resume, disable, narrowed
|
||||
// type allow-list. Drives consumer-side lifecycle operations
|
||||
// such as SSF stream pause / resume / disable / events_requested
|
||||
// narrowing.
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.releaseHeldForOwner",
|
||||
query = "UPDATE OutboxEntryEntity e"
|
||||
+ " SET e.status = :pending, e.nextAttemptAt = :now"
|
||||
+ " WHERE e.entryKind = :entryKind"
|
||||
+ " AND e.ownerId = :ownerId"
|
||||
+ " AND e.status = :held"),
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.holdPendingForOwner",
|
||||
query = "UPDATE OutboxEntryEntity e"
|
||||
+ " SET e.status = :held"
|
||||
+ " WHERE e.entryKind = :entryKind"
|
||||
+ " AND e.ownerId = :ownerId"
|
||||
+ " AND e.status = :pending"),
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.deadLetterQueuedForOwner",
|
||||
query = "UPDATE OutboxEntryEntity e"
|
||||
+ " SET e.status = :dead, e.lastError = :reason"
|
||||
+ " WHERE e.entryKind = :entryKind"
|
||||
+ " AND e.ownerId = :ownerId"
|
||||
+ " AND e.status IN :statuses"),
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.deadLetterQueuedForOwnerNotMatchingTypes",
|
||||
query = "UPDATE OutboxEntryEntity e"
|
||||
+ " SET e.status = :dead, e.lastError = :reason"
|
||||
+ " WHERE e.entryKind = :entryKind"
|
||||
+ " AND e.ownerId = :ownerId"
|
||||
+ " AND e.status IN :statuses"
|
||||
+ " AND e.entryType NOT IN :allowedTypes"),
|
||||
// Migrating an owner's queued rows to a different entryKind
|
||||
// (e.g. SSF receiver flipping push <-> poll). Terminal rows
|
||||
// (DELIVERED, DEAD_LETTER) are left alone — they're audit/dedup
|
||||
// artifacts of the previous channel and migrating them would
|
||||
// confuse correlation-id dedup on the new channel.
|
||||
@NamedQuery(
|
||||
name = "OutboxEntryEntity.migrateEntryKindForOwner",
|
||||
query = "UPDATE OutboxEntryEntity e"
|
||||
+ " SET e.entryKind = :newKind"
|
||||
+ " WHERE e.entryKind = :currentKind"
|
||||
+ " AND e.ownerId = :ownerId"
|
||||
+ " AND e.status IN :statuses")
|
||||
})
|
||||
public class OutboxEntryEntity {
|
||||
|
||||
@Id
|
||||
@Column(name = "ID", length = 36)
|
||||
protected String id;
|
||||
|
||||
@Column(name = "ENTRY_KIND", nullable = false, length = 64)
|
||||
protected String entryKind;
|
||||
|
||||
@Column(name = "REALM_ID", nullable = false, length = 36)
|
||||
protected String realmId;
|
||||
|
||||
@Column(name = "OWNER_ID", nullable = false, length = 64)
|
||||
protected String ownerId;
|
||||
|
||||
/**
|
||||
* Optional sub-grouping within {@code (entryKind, ownerId)}.
|
||||
* For SSF this is the receiver's stream id so operations such as
|
||||
* "narrow events_requested for stream X" or "stream X disabled —
|
||||
* purge its undelivered rows" stay SQL-filterable rather than
|
||||
* hidden in {@code metadata}. Kinds that don't need a sub-group
|
||||
* leave it null.
|
||||
*/
|
||||
@Column(name = "CONTAINER_ID", length = 64)
|
||||
protected String containerId;
|
||||
|
||||
@Column(name = "CORRELATION_ID", nullable = false, length = 255)
|
||||
protected String correlationId;
|
||||
|
||||
@Column(name = "ENTRY_TYPE", nullable = false, length = 256)
|
||||
protected String entryType;
|
||||
|
||||
@Nationalized
|
||||
@Column(name = "PAYLOAD", nullable = false)
|
||||
protected String payload;
|
||||
|
||||
@Nationalized
|
||||
@Column(name = "METADATA")
|
||||
protected String metadata;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "STATUS", nullable = false, length = 16)
|
||||
protected OutboxEntryStatus status;
|
||||
|
||||
@Column(name = "ATTEMPTS", nullable = false)
|
||||
protected int attempts;
|
||||
|
||||
@Column(name = "NEXT_ATTEMPT_AT", nullable = false)
|
||||
protected Instant nextAttemptAt;
|
||||
|
||||
@Column(name = "LAST_ERROR", length = 2048)
|
||||
protected String lastError;
|
||||
|
||||
@Column(name = "CREATED_AT", nullable = false)
|
||||
protected Instant createdAt;
|
||||
|
||||
@Column(name = "DELIVERED_AT")
|
||||
protected Instant deliveredAt;
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getEntryKind() {
|
||||
return entryKind;
|
||||
}
|
||||
|
||||
public void setEntryKind(String entryKind) {
|
||||
this.entryKind = entryKind;
|
||||
}
|
||||
|
||||
public String getRealmId() {
|
||||
return realmId;
|
||||
}
|
||||
|
||||
public void setRealmId(String realmId) {
|
||||
this.realmId = realmId;
|
||||
}
|
||||
|
||||
public String getOwnerId() {
|
||||
return ownerId;
|
||||
}
|
||||
|
||||
public void setOwnerId(String ownerId) {
|
||||
this.ownerId = ownerId;
|
||||
}
|
||||
|
||||
public String getContainerId() {
|
||||
return containerId;
|
||||
}
|
||||
|
||||
public void setContainerId(String containerId) {
|
||||
this.containerId = containerId;
|
||||
}
|
||||
|
||||
public String getCorrelationId() {
|
||||
return correlationId;
|
||||
}
|
||||
|
||||
public void setCorrelationId(String correlationId) {
|
||||
this.correlationId = correlationId;
|
||||
}
|
||||
|
||||
public String getEntryType() {
|
||||
return entryType;
|
||||
}
|
||||
|
||||
public void setEntryType(String entryType) {
|
||||
this.entryType = entryType;
|
||||
}
|
||||
|
||||
public String getPayload() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
public void setPayload(String payload) {
|
||||
this.payload = payload;
|
||||
}
|
||||
|
||||
public String getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
public void setMetadata(String metadata) {
|
||||
this.metadata = metadata;
|
||||
}
|
||||
|
||||
public OutboxEntryStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(OutboxEntryStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public int getAttempts() {
|
||||
return attempts;
|
||||
}
|
||||
|
||||
public void setAttempts(int attempts) {
|
||||
this.attempts = attempts;
|
||||
}
|
||||
|
||||
public Instant getNextAttemptAt() {
|
||||
return nextAttemptAt;
|
||||
}
|
||||
|
||||
public void setNextAttemptAt(Instant nextAttemptAt) {
|
||||
this.nextAttemptAt = nextAttemptAt;
|
||||
}
|
||||
|
||||
public String getLastError() {
|
||||
return lastError;
|
||||
}
|
||||
|
||||
public void setLastError(String lastError) {
|
||||
this.lastError = lastError;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public Instant getDeliveredAt() {
|
||||
return deliveredAt;
|
||||
}
|
||||
|
||||
public void setDeliveredAt(Instant deliveredAt) {
|
||||
this.deliveredAt = deliveredAt;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof OutboxEntryEntity)) return false;
|
||||
OutboxEntryEntity that = (OutboxEntryEntity) o;
|
||||
return Objects.equals(id, that.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hashCode(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.models.jpa.entities;
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Lifecycle status of a row in the generic outbox ({@code OUTBOX_ENTRY}).
|
||||
*
|
||||
* <p>The state machine is:
|
||||
* <pre>
|
||||
* PENDING - delivery succeeds -> DELIVERED
|
||||
* PENDING - retries exhausted -> DEAD_LETTER
|
||||
* PENDING - upstream paused -> HELD
|
||||
* HELD - upstream resumed -> PENDING
|
||||
* PENDING / HELD - admin "purge queued" -> (deleted)
|
||||
* DEAD_LETTER - admin "retry" -> PENDING (resets attempts, next_attempt_at)
|
||||
* </pre>
|
||||
*
|
||||
* <p>Features that don't pause never produce {@link #HELD} rows; the
|
||||
* status is generic so the drainer doesn't need to know which feature
|
||||
* uses pause/resume.
|
||||
*
|
||||
* <p>Rows in {@link #DELIVERED} are kept briefly for audit / idempotency
|
||||
* (correlation-id dedup). Rows in {@link #DEAD_LETTER} are retained for
|
||||
* the configured per-feature dead-letter retention window before being
|
||||
* purged. The {@link #QUEUED} set captures the pre-terminal states the
|
||||
* "purge queued" admin operation targets.
|
||||
*/
|
||||
public enum OutboxEntryStatus {
|
||||
|
||||
/**
|
||||
* Entry is queued for delivery. The drainer picks up rows in this
|
||||
* state whose {@code next_attempt_at} is due.
|
||||
*/
|
||||
PENDING,
|
||||
|
||||
/**
|
||||
* Entry was accepted by the destination (handler returned
|
||||
* {@code DELIVERED}) and needs no further action.
|
||||
*/
|
||||
DELIVERED,
|
||||
|
||||
/**
|
||||
* All retry attempts have been exhausted (or the row aged beyond
|
||||
* the configured pendingMaxAge backstop) without successful
|
||||
* delivery. Requires admin intervention.
|
||||
*/
|
||||
DEAD_LETTER,
|
||||
|
||||
/**
|
||||
* Entry is parked because the upstream (e.g. SSF stream) is in a
|
||||
* paused state. Drainers must skip rows in this state until the
|
||||
* upstream resumes and the rows are bulk-transitioned back to
|
||||
* {@link #PENDING} in original arrival order.
|
||||
*/
|
||||
HELD;
|
||||
|
||||
/**
|
||||
* Statuses representing entries that are queued — i.e. waiting to
|
||||
* reach a terminal state. Single source of truth for the
|
||||
* {@code DELETE .../events/queued} admin endpoints (and the
|
||||
* disable-on-save cleanup that drives them) so future additions
|
||||
* to the pre-terminal state set automatically extend the
|
||||
* "purge queued" semantics without API changes.
|
||||
*/
|
||||
public static final Set<OutboxEntryStatus> QUEUED = EnumSet.of(PENDING, HELD);
|
||||
}
|
||||
@@ -66,4 +66,138 @@
|
||||
<addUniqueConstraint columnNames="CREDENTIAL_SCOPE_NAME, USER_ID" constraintName="UK_KKUWUVD67ONTGSUGOGM8UEWRE" tableName="USER_VER_CREDENTIAL"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="keycloak" id="26.7.0-outbox">
|
||||
|
||||
<!--
|
||||
Generic outbox / event-queue infrastructure.
|
||||
|
||||
Backs org.keycloak.events.outbox.OutboxStore and the OutboxDrainerTask
|
||||
/ OutboxCleanupTask machinery. The ENTRY_KIND column is a plain
|
||||
string discriminator scoping the row to a subsystem ("ssf",
|
||||
"webhook", "audit", ...) so a single shared table serves
|
||||
multiple consumers without cross-talk. Every drainer query
|
||||
filters on ENTRY_KIND first; the indexes below are ordered to
|
||||
keep that lookup index-only.
|
||||
|
||||
Two-axis classification on every row:
|
||||
- ENTRY_KIND : which subsystem owns this row (broad domain).
|
||||
- ENTRY_TYPE : the concrete type within that domain (for SSF
|
||||
the SET event_type; for webhooks the hook
|
||||
event name; etc.).
|
||||
|
||||
Columns intentionally generic:
|
||||
- PAYLOAD : opaque text body (signed JWS, JSON envelope,
|
||||
binary as base64). Consumers pick their own
|
||||
wire shape; the store treats it as bytes.
|
||||
- METADATA : optional JSON for kind-specific extensions
|
||||
that don't justify a column (e.g. webhook
|
||||
signing key id).
|
||||
- OWNER_ID : the kind's primary scoping key (clientId for
|
||||
SSF receivers, hookId for webhooks). Used by
|
||||
per-owner stats / delete / cleanup endpoints.
|
||||
- CONTAINER_ID : optional sub-grouping within (kind, owner).
|
||||
For SSF this is the receiver's streamId so
|
||||
stream-scoped operations (events_requested
|
||||
filter, stream-disable purge) stay
|
||||
SQL-filterable rather than hidden in JSON.
|
||||
Nullable — kinds that don't need a
|
||||
sub-grouping (single channel per owner)
|
||||
leave it unset.
|
||||
|
||||
See:
|
||||
- org.keycloak.models.jpa.entities.OutboxEntryEntity
|
||||
- org.keycloak.events.outbox.OutboxStore
|
||||
- org.keycloak.events.outbox.OutboxDrainerTask
|
||||
-->
|
||||
|
||||
<createTable tableName="OUTBOX_ENTRY">
|
||||
<column name="ID" type="VARCHAR(36)">
|
||||
<constraints nullable="false" primaryKey="true" primaryKeyName="PK_OUTBOX_ENTRY"/>
|
||||
</column>
|
||||
<column name="ENTRY_KIND" type="VARCHAR(64)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="REALM_ID" type="VARCHAR(36)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="OWNER_ID" type="VARCHAR(64)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="CONTAINER_ID" type="VARCHAR(64)"/>
|
||||
<column name="CORRELATION_ID" type="VARCHAR(255)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="ENTRY_TYPE" type="VARCHAR(256)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!--
|
||||
NCLOB rather than VARCHAR: payloads can be arbitrarily
|
||||
large (signed JWS, JSON envelopes with embedded data).
|
||||
-->
|
||||
<column name="PAYLOAD" type="NCLOB">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="METADATA" type="NCLOB"/>
|
||||
<column name="STATUS" type="VARCHAR(16)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="ATTEMPTS" type="INT" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="NEXT_ATTEMPT_AT" type="TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="LAST_ERROR" type="VARCHAR(2048)"/>
|
||||
<column name="CREATED_AT" type="TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="DELIVERED_AT" type="TIMESTAMP"/>
|
||||
</createTable>
|
||||
|
||||
<!--
|
||||
Dedup: one outstanding row per (kind, owner, correlation).
|
||||
Two consumers using the same correlation id (e.g. jti
|
||||
reused across a webhook and an SSF stream) don't collide
|
||||
because the (kind) prefix scopes them apart.
|
||||
-->
|
||||
<addUniqueConstraint
|
||||
tableName="OUTBOX_ENTRY"
|
||||
columnNames="ENTRY_KIND, OWNER_ID, CORRELATION_ID"
|
||||
constraintName="UC_OUTBOX_KIND_OWNER_CORRELATION"/>
|
||||
|
||||
<!-- Drainer hot path: WHERE entry_kind=? AND status=? AND next_attempt_at<=? -->
|
||||
<createIndex tableName="OUTBOX_ENTRY" indexName="IDX_OUTBOX_DRAIN">
|
||||
<column name="ENTRY_KIND"/>
|
||||
<column name="STATUS"/>
|
||||
<column name="NEXT_ATTEMPT_AT"/>
|
||||
</createIndex>
|
||||
|
||||
<!-- Realm-scoped reads / cascade deletes on RealmRemovedEvent. -->
|
||||
<createIndex tableName="OUTBOX_ENTRY" indexName="IDX_OUTBOX_REALM">
|
||||
<column name="ENTRY_KIND"/>
|
||||
<column name="REALM_ID"/>
|
||||
<column name="STATUS"/>
|
||||
</createIndex>
|
||||
|
||||
<!-- Per-owner reads (admin stats / delete / cascade on owner removal). -->
|
||||
<createIndex tableName="OUTBOX_ENTRY" indexName="IDX_OUTBOX_OWNER">
|
||||
<column name="ENTRY_KIND"/>
|
||||
<column name="OWNER_ID"/>
|
||||
<column name="STATUS"/>
|
||||
</createIndex>
|
||||
|
||||
<!--
|
||||
Container-scoped reads. Used when a row's container has a
|
||||
lifecycle event of its own (e.g. SSF stream disabled,
|
||||
stream events_requested narrowed) and we need to bulk-act
|
||||
on its rows without scanning the whole owner.
|
||||
-->
|
||||
<createIndex tableName="OUTBOX_ENTRY" indexName="IDX_OUTBOX_CONTAINER">
|
||||
<column name="ENTRY_KIND"/>
|
||||
<column name="OWNER_ID"/>
|
||||
<column name="CONTAINER_ID"/>
|
||||
</createIndex>
|
||||
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
<class>org.keycloak.models.jpa.session.PersistentUserSessionEntity</class>
|
||||
<class>org.keycloak.models.jpa.session.PersistentClientSessionEntity</class>
|
||||
<class>org.keycloak.models.jpa.entities.RevokedTokenEntity</class>
|
||||
<class>org.keycloak.models.jpa.entities.OutboxEntryEntity</class>
|
||||
<class>org.keycloak.models.jpa.entities.GroupEntity</class>
|
||||
<class>org.keycloak.models.jpa.entities.GroupAttributeEntity</class>
|
||||
<class>org.keycloak.models.jpa.entities.GroupRoleMappingEntity</class>
|
||||
|
||||
@@ -320,6 +320,7 @@
|
||||
<module>tests</module>
|
||||
<module>quarkus</module>
|
||||
<module>scim</module>
|
||||
<module>ssf</module>
|
||||
<module>authzen</module>
|
||||
</modules>
|
||||
|
||||
@@ -1360,6 +1361,23 @@
|
||||
<artifactId>keycloak-scim-model</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- SSF -->
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-ssf-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-ssf-transmitter</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-ssf-services</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
|
||||
@@ -474,6 +474,37 @@
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-ssf-core</artifactId>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>*</groupId>
|
||||
<artifactId>*</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-ssf-transmitter</artifactId>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>*</groupId>
|
||||
<artifactId>*</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-ssf-services</artifactId>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>*</groupId>
|
||||
<artifactId>*</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<!-- Keycloak Dependencies-->
|
||||
<dependency>
|
||||
<groupId>org.jboss.logging</groupId>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||
<parent>
|
||||
<artifactId>keycloak-parent</artifactId>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<version>999.0.0-SNAPSHOT</version>
|
||||
<relativePath>../../pom.xml</relativePath>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<artifactId>keycloak-ssf-core</artifactId>
|
||||
<name>Keycloak SSF Core</name>
|
||||
<description>
|
||||
Common SSF (Shared Signals Framework) classes, event types, subject identifiers, and the SSF Event SPI.
|
||||
</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-server-spi</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-server-spi-private</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-services</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jboss.logging</groupId>
|
||||
<artifactId>jboss-logging</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Test dependencies -->
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.keycloak.ssf;
|
||||
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.ssf.event.SsfEventProvider;
|
||||
|
||||
import static org.keycloak.utils.KeycloakSessionUtil.getKeycloakSession;
|
||||
|
||||
/**
|
||||
* Collection of SSF constants and entry points.
|
||||
*/
|
||||
public class Ssf {
|
||||
|
||||
public static final String SSF_VERSION_1_0 = "1_0";
|
||||
|
||||
public static final String SSF_OAUTH_AUTHORIZATION_SCHEME_URN = "urn:ietf:rfc:6749";
|
||||
|
||||
public static final String SCOPE_SSF_READ = "ssf.read";
|
||||
|
||||
public static final String SCOPE_SSF_MANAGE = "ssf.manage";
|
||||
|
||||
public static final String SSF_WELL_KNOWN_METADATA_PATH = ".well-known/ssf-configuration";
|
||||
|
||||
public static final String SSF_REALM_RESOURCE_PATH = "ssf";
|
||||
|
||||
public static final String SSF_TRANSMITTER_PATH = "transmitter";
|
||||
|
||||
public static final String APPLICATION_SECEVENT_JWT_TYPE = "application/secevent+jwt";
|
||||
|
||||
/**
|
||||
* 4.1.1. Explicit Typing of SETs
|
||||
*
|
||||
* @see https://openid.github.io/sharedsignals/openid-sharedsignals-framework-1_0.html#section-4.1.1
|
||||
*/
|
||||
public static final String SECEVENT_JWT_TYPE = "secevent+jwt";
|
||||
|
||||
public static final String DELIVERY_METHOD_PUSH_URI = "urn:ietf:rfc:8935";
|
||||
|
||||
public static final String DELIVERY_METHOD_RISC_PUSH_URI = "https://schemas.openid.net/secevent/risc/delivery-method/push";
|
||||
|
||||
public static final String DELIVERY_METHOD_POLL_URI = "urn:ietf:rfc:8936";
|
||||
|
||||
public static final String DELIVERY_METHOD_RISC_POLL_URI = "https://schemas.openid.net/secevent/risc/delivery-method/poll";
|
||||
|
||||
public static final String SSF_TRANSMITTER_ENABLED_KEY = "ssf.transmitterEnabled";
|
||||
|
||||
private Ssf() {
|
||||
}
|
||||
|
||||
public static SsfEventProvider events() {
|
||||
var session = getKeycloakSession();
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
return session.getProvider(SsfEventProvider.class);
|
||||
}
|
||||
|
||||
public static boolean isTransmitterEnabled(RealmModel realm) {
|
||||
return Boolean.parseBoolean(realm.getAttribute(SSF_TRANSMITTER_ENABLED_KEY));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.keycloak.ssf;
|
||||
|
||||
import org.keycloak.models.ModelException;
|
||||
|
||||
public class SsfException extends ModelException {
|
||||
|
||||
public SsfException() {
|
||||
}
|
||||
|
||||
public SsfException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public SsfException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.keycloak.ssf;
|
||||
|
||||
public enum SsfProfile {
|
||||
|
||||
/**
|
||||
* Standard SSF 1.0, as defined by https://openid.net/specs/openid-sharedsignals-framework-1_0-final.html
|
||||
*/
|
||||
SSF_1_0,
|
||||
|
||||
/**
|
||||
* Legacy SSE CAEP Profile, as defined by https://openid.net/specs/openid-sse-framework-1_0.html
|
||||
*
|
||||
* This is required to support compatibility with Apple Business Manager / Apple School Manager.
|
||||
*/
|
||||
SSE_CAEP
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.keycloak.ssf.event;
|
||||
|
||||
/**
|
||||
* Default {@link SsfEventProvider} implementation that simply exposes the
|
||||
* pre-built {@link SsfEventRegistry}.
|
||||
*/
|
||||
public class DefaultSsfEventProvider implements SsfEventProvider {
|
||||
|
||||
private final SsfEventRegistry registry;
|
||||
|
||||
public DefaultSsfEventProvider(SsfEventRegistry registry) {
|
||||
this.registry = registry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SsfEventRegistry getRegistry() {
|
||||
return registry;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package org.keycloak.ssf.event;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.provider.EnvironmentDependentProviderFactory;
|
||||
import org.keycloak.ssf.event.caep.CaepCredentialChange;
|
||||
import org.keycloak.ssf.event.caep.CaepSessionRevoked;
|
||||
import org.keycloak.ssf.event.stream.SsfStreamUpdatedEvent;
|
||||
import org.keycloak.ssf.event.stream.SsfStreamVerificationEvent;
|
||||
|
||||
/**
|
||||
* Default {@link SsfEventProviderFactory} that contributes the built-in set
|
||||
* of standard SSF / CAEP events.
|
||||
*
|
||||
* <p>This factory is also responsible for assembling the global
|
||||
* {@link SsfEventRegistry} from the contributions of every registered
|
||||
* factory in {@link #postInit(KeycloakSessionFactory)}.
|
||||
*/
|
||||
public class DefaultSsfEventProviderFactory implements SsfEventProviderFactory, EnvironmentDependentProviderFactory {
|
||||
|
||||
public static final String PROVIDER_ID = "default";
|
||||
|
||||
/**
|
||||
* Standard event contributions keyed by event type URI. The value is a
|
||||
* {@code ::new} method reference so the registry can instantiate fresh
|
||||
* instances at runtime (e.g. for the synthetic event emitter) without
|
||||
* reflection.
|
||||
*
|
||||
* <p>{@link LinkedHashMap} to preserve insertion order for predictable
|
||||
* iteration when introspecting the registry (e.g. in the admin UI's
|
||||
* supported-events list).
|
||||
*/
|
||||
private static final Map<String, Supplier<? extends SsfEvent>> STANDARD_EVENT_FACTORIES;
|
||||
|
||||
static {
|
||||
Map<String, Supplier<? extends SsfEvent>> events = new LinkedHashMap<>();
|
||||
|
||||
// SSF Stream events
|
||||
events.put(SsfStreamVerificationEvent.TYPE, SsfStreamVerificationEvent::new);
|
||||
events.put(SsfStreamUpdatedEvent.TYPE, SsfStreamUpdatedEvent::new);
|
||||
|
||||
// CAEP events
|
||||
events.put(CaepCredentialChange.TYPE, CaepCredentialChange::new);
|
||||
events.put(CaepSessionRevoked.TYPE, CaepSessionRevoked::new);
|
||||
|
||||
STANDARD_EVENT_FACTORIES = Map.copyOf(events);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subset of {@link #STANDARD_EVENT_FACTORIES} the transmitter can ship out.
|
||||
* Either {@code SecurityEventTokenMapper} produces them from Keycloak
|
||||
* events, or operators can raise them on demand via the admin emit API.
|
||||
* Every other built-in event in the map is contributed to the registry
|
||||
* only so the receiver-side parser can decode incoming SETs of that type.
|
||||
*
|
||||
* <p>The two types here are use-cases enumerated by the OpenID CAEP
|
||||
* Interoperability Profile 1.0: {@code session-revoked} and
|
||||
* {@code credential-change}. The profile is opt-in per use-case
|
||||
* ("Implementations MAY choose to support one or more …"), so supporting
|
||||
* any one of them is enough to count as interoperable.
|
||||
*
|
||||
* @see <a href="https://openid.github.io/sharedsignals/openid-caep-interoperability-profile-1_0.html">OpenID CAEP Interoperability Profile 1.0</a>
|
||||
*/
|
||||
public static final Set<String> EMITTABLE_EVENT_TYPES = Set.of(
|
||||
CaepCredentialChange.TYPE,
|
||||
CaepSessionRevoked.TYPE);
|
||||
|
||||
/**
|
||||
* Subset of {@link #EMITTABLE_EVENT_TYPES} that {@code SecurityEventTokenMapper}
|
||||
* actually produces natively from Keycloak listener events. Drives the
|
||||
* "natively emitted" badge in the admin UI.
|
||||
*/
|
||||
public static final Set<String> NATIVELY_EMITTED_EVENT_TYPES = Set.of(
|
||||
CaepCredentialChange.TYPE,
|
||||
CaepSessionRevoked.TYPE);
|
||||
|
||||
private volatile SsfEventRegistry registry;
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return PROVIDER_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Supplier<? extends SsfEvent>> getContributedEventFactories() {
|
||||
return STANDARD_EVENT_FACTORIES;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getEmittableEventTypes() {
|
||||
return EMITTABLE_EVENT_TYPES;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getNativelyEmittedEventTypes() {
|
||||
return NATIVELY_EMITTED_EVENT_TYPES;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SsfEventProvider create(KeycloakSession session) {
|
||||
return new DefaultSsfEventProvider(registry);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postInit(KeycloakSessionFactory factory) {
|
||||
this.registry = SsfEventProviderFactory.buildRegistry(factory);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSupported(Config.Scope config) {
|
||||
return Profile.isFeatureEnabled(Profile.Feature.SSF);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.keycloak.ssf.event;
|
||||
|
||||
/**
|
||||
* Fallback {@link SsfEvent} if we encounter an unknown SsfEvent type.
|
||||
*/
|
||||
public class GenericSsfEvent extends SsfEvent {
|
||||
|
||||
public GenericSsfEvent() {
|
||||
super(null);
|
||||
|
||||
// Generic events don't have an alias by default
|
||||
setAlias(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "GenericSecurityEvent{" +
|
||||
"subjectId=" + subjectId +
|
||||
", eventType='" + eventType + '\'' +
|
||||
", eventTimestamp=" + eventTimestamp +
|
||||
", initiatingEntity=" + initiatingEntity +
|
||||
", reasonAdmin=" + reasonAdmin +
|
||||
", reasonUser=" + reasonUser +
|
||||
", attributes=" + attributes +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.keycloak.ssf.event;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public enum InitiatingEntity {
|
||||
ADMIN("admin"),
|
||||
USER("user"),
|
||||
POLICY("policy"),
|
||||
SYSTEM("system"),
|
||||
;
|
||||
|
||||
private final String code;
|
||||
|
||||
InitiatingEntity(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
@JsonValue
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package org.keycloak.ssf.event;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
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.
|
||||
*
|
||||
* See: https://datatracker.ietf.org/doc/html/rfc8417
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public abstract class SsfEvent {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@JsonProperty("reason_admin")
|
||||
protected Map<String, String> reasonAdmin;
|
||||
|
||||
/**
|
||||
* A localized message intended for the end user.
|
||||
* key is language code, value is message.
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
|
||||
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 String getEventType() {
|
||||
return eventType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes {@link #attributes} as a Jackson "any-getter" so each entry is
|
||||
* rendered as a top-level JSON field on the event object (flattened
|
||||
* alongside the declared {@code @JsonProperty} fields), matching the
|
||||
* SSF §4.2.3 extension-field placement rather than nesting them under
|
||||
* an {@code "attributes"} key.
|
||||
*/
|
||||
@JsonAnyGetter
|
||||
public Map<String, Object> getAttributes() {
|
||||
return attributes;
|
||||
}
|
||||
|
||||
public void setAttributes(Map<String, Object> attributes) {
|
||||
this.attributes = attributes;
|
||||
}
|
||||
|
||||
@JsonAnySetter
|
||||
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());
|
||||
}
|
||||
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<>();
|
||||
for (Class<?> c = t; c != null && c != Object.class; c = c.getSuperclass()) {
|
||||
for (Field f : c.getDeclaredFields()) {
|
||||
JsonProperty ann = f.getAnnotation(JsonProperty.class);
|
||||
if (ann != null && !ann.value().isEmpty()) {
|
||||
names.add(ann.value());
|
||||
}
|
||||
}
|
||||
}
|
||||
return Set.copyOf(names);
|
||||
});
|
||||
}
|
||||
|
||||
public void setEventType(String eventType) {
|
||||
this.eventType = eventType;
|
||||
}
|
||||
|
||||
public void setSubjectId(SubjectId subjectId) {
|
||||
this.subjectId = subjectId;
|
||||
}
|
||||
|
||||
public String getAlias() {
|
||||
return alias;
|
||||
}
|
||||
|
||||
public void setAlias(String alias) {
|
||||
this.alias = alias;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that this event instance carries the fields the SSF /
|
||||
* CAEP / RISC spec marks as REQUIRED. Called by the synthetic-emit
|
||||
* pipeline after Jackson has materialised the event from the
|
||||
* caller-supplied JSON, so a missing-field mistake is rejected
|
||||
* with a clear error before the SET is signed and dispatched.
|
||||
*
|
||||
* <p>Default implementation is a no-op — most CAEP/RISC events have
|
||||
* no strictly-required fields beyond the subject, which the
|
||||
* pipeline validates separately. Subclasses with mandatory fields
|
||||
* (e.g. {@code CaepCredentialChange.change_type}) override this
|
||||
* to throw {@link SsfEventValidationException} with a message
|
||||
* naming the missing field.
|
||||
*
|
||||
* <p>Native event production (the SSF event listener) builds
|
||||
* instances from typed Keycloak event details that always supply
|
||||
* the required fields, so the hook only matters on the
|
||||
* synthetic-emit path. Custom extension events use this same hook
|
||||
* to enforce their own invariants.
|
||||
*/
|
||||
public void validate() {
|
||||
// no-op — overridden by event subclasses that have spec-required fields
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.keycloak.ssf.event;
|
||||
|
||||
import org.keycloak.provider.Provider;
|
||||
|
||||
/**
|
||||
* Per-session provider that exposes the global {@link SsfEventRegistry}.
|
||||
*
|
||||
* <p>The registry itself is built once at server startup by aggregating the
|
||||
* events contributed by every registered {@link SsfEventProviderFactory}, so
|
||||
* lookups are cheap and stateless.
|
||||
*/
|
||||
public interface SsfEventProvider extends Provider {
|
||||
|
||||
@Override
|
||||
default void close() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the global, immutable registry of known SSF events
|
||||
*/
|
||||
SsfEventRegistry getRegistry();
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package org.keycloak.ssf.event;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
|
||||
/**
|
||||
* Factory contract for {@link SsfEventProvider} implementations.
|
||||
*
|
||||
* <p>Each registered factory contributes a map of event-type-URI to
|
||||
* {@link SsfEvent} factory via {@link #getContributedEventFactories()}. At
|
||||
* startup, the contributions of every registered factory are merged into a
|
||||
* single {@link SsfEventRegistry}, which is then exposed through
|
||||
* {@link SsfEventProvider#getRegistry()}.
|
||||
*
|
||||
* <p>To register custom events, drop a factory that implements this interface
|
||||
* into {@code META-INF/services/org.keycloak.protocol.ssf.event.SsfEventProviderFactory}
|
||||
* and return your additional events from {@link #getContributedEventFactories()}.
|
||||
*/
|
||||
public interface SsfEventProviderFactory extends ProviderFactory<SsfEventProvider> {
|
||||
|
||||
/**
|
||||
* Returns the map of event-type URI to factory for the {@link SsfEvent}
|
||||
* instances this provider contributes to the global event registry.
|
||||
* Called once at startup, after
|
||||
* {@link ProviderFactory#init(org.keycloak.Config.Scope)} but before
|
||||
* {@link ProviderFactory#postInit(KeycloakSessionFactory)}.
|
||||
*
|
||||
* <p>The factory (typically a {@code SomeEvent::new} method reference)
|
||||
* lets the registry mint fresh instances at runtime without reflection —
|
||||
* used by the synthetic event emitter when the caller doesn't supply an
|
||||
* event body. Keying by URI lets the registry skip a probing
|
||||
* instantiation just to learn the event type.
|
||||
*
|
||||
* <p>The default implementation returns an empty map.
|
||||
*/
|
||||
default Map<String, Supplier<? extends SsfEvent>> getContributedEventFactories() {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the subset of {@link #getContributedEventFactories()} that the
|
||||
* transmitter can actually ship out — either because a Keycloak listener
|
||||
* / transmitter-side trigger produces them automatically, or because they
|
||||
* can be raised on demand via the admin emit API. Combined, the
|
||||
* contributions of every registered factory drive both the default
|
||||
* {@code events_supported} set advertised to receiver clients and the
|
||||
* whitelist of types the emit API will accept.
|
||||
*
|
||||
* <p>Events contributed purely for inbound parsing on the receiver
|
||||
* side MUST NOT be returned here. Advertising an event the transmitter
|
||||
* cannot ship would mislead receivers.
|
||||
*
|
||||
* <p>The default implementation returns an empty set.
|
||||
*/
|
||||
default Set<String> getEmittableEventTypes() {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the further subset of {@link #getEmittableEventTypes()} that the
|
||||
* transmitter emits natively — i.e. driven by Keycloak listener / trigger
|
||||
* logic rather than only by an explicit admin-API call. The admin UI
|
||||
* surfaces this set as the "built-in" badge on event entries: events the
|
||||
* operator does not have to script anything to receive.
|
||||
*
|
||||
* <p>Events that are emittable but only via the admin emit API
|
||||
* (no listener wiring on the transmitter side) MUST NOT be returned here.
|
||||
*
|
||||
* <p>The default implementation falls back to {@link #getEmittableEventTypes()}
|
||||
* so existing factories that don't distinguish keep their previous behaviour.
|
||||
*/
|
||||
default Set<String> getNativelyEmittedEventTypes() {
|
||||
return getEmittableEventTypes();
|
||||
}
|
||||
|
||||
@Override
|
||||
default void init(Config.Scope config) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
@Override
|
||||
default void close() {
|
||||
// no-op
|
||||
}
|
||||
|
||||
/**
|
||||
* Most factories registered through this SPI exist only to contribute
|
||||
* events to the shared {@link SsfEventRegistry} via
|
||||
* {@link #getContributedEventFactories()} — they don't supply a per-
|
||||
* session {@link SsfEventProvider} instance because callers resolve
|
||||
* {@code SsfEventProvider} through the configured default factory
|
||||
* (id {@code "default"}, shipped as {@code DefaultSsfEventProviderFactory}).
|
||||
*
|
||||
* <p>The default implementation returns {@code null} so contribution-
|
||||
* only extensions can keep their factory class to {@code getId()} +
|
||||
* {@code isSupported()} + the {@code getContributedEventFactories()}
|
||||
* map. Override only when your factory is intended to replace the
|
||||
* default provider entirely.
|
||||
*/
|
||||
@Override
|
||||
default SsfEventProvider create(KeycloakSession session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates the events contributed by all registered
|
||||
* {@link SsfEventProviderFactory} instances into a single immutable
|
||||
* {@link SsfEventRegistry}.
|
||||
*/
|
||||
static SsfEventRegistry buildRegistry(KeycloakSessionFactory sessionFactory) {
|
||||
Collection<? extends SsfEventProviderFactory> factories = sessionFactory
|
||||
.getProviderFactoriesStream(SsfEventProvider.class)
|
||||
.map(SsfEventProviderFactory.class::cast)
|
||||
.toList();
|
||||
|
||||
return SsfEventRegistry.from(factories);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package org.keycloak.ssf.event;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.ssf.event.stream.SsfStreamUpdatedEvent;
|
||||
import org.keycloak.ssf.event.stream.SsfStreamVerificationEvent;
|
||||
|
||||
/**
|
||||
* Immutable registry of all known SSF event types and their aliases.
|
||||
*
|
||||
* <p>The registry is built once at server startup by aggregating the events
|
||||
* contributed by every registered {@link SsfEventProviderFactory} and is then
|
||||
* exposed through {@link SsfEventProvider#getRegistry()}.
|
||||
*/
|
||||
public final class SsfEventRegistry {
|
||||
|
||||
private final Map<String, Class<? extends SsfEvent>> classByEventType;
|
||||
|
||||
private final Map<String, Class<? extends SsfEvent>> classByAlias;
|
||||
|
||||
private final Map<String, String> aliasByEventType;
|
||||
|
||||
private final Map<String, String> eventTypeByAlias;
|
||||
|
||||
private final Map<String, Supplier<? extends SsfEvent>> factoryByEventType;
|
||||
|
||||
private final Set<String> emittableEventTypes;
|
||||
|
||||
private final Set<String> nativelyEmittedEventTypes;
|
||||
|
||||
SsfEventRegistry(
|
||||
Map<String, Class<? extends SsfEvent>> classByEventType,
|
||||
Map<String, Class<? extends SsfEvent>> classByAlias,
|
||||
Map<String, String> aliasByEventType,
|
||||
Map<String, String> eventTypeByAlias,
|
||||
Map<String, Supplier<? extends SsfEvent>> factoryByEventType,
|
||||
Set<String> emittableEventTypes,
|
||||
Set<String> nativelyEmittedEventTypes) {
|
||||
this.classByEventType = Collections.unmodifiableMap(classByEventType);
|
||||
this.classByAlias = Collections.unmodifiableMap(classByAlias);
|
||||
this.aliasByEventType = Collections.unmodifiableMap(aliasByEventType);
|
||||
this.eventTypeByAlias = Collections.unmodifiableMap(eventTypeByAlias);
|
||||
this.factoryByEventType = Collections.unmodifiableMap(factoryByEventType);
|
||||
this.emittableEventTypes = Collections.unmodifiableSet(emittableEventTypes);
|
||||
this.nativelyEmittedEventTypes = Collections.unmodifiableSet(nativelyEmittedEventTypes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an aggregated registry from the contributions of the given
|
||||
* factories. Used by {@link SsfEventProviderFactory#buildRegistry}.
|
||||
*/
|
||||
static SsfEventRegistry from(Collection<? extends SsfEventProviderFactory> factories) {
|
||||
|
||||
Map<String, Class<? extends SsfEvent>> classByEventType = new HashMap<>();
|
||||
Map<String, Class<? extends SsfEvent>> classByAlias = new HashMap<>();
|
||||
Map<String, String> aliasByEventType = new HashMap<>();
|
||||
Map<String, String> eventTypeByAlias = new HashMap<>();
|
||||
Map<String, Supplier<? extends SsfEvent>> factoryByEventType = new HashMap<>();
|
||||
Set<String> emittableEventTypes = new LinkedHashSet<>();
|
||||
Set<String> nativelyEmittedEventTypes = new LinkedHashSet<>();
|
||||
|
||||
for (SsfEventProviderFactory factory : factories) {
|
||||
for (Map.Entry<String, Supplier<? extends SsfEvent>> entry
|
||||
: factory.getContributedEventFactories().entrySet()) {
|
||||
String eventType = entry.getKey();
|
||||
Supplier<? extends SsfEvent> eventFactory = entry.getValue();
|
||||
|
||||
// Instantiate once at registry-build time to derive the
|
||||
// event class (used by Jackson as the deserialization
|
||||
// target) and the alias (explicit override or fallback
|
||||
// to the class' simple name). The factory is stored
|
||||
// alongside so callers that need fresh instances at
|
||||
// runtime (e.g. the synthetic event emitter) can invoke
|
||||
// eventFactory.get() directly without reflection.
|
||||
SsfEvent sample = eventFactory.get();
|
||||
Class<? extends SsfEvent> eventClass = sample.getClass();
|
||||
String alias = sample.getAlias() != null ? sample.getAlias() : eventClass.getSimpleName();
|
||||
|
||||
classByEventType.put(eventType, eventClass);
|
||||
classByAlias.put(alias, eventClass);
|
||||
aliasByEventType.put(eventType, alias);
|
||||
eventTypeByAlias.put(alias, eventType);
|
||||
factoryByEventType.put(eventType, eventFactory);
|
||||
}
|
||||
emittableEventTypes.addAll(factory.getEmittableEventTypes());
|
||||
nativelyEmittedEventTypes.addAll(factory.getNativelyEmittedEventTypes());
|
||||
}
|
||||
|
||||
return new SsfEventRegistry(classByEventType, classByAlias, aliasByEventType,
|
||||
eventTypeByAlias, factoryByEventType, emittableEventTypes,
|
||||
nativelyEmittedEventTypes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the {@link SsfEvent} class for the given full event type URI,
|
||||
* or an empty {@link Optional} if the event type is not registered.
|
||||
*/
|
||||
public Optional<Class<? extends SsfEvent>> getEventClassByType(String eventType) {
|
||||
return Optional.ofNullable(classByEventType.get(eventType));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the factory for creating fresh {@link SsfEvent} instances of
|
||||
* the given event type URI. Used by callers like the synthetic event
|
||||
* emitter to build a default event body without reaching for
|
||||
* reflection. Returns an empty {@link Optional} if the event type is
|
||||
* not registered.
|
||||
*/
|
||||
public Optional<Supplier<? extends SsfEvent>> getEventFactoryByType(String eventType) {
|
||||
return Optional.ofNullable(factoryByEventType.get(eventType));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the {@link SsfEvent} class for the given alias or full event
|
||||
* type URI. Returns {@code null} if neither matches a known event.
|
||||
*/
|
||||
public Class<? extends SsfEvent> resolveEventClass(String aliasOrEventType) {
|
||||
Class<? extends SsfEvent> eventClass = classByAlias.get(aliasOrEventType);
|
||||
if (eventClass != null) {
|
||||
return eventClass;
|
||||
}
|
||||
return classByEventType.get(aliasOrEventType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the alias (e.g. {@code CaepCredentialChange}) for the given
|
||||
* full event type URI, or {@code null} if the event type is not registered.
|
||||
*/
|
||||
public String resolveAliasForEventType(String eventType) {
|
||||
return aliasByEventType.get(eventType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the full event type URI for the given alias, or {@code null}
|
||||
* if the alias is not registered.
|
||||
*/
|
||||
public String resolveEventTypeForAlias(String alias) {
|
||||
return eventTypeByAlias.get(alias);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the full set of known event aliases (sorted).
|
||||
*/
|
||||
public Set<String> getKnownAliases() {
|
||||
return Collections.unmodifiableSet(new TreeSet<>(eventTypeByAlias.keySet()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the full set of known event type URIs.
|
||||
*/
|
||||
public Set<String> getKnownEventTypes() {
|
||||
return Collections.unmodifiableSet(classByEventType.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream-internal lifecycle event types (verification SET, stream-updated SET).
|
||||
* The transmitter owns these end-to-end and external callers
|
||||
* (synthetic emit, admin UI) are intentionally not allowed to
|
||||
* forge them — letting an external caller fire a stream-updated
|
||||
* SET would let it spoof transmitter behaviour towards the
|
||||
* receiver. Both the synthetic emit gate and the admin UI's
|
||||
* "available supported events" list filter these out.
|
||||
*/
|
||||
public static final Set<String> STREAM_LIFECYCLE_EVENT_TYPES = Set.of(
|
||||
SsfStreamVerificationEvent.TYPE,
|
||||
SsfStreamUpdatedEvent.TYPE);
|
||||
|
||||
/**
|
||||
* Event types a receiver can legitimately request via
|
||||
* {@code events_requested} on stream-create / -update — i.e. the
|
||||
* full registry minus the {@link #STREAM_LIFECYCLE_EVENT_TYPES
|
||||
* protocol-internal lifecycle events} that only the transmitter
|
||||
* may produce. Drives the admin UI's "available supported events"
|
||||
* multi-select so operators can configure receivers to receive
|
||||
* <em>any</em> deliverable type — even events Keycloak doesn't
|
||||
* fire from native event listeners but that an external system
|
||||
* may produce via the synthetic emit endpoint.
|
||||
*/
|
||||
public Set<String> getReceiverRequestableEventTypes() {
|
||||
Set<String> known = classByEventType.keySet();
|
||||
if (known.isEmpty()) {
|
||||
return Set.of();
|
||||
}
|
||||
java.util.Set<String> result = new java.util.LinkedHashSet<>(known.size());
|
||||
for (String type : known) {
|
||||
if (!STREAM_LIFECYCLE_EVENT_TYPES.contains(type)) {
|
||||
result.add(type);
|
||||
}
|
||||
}
|
||||
return Collections.unmodifiableSet(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the subset of {@link #getReceiverRequestableEventTypes()}
|
||||
* the transmitter can ship out — either via a Keycloak listener / trigger
|
||||
* or via the admin emit API. Aggregates contributions from every
|
||||
* registered {@link SsfEventProviderFactory#getEmittableEventTypes()}.
|
||||
*
|
||||
* <p>Drives the default {@code events_supported} set advertised to receiver
|
||||
* clients and the whitelist of types the admin emit API will accept.
|
||||
*/
|
||||
public Set<String> getEmittableEventTypes() {
|
||||
return emittableEventTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the subset of {@link #getEmittableEventTypes()} that the
|
||||
* transmitter emits natively via Keycloak listener / trigger logic, as
|
||||
* declared by every registered
|
||||
* {@link SsfEventProviderFactory#getNativelyEmittedEventTypes()}.
|
||||
*
|
||||
* <p><b>Not an enforcement gate.</b> Purely informational — the admin UI
|
||||
* uses it to surface the "built-in" badge on event entries so operators
|
||||
* see which event types fire automatically from Keycloak vs which only
|
||||
* ship when something explicitly invokes the emit API.
|
||||
*/
|
||||
public Set<String> getNativelyEmittedEventTypes() {
|
||||
return nativelyEmittedEventTypes;
|
||||
}
|
||||
|
||||
public static Set<String> parseEventTypeAliases(String eventAliases) {
|
||||
return Set.copyOf(Stream.of(eventAliases.split(",")).map(String::trim).toList());
|
||||
}
|
||||
|
||||
public static SsfEventRegistry of(KeycloakSession session) {
|
||||
SsfEventProvider eventsProvider = session.getProvider(SsfEventProvider.class);
|
||||
if (eventsProvider == null) {
|
||||
return null;
|
||||
}
|
||||
return eventsProvider.getRegistry();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.keycloak.ssf.event;
|
||||
|
||||
import org.keycloak.provider.Provider;
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
import org.keycloak.provider.Spi;
|
||||
|
||||
/**
|
||||
* SPI that allows extensions to contribute additional SSF event types to the
|
||||
* shared {@link SsfEventRegistry}. Multiple factories can be registered and
|
||||
* each factory's {@link SsfEventProviderFactory#getContributedEventFactories()} is
|
||||
* aggregated at startup.
|
||||
*/
|
||||
public class SsfEventSpi implements Spi {
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "ssf-events";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isInternal() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends Provider> getProviderClass() {
|
||||
return SsfEventProvider.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("rawtypes")
|
||||
public Class<? extends ProviderFactory> getProviderFactoryClass() {
|
||||
return SsfEventProviderFactory.class;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package org.keycloak.ssf.event;
|
||||
|
||||
import org.keycloak.ssf.SsfException;
|
||||
|
||||
/**
|
||||
* Thrown by {@link SsfEvent#validate()} when an event instance is
|
||||
* missing a field the SSF / CAEP / RISC spec marks as REQUIRED — or
|
||||
* a custom event implementation enforces its own invariants.
|
||||
*
|
||||
* <p>Carries a stable {@link #MESSAGE_KEY} ({@code invalid_event_data})
|
||||
* plus structured {@code eventAlias} and {@code field} fields so
|
||||
* callers (REST emit response, admin UI) can compose a localised
|
||||
* message from the pieces instead of parsing an English string. The
|
||||
* inherited {@link #getMessage()} returns a mechanical
|
||||
* {@code "<key>: <alias>.<field>"} composition for log lines and
|
||||
* non-localised callers.
|
||||
*
|
||||
* <p>Caught by the synthetic-emit pipeline and surfaced to the caller
|
||||
* as an {@code invalid_event_payload} response so an operator pushing
|
||||
* a hand-rolled JSON event sees exactly which field is missing
|
||||
* instead of a half-populated SET landing at the receiver.
|
||||
*
|
||||
* <p>Native event production never throws this — the SSF event listener
|
||||
* builds events from typed Keycloak event details that always supply
|
||||
* the spec-required fields. The exception type lives on this layer
|
||||
* (rather than under {@code transmitter}) so {@link SsfEvent} subclasses
|
||||
* can throw it from their {@link SsfEvent#validate()} override without
|
||||
* pulling a transmitter-layer dependency into {@code ssf/core}.
|
||||
*/
|
||||
public class SsfEventValidationException extends SsfException {
|
||||
|
||||
/**
|
||||
* Stable, i18n-friendly message key for every validation failure.
|
||||
* Callers that localise messages key off this plus the
|
||||
* {@link #getEventAlias()} / {@link #getField()} structured fields.
|
||||
*/
|
||||
public static final String MESSAGE_KEY = "invalid_event_data";
|
||||
|
||||
private final String eventAlias;
|
||||
private final String field;
|
||||
|
||||
public SsfEventValidationException(String eventAlias, String field) {
|
||||
super(MESSAGE_KEY + ": " + eventAlias + "." + field);
|
||||
this.eventAlias = eventAlias;
|
||||
this.field = field;
|
||||
}
|
||||
|
||||
public String getMessageKey() {
|
||||
return MESSAGE_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias of the event that failed validation
|
||||
* (e.g. {@code CaepCredentialChange}). Sourced from
|
||||
* {@link SsfEvent#getAlias()} at the throw site.
|
||||
*/
|
||||
public String getEventAlias() {
|
||||
return eventAlias;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire-name of the field that failed validation
|
||||
* (e.g. {@code change_type}). Use the {@code @JsonProperty} value,
|
||||
* not the Java field name, so the operator sees the same identifier
|
||||
* they used in the JSON body.
|
||||
*/
|
||||
public String getField() {
|
||||
return field;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package org.keycloak.ssf.event.caep;
|
||||
|
||||
import org.keycloak.ssf.event.SsfEventValidationException;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
|
||||
/**
|
||||
* The Credential Change event signals that a credential was created, changed, revoked or deleted.
|
||||
*/
|
||||
public class CaepCredentialChange extends CaepEvent {
|
||||
|
||||
/**
|
||||
* See: https://openid.github.io/sharedsignals/openid-caep-1_0.html#name-credential-change
|
||||
*/
|
||||
public static final String TYPE = "https://schemas.openid.net/secevent/caep/event-type/credential-change";
|
||||
|
||||
|
||||
/**
|
||||
* This MUST be one of the following strings, or any other credential type supported mutually by the Transmitter and the Receiver.
|
||||
* password
|
||||
* pin
|
||||
* x509
|
||||
* fido2-platform
|
||||
* fido2-roaming
|
||||
* fido-u2f
|
||||
* verifiable-credential
|
||||
* phone-voice
|
||||
* phone-sms
|
||||
* app
|
||||
*/
|
||||
@JsonProperty("credential_type")
|
||||
protected String credentialType;
|
||||
|
||||
/**
|
||||
* This MUST be one of the following strings:
|
||||
*
|
||||
* create
|
||||
* revoke
|
||||
* update
|
||||
* delete
|
||||
*/
|
||||
@JsonProperty("change_type")
|
||||
@JsonDeserialize(using = CaepCredentialChangeChangeTypeDeserializer.class)
|
||||
protected ChangeType changeType;
|
||||
|
||||
/**
|
||||
* credential friendly name
|
||||
*/
|
||||
@JsonProperty("friendly_name")
|
||||
protected String friendlyName;
|
||||
|
||||
/**
|
||||
* issuer of the X.509 certificate as defined in [RFC5280]
|
||||
*/
|
||||
@JsonProperty("x509_issuer")
|
||||
protected String x509Issuer;
|
||||
|
||||
/**
|
||||
* serial number of the X.509 certificate as defined in [RFC5280]
|
||||
*/
|
||||
@JsonProperty("x509_serial")
|
||||
protected String x509Serial;
|
||||
|
||||
/**
|
||||
* FIDO2 Authenticator Attestation GUID as defined in [WebAuthn]
|
||||
*/
|
||||
@JsonProperty("fido2_aaguid")
|
||||
protected String fido2Aaguid;
|
||||
|
||||
public CaepCredentialChange() {
|
||||
super(TYPE);
|
||||
}
|
||||
|
||||
/**
|
||||
* CAEP Interop §3.3.1: both {@code credential_type} and
|
||||
* {@code change_type} are REQUIRED. We don't enforce the spec's
|
||||
* enumerated value list ({@code password}, {@code pin}, …) — the
|
||||
* spec also allows "any other credential type supported mutually
|
||||
* by the Transmitter and the Receiver", so a runtime gate would
|
||||
* reject legitimate extensions.
|
||||
*/
|
||||
@Override
|
||||
public void validate() {
|
||||
if (credentialType == null || credentialType.isBlank()) {
|
||||
throw new SsfEventValidationException(getAlias(), "credential_type");
|
||||
}
|
||||
if (changeType == null) {
|
||||
throw new SsfEventValidationException(getAlias(), "change_type");
|
||||
}
|
||||
}
|
||||
|
||||
public String getCredentialType() {
|
||||
return credentialType;
|
||||
}
|
||||
|
||||
public void setCredentialType(String credentialType) {
|
||||
this.credentialType = credentialType;
|
||||
}
|
||||
|
||||
public ChangeType getChangeType() {
|
||||
return changeType;
|
||||
}
|
||||
|
||||
public void setChangeType(ChangeType changeType) {
|
||||
this.changeType = changeType;
|
||||
}
|
||||
|
||||
public String getFriendlyName() {
|
||||
return friendlyName;
|
||||
}
|
||||
|
||||
public void setFriendlyName(String friendlyName) {
|
||||
this.friendlyName = friendlyName;
|
||||
}
|
||||
|
||||
public String getX509Issuer() {
|
||||
return x509Issuer;
|
||||
}
|
||||
|
||||
public void setX509Issuer(String x509Issuer) {
|
||||
this.x509Issuer = x509Issuer;
|
||||
}
|
||||
|
||||
public String getX509Serial() {
|
||||
return x509Serial;
|
||||
}
|
||||
|
||||
public void setX509Serial(String x509Serial) {
|
||||
this.x509Serial = x509Serial;
|
||||
}
|
||||
|
||||
public String getFido2Aaguid() {
|
||||
return fido2Aaguid;
|
||||
}
|
||||
|
||||
public void setFido2Aaguid(String fido2Aaguid) {
|
||||
this.fido2Aaguid = fido2Aaguid;
|
||||
}
|
||||
|
||||
/**
|
||||
* See: https://openid.net/specs/openid-caep-specification-1_0.html#rfc.section.3.3.1
|
||||
*/
|
||||
public enum CaepCredentialType {
|
||||
|
||||
PASSWORD("password"),
|
||||
PIN("pin"),
|
||||
X509("x509"),
|
||||
FIDO2_PLATFORM("fido2-platform"),
|
||||
FIDO2_ROAMING("fido2-roaming"),
|
||||
FIDO2_U2F("fido-u2f"),
|
||||
VERIFIABLE_CREDENTIAL("verifiable-credential"),
|
||||
PHONE_VOICE("phone-voice"),
|
||||
PHONE_SMS("phone-sms"),
|
||||
APP("app"),
|
||||
|
||||
// NON-STANDARD custom event
|
||||
CUSTOM("custom");
|
||||
|
||||
private final String type;
|
||||
|
||||
CaepCredentialType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
@JsonValue
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
@JsonCreator
|
||||
public static CaepCredentialType fromString(String type) {
|
||||
for (CaepCredentialType ct : values()) {
|
||||
if (ct.getType().equals(type)) {
|
||||
return ct;
|
||||
}
|
||||
}
|
||||
// fallback to custom
|
||||
return CUSTOM;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* See: https://openid.net/specs/openid-caep-specification-1_0.html#rfc.section.3.3.1
|
||||
*/
|
||||
public enum ChangeType {
|
||||
|
||||
CREATE("create"),
|
||||
REVOKE("revoke"),
|
||||
UPDATE("update"),
|
||||
DELETE("delete");
|
||||
|
||||
private final String type;
|
||||
|
||||
ChangeType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
@JsonValue
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "CredentialChange{" +
|
||||
"credentialType=" + credentialType +
|
||||
", changeType=" + changeType +
|
||||
", friendlyName='" + friendlyName + '\'' +
|
||||
", x509Issuer='" + x509Issuer + '\'' +
|
||||
", x509Serial='" + x509Serial + '\'' +
|
||||
", fido2Aaguid='" + fido2Aaguid + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package org.keycloak.ssf.event.caep;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
|
||||
/**
|
||||
* The official change types are create, revoke, update, deleted, but some legacy implementations use created etc.
|
||||
* See: https://openid.net/specs/openid-caep-specification-1_0.html#rfc.section.3.3.1
|
||||
*/
|
||||
public class CaepCredentialChangeChangeTypeDeserializer extends JsonDeserializer<CaepCredentialChange.ChangeType> {
|
||||
|
||||
private static final Map<String, CaepCredentialChange.ChangeType> CHANGE_TYPE_MAP = new HashMap<>();
|
||||
|
||||
static {
|
||||
// some existing SSF transmitters use (older) non standard change type identifiers.
|
||||
CHANGE_TYPE_MAP.put("create", CaepCredentialChange.ChangeType.CREATE);
|
||||
CHANGE_TYPE_MAP.put("created", CaepCredentialChange.ChangeType.CREATE); // Handle non-standard form
|
||||
|
||||
CHANGE_TYPE_MAP.put("revoke", CaepCredentialChange.ChangeType.REVOKE);
|
||||
CHANGE_TYPE_MAP.put("revoked", CaepCredentialChange.ChangeType.REVOKE); // Handle non-standard form
|
||||
|
||||
CHANGE_TYPE_MAP.put("update", CaepCredentialChange.ChangeType.UPDATE);
|
||||
CHANGE_TYPE_MAP.put("updated", CaepCredentialChange.ChangeType.UPDATE); // Handle non-standard form
|
||||
|
||||
CHANGE_TYPE_MAP.put("delete", CaepCredentialChange.ChangeType.DELETE);
|
||||
CHANGE_TYPE_MAP.put("deleted", CaepCredentialChange.ChangeType.DELETE); // Handle non-standard form
|
||||
}
|
||||
|
||||
@Override
|
||||
public CaepCredentialChange.ChangeType deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
|
||||
String value = p.getText().toLowerCase(); // Normalize input
|
||||
CaepCredentialChange.ChangeType changeType = CHANGE_TYPE_MAP.get(value);
|
||||
|
||||
if (changeType == null) {
|
||||
throw new IOException("Unknown changeType value: " + value);
|
||||
}
|
||||
|
||||
return changeType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.keycloak.ssf.event.caep;
|
||||
|
||||
import org.keycloak.ssf.event.SsfEvent;
|
||||
|
||||
/**
|
||||
* Generic CaepEvent.
|
||||
*
|
||||
* See: https://openid.net/specs/openid-caep-1_0-final.html
|
||||
*/
|
||||
public abstract class CaepEvent extends SsfEvent {
|
||||
|
||||
public CaepEvent(String type) {
|
||||
super(type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.keycloak.ssf.event.caep;
|
||||
|
||||
/**
|
||||
* Session Revoked signals that the session identified by the subject has been revoked. The explicit session identifier may be directly referenced in the subject or other properties of the session may be included to allow the receiver to identify applicable sessions.
|
||||
*/
|
||||
public class CaepSessionRevoked extends CaepEvent {
|
||||
|
||||
/**
|
||||
* See: https://openid.github.io/sharedsignals/openid-caep-1_0.html#name-session-revoked
|
||||
*/
|
||||
public static final String TYPE = "https://schemas.openid.net/secevent/caep/event-type/session-revoked";
|
||||
|
||||
public CaepSessionRevoked() {
|
||||
super(TYPE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "SessionRevoked{}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.keycloak.ssf.event.stream;
|
||||
|
||||
import org.keycloak.ssf.event.SsfEvent;
|
||||
|
||||
/**
|
||||
* Base class for all SSF stream related events.
|
||||
*/
|
||||
public abstract class SsfStreamEvent extends SsfEvent {
|
||||
|
||||
public SsfStreamEvent(String eventType) {
|
||||
super(eventType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package org.keycloak.ssf.event.stream;
|
||||
|
||||
import org.keycloak.ssf.stream.StreamStatus;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* SSF Stream status updated event.
|
||||
*
|
||||
* See: https://openid.net/specs/openid-sharedsignals-framework-1_0-final.html#name-stream-updated-event
|
||||
*/
|
||||
public class SsfStreamUpdatedEvent extends SsfStreamEvent {
|
||||
|
||||
public static final String TYPE = "https://schemas.openid.net/secevent/ssf/event-type/stream-updated";
|
||||
|
||||
/**
|
||||
* REQUIRED. Defines the new status of the stream.
|
||||
*/
|
||||
@JsonProperty("status")
|
||||
protected StreamStatus status;
|
||||
|
||||
/**
|
||||
* OPTIONAL. Provides a short description of why the Transmitter has updated the status.
|
||||
*/
|
||||
@JsonProperty("reason")
|
||||
protected String reason;
|
||||
|
||||
public SsfStreamUpdatedEvent() {
|
||||
super(TYPE);
|
||||
}
|
||||
|
||||
public StreamStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(StreamStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public String getReason() {
|
||||
return reason;
|
||||
}
|
||||
|
||||
public void setReason(String reason) {
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.keycloak.ssf.event.stream;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* SSF Verification event.
|
||||
*
|
||||
* See: https://openid.net/specs/openid-sharedsignals-framework-1_0-final.html#name-verification
|
||||
*/
|
||||
public class SsfStreamVerificationEvent extends SsfStreamEvent {
|
||||
|
||||
public static final String TYPE = "https://schemas.openid.net/secevent/ssf/event-type/verification";
|
||||
|
||||
@JsonProperty("state")
|
||||
protected String state;
|
||||
|
||||
public SsfStreamVerificationEvent() {
|
||||
super(TYPE);
|
||||
}
|
||||
|
||||
public String getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
public void setState(String state) {
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
// Render absent state as an empty object rather than "state='null'"
|
||||
// so the log representation matches what Jackson actually puts on
|
||||
// the wire (omitted thanks to @JsonInclude(NON_NULL)).
|
||||
return state == null
|
||||
? "VerificationEvent{}"
|
||||
: "VerificationEvent{state='" + state + "'}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package org.keycloak.ssf.event.token;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.keycloak.TokenCategory;
|
||||
import org.keycloak.json.StringOrArrayDeserializer;
|
||||
import org.keycloak.json.StringOrArraySerializer;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class AbstractSecurityEventToken implements SecurityEventToken {
|
||||
|
||||
@JsonProperty("jti")
|
||||
protected String jti;
|
||||
|
||||
@JsonProperty("iss")
|
||||
protected String iss;
|
||||
|
||||
@JsonProperty("iat")
|
||||
protected Integer iat;
|
||||
|
||||
@JsonProperty("aud")
|
||||
@JsonSerialize(using = StringOrArraySerializer.class)
|
||||
@JsonDeserialize(using = StringOrArrayDeserializer.class)
|
||||
protected String[] aud;
|
||||
|
||||
@JsonProperty("events")
|
||||
@JsonDeserialize(using = SsfEventMapJsonDeserializer.class)
|
||||
private Map<String, Object> events;
|
||||
|
||||
@Override
|
||||
public TokenCategory getCategory() {
|
||||
// SSF SETs are server-produced and never issued to a client the way
|
||||
// OIDC access/id tokens are, so no OIDC-oriented TokenCategory
|
||||
// applies cleanly. The category returned here is NOT consulted by
|
||||
// the SSF signing path: SecurityEventTokenDispatcher resolves the
|
||||
// JWS signature algorithm via SsfSignatureAlgorithms#resolveForStream
|
||||
// (stream override → transmitter SPI default → hardcoded RS256) and
|
||||
// passes it straight to SecurityEventTokenEncoder. Returning
|
||||
// INTERNAL here is the neutral fallback that satisfies the
|
||||
// org.keycloak.Token contract; if a future caller accidentally
|
||||
// routes a SET through session.tokens() helpers, they'll pick up
|
||||
// the internal-token signer instead of silently inheriting a
|
||||
// receiver client's OIDC access-token alg.
|
||||
return TokenCategory.INTERNAL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getJti() {
|
||||
return jti;
|
||||
}
|
||||
|
||||
public void setJti(String jti) {
|
||||
this.jti = jti;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getIss() {
|
||||
return iss;
|
||||
}
|
||||
|
||||
public void setIss(String iss) {
|
||||
this.iss = iss;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer getIat() {
|
||||
return iat;
|
||||
}
|
||||
|
||||
public void setIat(Integer iat) {
|
||||
this.iat = iat;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getAud() {
|
||||
return aud;
|
||||
}
|
||||
|
||||
public void setAud(String[] aud) {
|
||||
this.aud = aud;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getEvents() {
|
||||
if (events == null) {
|
||||
events = new LinkedHashMap<>();
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
public void setEvents(Map<String, Object> events) {
|
||||
this.events = events;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.keycloak.ssf.event.token;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.keycloak.Token;
|
||||
|
||||
public interface SecurityEventToken extends Token {
|
||||
|
||||
String getJti();
|
||||
|
||||
String getIss();
|
||||
|
||||
Integer getIat();
|
||||
|
||||
String[] getAud();
|
||||
|
||||
Map<String, Object> getEvents();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.keycloak.ssf.event.token;
|
||||
|
||||
/**
|
||||
* Legacy Security Event Token (SET) based on the Shared Signals and Events (SSE) standard.
|
||||
* This is required by some older SSF implementations, e.g. Apple Business Manager.
|
||||
*/
|
||||
public class SseCaepSecurityEventToken extends AbstractSecurityEventToken {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package org.keycloak.ssf.event.token;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.keycloak.ssf.Ssf;
|
||||
import org.keycloak.ssf.event.GenericSsfEvent;
|
||||
import org.keycloak.ssf.event.SsfEvent;
|
||||
import org.keycloak.ssf.event.SsfEventProvider;
|
||||
import org.keycloak.ssf.event.SsfEventRegistry;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Custom deserializer for Security Events.
|
||||
* <pre>
|
||||
* "events" (Security Events) Claim
|
||||
* This claim contains a set of event statements that each provide
|
||||
* information describing a single logical event that has occurred
|
||||
* about a security subject (e.g., a state change to the subject).
|
||||
* Multiple event identifiers with the same value MUST NOT be used.
|
||||
* The "events" claim MUST NOT be used to express multiple
|
||||
* independent logical events.
|
||||
*
|
||||
* The value of the "events" claim is a JSON object whose members are
|
||||
* name/value pairs whose names are URIs identifying the event
|
||||
* statements being expressed. Event identifiers SHOULD be stable
|
||||
* values (e.g., a permanent URL for an event specification). For
|
||||
* each name present, the corresponding value MUST be a JSON object.
|
||||
* The JSON object MAY be an empty object ("{}"), or it MAY be a JSON
|
||||
* object containing data described by the profiling specification.
|
||||
* </pre>
|
||||
* See: https://datatracker.ietf.org/doc/html/rfc8417#section-2.2
|
||||
*/
|
||||
public class SsfEventMapJsonDeserializer extends JsonDeserializer<Map<String, SsfEvent>> {
|
||||
|
||||
@Override
|
||||
public Map<String, SsfEvent> deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
|
||||
|
||||
ObjectMapper mapper = (ObjectMapper) p.getCodec();
|
||||
JsonNode node = mapper.readTree(p);
|
||||
|
||||
SsfEventRegistry registry = resolveRegistry();
|
||||
|
||||
Map<String, SsfEvent> eventsMap = new HashMap<>();
|
||||
|
||||
for (Map.Entry<String, JsonNode> entry : node.properties()) {
|
||||
String eventType = entry.getKey(); // Extracts event type key
|
||||
JsonNode eventData = entry.getValue(); // Extracts event data
|
||||
|
||||
Class<? extends SsfEvent> eventClass = resolveEventClass(registry, eventType);
|
||||
|
||||
SsfEvent event = mapper.treeToValue(eventData, eventClass);
|
||||
event.setEventType(eventType); // Manually set event type since it's not in JSON
|
||||
eventsMap.put(eventType, event);
|
||||
}
|
||||
|
||||
return eventsMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the {@link SsfEventRegistry} used to map event type URIs to
|
||||
* concrete {@link SsfEvent} subclasses. Uses the per-session
|
||||
* {@link SsfEventProvider} when a Keycloak session is bound to the
|
||||
* current thread; otherwise returns {@code null} so the deserializer
|
||||
* degrades to {@link GenericSsfEvent} for every event type rather than
|
||||
* failing with an NPE — this keeps SET parsing available to callers
|
||||
* (e.g. tests, background workers) that run outside a request scope.
|
||||
*/
|
||||
protected SsfEventRegistry resolveRegistry() {
|
||||
|
||||
SsfEventProvider events = Ssf.events();
|
||||
if (events == null) {
|
||||
return null;
|
||||
}
|
||||
return events.getRegistry();
|
||||
}
|
||||
|
||||
protected Class<? extends SsfEvent> resolveEventClass(SsfEventRegistry registry, String eventType) {
|
||||
if (registry == null) {
|
||||
return GenericSsfEvent.class;
|
||||
}
|
||||
return registry.getEventClassByType(eventType).orElse(GenericSsfEvent.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.keycloak.ssf.event.token;
|
||||
|
||||
import org.keycloak.ssf.subject.SubjectId;
|
||||
import org.keycloak.ssf.subject.SubjectIdJsonDeserializer;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
|
||||
/**
|
||||
* Represents a RFC8417 Security Event Token (SET).
|
||||
*
|
||||
* See: https://datatracker.ietf.org/doc/html/rfc8417
|
||||
*/
|
||||
public class SsfSecurityEventToken extends AbstractSecurityEventToken {
|
||||
|
||||
@JsonProperty("sub_id")
|
||||
@JsonDeserialize(using = SubjectIdJsonDeserializer.class)
|
||||
protected SubjectId subjectId;
|
||||
|
||||
@JsonProperty("txn")
|
||||
protected String txn;
|
||||
|
||||
public SubjectId getSubjectId() {
|
||||
return subjectId;
|
||||
}
|
||||
|
||||
public void setSubjectId(SubjectId subjectId) {
|
||||
this.subjectId = subjectId;
|
||||
}
|
||||
|
||||
public SsfSecurityEventToken subjectId(SubjectId subjectId) {
|
||||
setSubjectId(subjectId);
|
||||
return this;
|
||||
}
|
||||
|
||||
public SsfSecurityEventToken txn(String txn) {
|
||||
setTxn(txn);
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getTxn() {
|
||||
return txn;
|
||||
}
|
||||
|
||||
public void setTxn(String txn) {
|
||||
this.txn = txn;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.keycloak.ssf.metadata;
|
||||
|
||||
/**
|
||||
* Legal values for the SSF transmitter's {@code default_subjects}
|
||||
* metadata field (SSF 1.0 §7.1). Controls the transmitter's
|
||||
* default behavior for subject-scoped event delivery:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #ALL} — deliver events for every matching subject unless
|
||||
* a stream explicitly narrows the scope. Preserves the
|
||||
* transmitter's pre-subject-management behaviour.</li>
|
||||
* <li>{@link #NONE} — deliver events only for subjects that have been
|
||||
* explicitly subscribed (via receiver add-subject calls or via
|
||||
* admin-curated {@code ssf.notify.<clientId>} attributes).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The spec also permits a concrete Subject claim as the third option;
|
||||
* that variant is not supported in Keycloak today.
|
||||
*/
|
||||
public enum DefaultSubjects {
|
||||
ALL,
|
||||
NONE;
|
||||
|
||||
/**
|
||||
* Parses a case-insensitive string into a {@link DefaultSubjects}
|
||||
* value, returning {@code fallback} when the input is {@code null},
|
||||
* blank, or not a legal value. Used from SPI / config entrypoints
|
||||
* where user input needs to be tolerant of case.
|
||||
*/
|
||||
public static DefaultSubjects parseOrDefault(String value, DefaultSubjects fallback) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return DefaultSubjects.valueOf(value.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package org.keycloak.ssf.metadata;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAnyGetter;
|
||||
import com.fasterxml.jackson.annotation.JsonAnySetter;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public class TransmitterMetadata {
|
||||
|
||||
@JsonProperty("spec_version")
|
||||
private String specVersion;
|
||||
|
||||
@JsonProperty("issuer")
|
||||
private String issuer;
|
||||
|
||||
@JsonProperty("jwks_uri")
|
||||
private String jwksUri;
|
||||
|
||||
@JsonProperty("delivery_methods_supported")
|
||||
private Set<String> deliveryMethodSupported;
|
||||
|
||||
@JsonProperty("configuration_endpoint")
|
||||
private String configurationEndpoint;
|
||||
|
||||
@JsonProperty("status_endpoint")
|
||||
private String statusEndpoint;
|
||||
|
||||
@JsonProperty("add_subject_endpoint")
|
||||
private String addSubjectEndpoint;
|
||||
|
||||
@JsonProperty("remove_subject_endpoint")
|
||||
private String removeSubjectEndpoint;
|
||||
|
||||
@JsonProperty("verification_endpoint")
|
||||
private String verificationEndpoint;
|
||||
|
||||
@JsonProperty("critical_subject_members")
|
||||
private Set<String> criticalSubjectMembers;
|
||||
|
||||
@JsonProperty("default_subjects")
|
||||
private String defaultSubjects;
|
||||
|
||||
@JsonProperty("authorization_schemes")
|
||||
private List<Map<String, Object>> authorizationSchemes;
|
||||
|
||||
@JsonIgnore
|
||||
private final Map<String, Object> metadata = new HashMap<String, Object>();
|
||||
|
||||
public TransmitterMetadata() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy constructor. Shallow-copies the collection fields so the new
|
||||
* instance can be mutated independently of {@code other}. Inner
|
||||
* {@code Map<String, Object>} entries inside
|
||||
* {@link #authorizationSchemes} are shared by reference — callers
|
||||
* that need to mutate individual scheme maps should copy them
|
||||
* themselves.
|
||||
*/
|
||||
public TransmitterMetadata(TransmitterMetadata other) {
|
||||
this.specVersion = other.specVersion;
|
||||
this.issuer = other.issuer;
|
||||
this.jwksUri = other.jwksUri;
|
||||
this.deliveryMethodSupported = other.deliveryMethodSupported == null
|
||||
? null : new HashSet<>(other.deliveryMethodSupported);
|
||||
this.configurationEndpoint = other.configurationEndpoint;
|
||||
this.statusEndpoint = other.statusEndpoint;
|
||||
this.addSubjectEndpoint = other.addSubjectEndpoint;
|
||||
this.removeSubjectEndpoint = other.removeSubjectEndpoint;
|
||||
this.verificationEndpoint = other.verificationEndpoint;
|
||||
this.criticalSubjectMembers = other.criticalSubjectMembers == null
|
||||
? null : new HashSet<>(other.criticalSubjectMembers);
|
||||
this.defaultSubjects = other.defaultSubjects;
|
||||
this.authorizationSchemes = other.authorizationSchemes == null
|
||||
? null : new ArrayList<>(other.authorizationSchemes);
|
||||
this.metadata.putAll(other.metadata);
|
||||
}
|
||||
|
||||
public String getSpecVersion() {
|
||||
return specVersion;
|
||||
}
|
||||
|
||||
public void setSpecVersion(String specVersion) {
|
||||
this.specVersion = specVersion;
|
||||
}
|
||||
|
||||
public String getIssuer() {
|
||||
return issuer;
|
||||
}
|
||||
|
||||
public void setIssuer(String issuer) {
|
||||
this.issuer = issuer;
|
||||
}
|
||||
|
||||
public String getJwksUri() {
|
||||
return jwksUri;
|
||||
}
|
||||
|
||||
public void setJwksUri(String jwksUri) {
|
||||
this.jwksUri = jwksUri;
|
||||
}
|
||||
|
||||
public Set<String> getDeliveryMethodSupported() {
|
||||
return deliveryMethodSupported;
|
||||
}
|
||||
|
||||
public void setDeliveryMethodSupported(Set<String> deliveryMethodSupported) {
|
||||
this.deliveryMethodSupported = deliveryMethodSupported;
|
||||
}
|
||||
|
||||
public String getConfigurationEndpoint() {
|
||||
return configurationEndpoint;
|
||||
}
|
||||
|
||||
public void setConfigurationEndpoint(String configurationEndpoint) {
|
||||
this.configurationEndpoint = configurationEndpoint;
|
||||
}
|
||||
|
||||
public String getStatusEndpoint() {
|
||||
return statusEndpoint;
|
||||
}
|
||||
|
||||
public void setStatusEndpoint(String statusEndpoint) {
|
||||
this.statusEndpoint = statusEndpoint;
|
||||
}
|
||||
|
||||
public String getAddSubjectEndpoint() {
|
||||
return addSubjectEndpoint;
|
||||
}
|
||||
|
||||
public void setAddSubjectEndpoint(String addSubjectEndpoint) {
|
||||
this.addSubjectEndpoint = addSubjectEndpoint;
|
||||
}
|
||||
|
||||
public String getRemoveSubjectEndpoint() {
|
||||
return removeSubjectEndpoint;
|
||||
}
|
||||
|
||||
public void setRemoveSubjectEndpoint(String removeSubjectEndpoint) {
|
||||
this.removeSubjectEndpoint = removeSubjectEndpoint;
|
||||
}
|
||||
|
||||
public String getVerificationEndpoint() {
|
||||
return verificationEndpoint;
|
||||
}
|
||||
|
||||
public void setVerificationEndpoint(String verificationEndpoint) {
|
||||
this.verificationEndpoint = verificationEndpoint;
|
||||
}
|
||||
|
||||
public Set<String> getCriticalSubjectMembers() {
|
||||
return criticalSubjectMembers;
|
||||
}
|
||||
|
||||
public void setCriticalSubjectMembers(Set<String> criticalSubjectMembers) {
|
||||
this.criticalSubjectMembers = criticalSubjectMembers;
|
||||
}
|
||||
|
||||
public String getDefaultSubjects() {
|
||||
return defaultSubjects;
|
||||
}
|
||||
|
||||
public void setDefaultSubjects(String defaultSubjects) {
|
||||
this.defaultSubjects = defaultSubjects;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getAuthorizationSchemes() {
|
||||
return authorizationSchemes;
|
||||
}
|
||||
|
||||
public void setAuthorizationSchemes(List<Map<String, Object>> authorizationSchemes) {
|
||||
this.authorizationSchemes = authorizationSchemes;
|
||||
}
|
||||
|
||||
@JsonAnySetter
|
||||
public void setMetadata(String key, Object value) {
|
||||
metadata.put(key, value);
|
||||
}
|
||||
|
||||
@JsonAnyGetter
|
||||
public Map<String, Object> getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "TransmitterMetadata{" +
|
||||
"specVersion='" + specVersion + '\'' +
|
||||
", issuer='" + issuer + '\'' +
|
||||
", jwksUri='" + jwksUri + '\'' +
|
||||
", deliveryMethodSupported=" + deliveryMethodSupported +
|
||||
", configurationEndpoint='" + configurationEndpoint + '\'' +
|
||||
", statusEndpoint='" + statusEndpoint + '\'' +
|
||||
", addSubjectEndpoint='" + addSubjectEndpoint + '\'' +
|
||||
", removeSubjectEndpoint='" + removeSubjectEndpoint + '\'' +
|
||||
", verificationEndpoint='" + verificationEndpoint + '\'' +
|
||||
", criticalSubjectMembers=" + criticalSubjectMembers +
|
||||
", defaultSubjects='" + defaultSubjects + '\'' +
|
||||
", authorizationSchemes=" + authorizationSchemes +
|
||||
", metadata=" + metadata +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package org.keycloak.ssf.stream;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAnySetter;
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* See SET Token Delivery Using HTTP Profile https://openid.net/specs/openid-sharedsignals-framework-1_0.html#section-10.3.1.1
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public abstract class AbstractDeliveryMethodRepresentation {
|
||||
|
||||
/**
|
||||
* Receiver-Supplied, REQUIRED. The specific delivery method to be used. This can be any one of "urn:ietf:rfc:8935" (push) or "urn:ietf:rfc:8936" (poll), but not both.
|
||||
*/
|
||||
@JsonProperty("method")
|
||||
private final DeliveryMethod method;
|
||||
|
||||
/**
|
||||
* endpoint_url
|
||||
* The URL where events are pushed through HTTP POST. This is set by the Receiver. If a Receiver is using multiple streams from a single Transmitter and needs to keep the SETs separated, it is RECOMMENDED that the URL for each stream be unique.
|
||||
*/
|
||||
@JsonProperty("endpoint_url")
|
||||
private final URI endpointUrl;
|
||||
|
||||
/**
|
||||
* authorization_header
|
||||
*
|
||||
* The HTTP Authorization header that the Transmitter MUST set with each event delivery, if the configuration is present. The value is optional, and it is set by the Receiver.
|
||||
*/
|
||||
@JsonProperty("authorization_header")
|
||||
private String authorizationHeader;
|
||||
|
||||
private Map<String, Object> metadata;
|
||||
|
||||
protected AbstractDeliveryMethodRepresentation(DeliveryMethod method, URI endpointUrl) {
|
||||
this.method = method;
|
||||
this.endpointUrl = endpointUrl;
|
||||
}
|
||||
|
||||
public DeliveryMethod getMethod() {
|
||||
return method;
|
||||
}
|
||||
|
||||
public URI getEndpointUrl() {
|
||||
return endpointUrl;
|
||||
}
|
||||
|
||||
public String getAuthorizationHeader() {
|
||||
return authorizationHeader;
|
||||
}
|
||||
|
||||
public void setAuthorizationHeader(String authorizationHeader) {
|
||||
this.authorizationHeader = authorizationHeader;
|
||||
}
|
||||
|
||||
@JsonAnySetter
|
||||
public void setMetadataValue(String key, Object value) {
|
||||
if (metadata == null) {
|
||||
metadata = new HashMap<>();
|
||||
}
|
||||
this.metadata.put(key, value);
|
||||
}
|
||||
|
||||
public Object getMetadataValue(String key) {
|
||||
if (metadata == null) {
|
||||
metadata = new HashMap<>();
|
||||
}
|
||||
return this.metadata.get(key);
|
||||
}
|
||||
|
||||
@JsonCreator
|
||||
public static AbstractDeliveryMethodRepresentation create(@JsonProperty("method") DeliveryMethod method, @JsonProperty("endpoint_url") URI endpointUrl, @JsonProperty("authorization_header") String authorizationHeader) {
|
||||
switch (method) {
|
||||
case PUSH:
|
||||
return new PushDeliveryMethodRepresentation(endpointUrl, authorizationHeader);
|
||||
case POLL:
|
||||
return new PollDeliveryMethodRepresentation(endpointUrl);
|
||||
default:
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.keycloak.ssf.stream;
|
||||
|
||||
import org.keycloak.ssf.subject.SubjectId;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public class AddSubjectRequest {
|
||||
|
||||
/**
|
||||
* REQUIRED. A string identifying the stream to which the subject is being added.
|
||||
*/
|
||||
@JsonProperty("stream_id")
|
||||
private String streamId;
|
||||
|
||||
/**
|
||||
* REQUIRED. A Subject claim identifying the subject to be added.
|
||||
*/
|
||||
@JsonProperty("subject")
|
||||
private SubjectId subject;
|
||||
|
||||
/**
|
||||
* OPTIONAL. A boolean value; when true, it indicates that the Event Receiver has verified the Subject claim. When false, it indicates that the Event Receiver has not verified the Subject claim. If omitted, Event Transmitters SHOULD assume that the subject has been verified.
|
||||
*/
|
||||
@JsonProperty("verified")
|
||||
private Boolean verified;
|
||||
|
||||
public String getStreamId() {
|
||||
return streamId;
|
||||
}
|
||||
|
||||
public void setStreamId(String streamId) {
|
||||
this.streamId = streamId;
|
||||
}
|
||||
|
||||
public SubjectId getSubject() {
|
||||
return subject;
|
||||
}
|
||||
|
||||
public void setSubject(SubjectId subject) {
|
||||
this.subject = subject;
|
||||
}
|
||||
|
||||
public Boolean getVerified() {
|
||||
return verified;
|
||||
}
|
||||
|
||||
public void setVerified(Boolean verified) {
|
||||
this.verified = verified;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.keycloak.ssf.stream;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public class CreateStreamRequest {
|
||||
|
||||
/**
|
||||
* Receiver-Supplied, OPTIONAL. An array of URIs identifying the set of events that the Receiver requested. A Receiver SHOULD request only the events that it understands and it can act on. This is configurable by the Receiver. A Transmitter MUST ignore any array values that it does not understand. This array SHOULD NOT be empty.
|
||||
*/
|
||||
@JsonProperty("events_requested")
|
||||
private Set<String> eventsRequested;
|
||||
|
||||
/**
|
||||
* Receiver-Supplied, OPTIONAL. A JSON object containing a set of name/value pairs specifying configuration parameters for the SET delivery method. The actual delivery method is identified by the special key "method" with the value being a URI as defined in Section 10.3.1. The value of the "delivery" field contains two sub-fields:
|
||||
*/
|
||||
@JsonProperty("delivery")
|
||||
private AbstractDeliveryMethodRepresentation delivery;
|
||||
|
||||
/**
|
||||
* Receiver-Supplied, OPTIONAL. A string that describes the properties of the stream. This is useful in multi-stream systems to identify the stream for human actors. The transmitter MAY truncate the string beyond an allowed max length.
|
||||
*/
|
||||
@JsonProperty("description")
|
||||
private String description;
|
||||
|
||||
public Set<String> getEventsRequested() {
|
||||
return eventsRequested;
|
||||
}
|
||||
|
||||
public void setEventsRequested(Set<String> eventsRequested) {
|
||||
this.eventsRequested = eventsRequested;
|
||||
}
|
||||
|
||||
public AbstractDeliveryMethodRepresentation getDelivery() {
|
||||
return delivery;
|
||||
}
|
||||
|
||||
public void setDelivery(AbstractDeliveryMethodRepresentation delivery) {
|
||||
this.delivery = delivery;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.ssf.stream;
|
||||
|
||||
import org.keycloak.ssf.Ssf;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public enum DeliveryMethod {
|
||||
|
||||
// Standard SSF Delivery Methods
|
||||
PUSH(Ssf.DELIVERY_METHOD_PUSH_URI),
|
||||
POLL(Ssf.DELIVERY_METHOD_POLL_URI),
|
||||
|
||||
RISC_PUSH(Ssf.DELIVERY_METHOD_RISC_PUSH_URI),
|
||||
RISC_POLL(Ssf.DELIVERY_METHOD_RISC_POLL_URI);
|
||||
|
||||
private final String specUrn;
|
||||
|
||||
DeliveryMethod(String specUrn) {
|
||||
this.specUrn = specUrn;
|
||||
}
|
||||
|
||||
public static DeliveryMethod valueOfUri(String deliveryMethod) {
|
||||
for(DeliveryMethod dm : values()) {
|
||||
if (dm.specUrn.equals(deliveryMethod)) {
|
||||
return dm;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Unknown delivery method: " + deliveryMethod);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coarse-grained PUSH/POLL family. Both spec variants of each transport
|
||||
* (RFC 8935 + legacy RISC PUSH; RFC 8936 + legacy RISC POLL) collapse to
|
||||
* the same family so per-client allow-listing operates on the operator's
|
||||
* mental model rather than four separate URIs.
|
||||
*/
|
||||
public DeliveryMethodFamily family() {
|
||||
return switch (this) {
|
||||
case PUSH, RISC_PUSH -> DeliveryMethodFamily.PUSH;
|
||||
case POLL, RISC_POLL -> DeliveryMethodFamily.POLL;
|
||||
};
|
||||
}
|
||||
|
||||
@JsonValue
|
||||
public String getSpecUri() {
|
||||
return specUrn;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return specUrn;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2026 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.ssf.stream;
|
||||
|
||||
/**
|
||||
* Coarse-grained classification of {@link DeliveryMethod} into the two
|
||||
* delivery families operators reason about: PUSH (transmitter calls out
|
||||
* to receiver) and POLL (receiver pulls from transmitter). Used by the
|
||||
* per-receiver {@code ssf.allowedDeliveryMethods} client-attribute gate
|
||||
* so admins configure one of two values rather than enumerating the
|
||||
* spec-standard plus legacy SSE-CAEP RISC URI variants individually.
|
||||
*
|
||||
* <p>Use {@link DeliveryMethod#family()} to map a concrete method onto
|
||||
* its family or {@link #ofMethodValue(String)} to parse a wire/attribute
|
||||
* value (lowercase {@code push} / {@code poll}, case-insensitive on input).
|
||||
*/
|
||||
public enum DeliveryMethodFamily {
|
||||
|
||||
/** Transmitter-initiated HTTP POST (RFC 8935 + legacy RISC PUSH). */
|
||||
PUSH,
|
||||
|
||||
/** Receiver-initiated HTTP GET (RFC 8936 + legacy RISC POLL). */
|
||||
POLL;
|
||||
|
||||
/**
|
||||
* Parses an attribute / wire value into a {@link DeliveryMethodFamily}.
|
||||
* Case-insensitive; whitespace-tolerant. Returns {@code null} for
|
||||
* unrecognised input rather than throwing — callers typically treat
|
||||
* unknown entries as "skip this entry" when reading the per-client
|
||||
* allowed-methods list.
|
||||
*/
|
||||
public static DeliveryMethodFamily ofMethodValue(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String normalized = value.trim().toLowerCase();
|
||||
return switch (normalized) {
|
||||
case "push" -> PUSH;
|
||||
case "poll" -> POLL;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Canonical lowercase string used in client-attribute storage. */
|
||||
public String getValue() {
|
||||
return name().toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.keycloak.ssf.stream;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
public class PollDeliveryMethodRepresentation extends AbstractDeliveryMethodRepresentation {
|
||||
|
||||
public PollDeliveryMethodRepresentation(URI endpointUrl) {
|
||||
super(DeliveryMethod.POLL, endpointUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.keycloak.ssf.stream;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Objects;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* See: 10.3.1.1. Push Delivery using HTTP https://openid.net/specs/openid-sharedsignals-framework-1_0.html#section-10.3.1.1
|
||||
*/
|
||||
public class PushDeliveryMethodRepresentation extends AbstractDeliveryMethodRepresentation {
|
||||
|
||||
|
||||
/**
|
||||
* authorization_header
|
||||
*
|
||||
* The HTTP Authorization header that the Transmitter MUST set with each event delivery, if the configuration is present. The value is optional and it is set by the Receiver.
|
||||
*/
|
||||
@JsonProperty("authorization_header")
|
||||
protected String authorizationHeader;
|
||||
|
||||
/**
|
||||
* @param endpointUrl MUST be supplied by the Receiver
|
||||
* @param authorizationHeader MAY be supploed by the Receiver
|
||||
*/
|
||||
public PushDeliveryMethodRepresentation(URI endpointUrl, String authorizationHeader) {
|
||||
super(DeliveryMethod.PUSH, Objects.requireNonNull(endpointUrl, "endpointUrl"));
|
||||
this.authorizationHeader = authorizationHeader;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAuthorizationHeader() {
|
||||
return authorizationHeader;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setAuthorizationHeader(String authorizationHeader) {
|
||||
this.authorizationHeader = authorizationHeader;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.keycloak.ssf.stream;
|
||||
|
||||
import org.keycloak.ssf.subject.SubjectId;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public class RemoveSubjectRequest {
|
||||
/**
|
||||
* REQUIRED. A string identifying the stream to which the subject is being added.
|
||||
*/
|
||||
@JsonProperty("stream_id")
|
||||
private String streamId;
|
||||
|
||||
/**
|
||||
* REQUIRED. A Subject claim identifying the subject to be added.
|
||||
*/
|
||||
@JsonProperty("subject")
|
||||
private SubjectId subject;
|
||||
|
||||
public String getStreamId() {
|
||||
return streamId;
|
||||
}
|
||||
|
||||
public void setStreamId(String streamId) {
|
||||
this.streamId = streamId;
|
||||
}
|
||||
|
||||
public SubjectId getSubject() {
|
||||
return subject;
|
||||
}
|
||||
|
||||
public void setSubject(SubjectId subject) {
|
||||
this.subject = subject;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package org.keycloak.ssf.stream;
|
||||
|
||||
public class ReplaceSsfStreamRequest extends UpdateSsfStreamRequest {
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package org.keycloak.ssf.stream;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
|
||||
|
||||
/**
|
||||
* See: https://openid.net/specs/openid-sharedsignals-framework-1_0.html#name-stream-configuration
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@JsonPropertyOrder({"iss", "aud", "events_supported", "events_requested", "events_delivered", "delivery", "min_verification_interval", "format"})
|
||||
public class SsfStreamRepresentation {
|
||||
|
||||
//see: https://openid.net/specs/openid-sharedsignals-framework-1_0.html#section-7.1.1
|
||||
|
||||
/**
|
||||
* Transmitter-Supplied, REQUIRED. A string that uniquely identifies the stream. A Transmitter MUST generate a unique ID for each of its non-deleted streams at the time of stream creation.
|
||||
*/
|
||||
@JsonProperty("stream_id")
|
||||
private String id;
|
||||
|
||||
/**
|
||||
* Receiver-Supplied, OPTIONAL. A string that describes the properties of the stream. This is useful in multi-stream systems to identify the stream for human actors. The transmitter MAY truncate the string beyond an allowed max length.
|
||||
*/
|
||||
@JsonProperty("description")
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* Transmitter-Supplied, REQUIRED. A URL using the https scheme with no query or fragment component that the Transmitter asserts as its Issuer Identifier. This MUST be identical to the "iss" Claim value in Security Event Tokens issued from this Transmitter.
|
||||
*/
|
||||
@JsonProperty("iss")
|
||||
private URI issuer;
|
||||
|
||||
/**
|
||||
* Transmitter-Supplied, REQUIRED. A string or an array of strings containing an audience claim as defined in JSON Web Token (JWT)[RFC7519] that identifies the Event Receiver(s) for the Event Stream. This property cannot be updated. If multiple Receivers are specified then the Transmitter SHOULD know that these Receivers are the same entity.
|
||||
*/
|
||||
@JsonProperty("aud")
|
||||
private Object audience; // Can be URI or List<URI>
|
||||
|
||||
/**
|
||||
* Transmitter-Supplied, OPTIONAL. An array of URIs identifying the set of events supported by the Transmitter for this Receiver. If omitted, Event Transmitters SHOULD make this set available to the Event Receiver via some other means (e.g. publishing it in online documentation).
|
||||
*/
|
||||
@JsonProperty("events_supported")
|
||||
private List<URI> eventsSupported;
|
||||
|
||||
/**
|
||||
* Receiver-Supplied, OPTIONAL. An array of URIs identifying the set of events that the Receiver requested. A Receiver SHOULD request only the events that it understands and it can act on. This is configurable by the Receiver. A Transmitter MUST ignore any array values that it does not understand. This array SHOULD NOT be empty.
|
||||
*/
|
||||
@JsonProperty("events_requested")
|
||||
private List<URI> eventsRequested;
|
||||
|
||||
/**
|
||||
* Transmitter-Supplied, REQUIRED. An array of URIs identifying the set of events that the Transmitter MUST include in the stream. This is a subset (not necessarily a proper subset) of the intersection of "events_supported" and "events_requested". A Receiver MUST rely on the values received in this field to understand which event types it can expect from the Transmitter.
|
||||
*/
|
||||
@JsonProperty("events_delivered")
|
||||
private List<URI> eventsDelivered;
|
||||
|
||||
/**
|
||||
* REQUIRED. A JSON object containing a set of name/value pairs specifying configuration parameters for the SET delivery method. The actual delivery method is identified by the special key "method" with the value being a URI as defined in Section 10.3.1. The value of the "delivery" field contains two sub-fields:
|
||||
*/
|
||||
@JsonProperty("delivery")
|
||||
private AbstractDeliveryMethodRepresentation delivery;
|
||||
|
||||
/**
|
||||
* Transmitter-Supplied, OPTIONAL. An integer indicating the minimum amount of time in seconds that must pass in between verification requests. If an Event Receiver submits verification requests more frequently than this, the Event Transmitter MAY respond with a 429 status code. An Event Transmitter SHOULD NOT respond with a 429 status code if an Event Receiver is not exceeding this frequency.
|
||||
*/
|
||||
@JsonProperty("min_verification_interval")
|
||||
private Integer minVerificationInterval;
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public URI getIssuer() {
|
||||
return issuer;
|
||||
}
|
||||
|
||||
public void setIssuer(URI issuer) {
|
||||
this.issuer = issuer;
|
||||
}
|
||||
|
||||
public Object getAudience() {
|
||||
return audience;
|
||||
}
|
||||
|
||||
public void setAudience(Object audience) {
|
||||
this.audience = audience;
|
||||
}
|
||||
|
||||
public List<URI> getEventsSupported() {
|
||||
return eventsSupported;
|
||||
}
|
||||
|
||||
public void setEventsSupported(List<URI> eventsSupported) {
|
||||
this.eventsSupported = eventsSupported;
|
||||
}
|
||||
|
||||
public List<URI> getEventsRequested() {
|
||||
return eventsRequested;
|
||||
}
|
||||
|
||||
public void setEventsRequested(List<URI> eventsRequested) {
|
||||
this.eventsRequested = eventsRequested;
|
||||
}
|
||||
|
||||
public List<URI> getEventsDelivered() {
|
||||
return eventsDelivered;
|
||||
}
|
||||
|
||||
public void setEventsDelivered(List<URI> eventsDelivered) {
|
||||
this.eventsDelivered = eventsDelivered;
|
||||
}
|
||||
|
||||
public AbstractDeliveryMethodRepresentation getDelivery() {
|
||||
return delivery;
|
||||
}
|
||||
|
||||
public void setDelivery(AbstractDeliveryMethodRepresentation delivery) {
|
||||
this.delivery = delivery;
|
||||
}
|
||||
|
||||
public Integer getMinVerificationInterval() {
|
||||
return minVerificationInterval;
|
||||
}
|
||||
|
||||
public void setMinVerificationInterval(Integer minVerificationInterval) {
|
||||
this.minVerificationInterval = minVerificationInterval;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.keycloak.ssf.stream;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public class SsfStreamStatusRepresentation {
|
||||
|
||||
@JsonProperty("stream_id")
|
||||
private String streamId;
|
||||
|
||||
@JsonProperty("status")
|
||||
private StreamStatus status;
|
||||
|
||||
@JsonProperty("reason")
|
||||
private String reason;
|
||||
|
||||
public String getStreamId() {
|
||||
return streamId;
|
||||
}
|
||||
|
||||
public void setStreamId(String streamId) {
|
||||
this.streamId = streamId;
|
||||
}
|
||||
|
||||
public StreamStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(StreamStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public String getReason() {
|
||||
return reason;
|
||||
}
|
||||
|
||||
public void setReason(String reason) {
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package org.keycloak.ssf.stream;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* Represents a stream status in the SSF transmitter.
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class StreamStatus {
|
||||
|
||||
@JsonProperty("stream_id")
|
||||
private String streamId;
|
||||
|
||||
@JsonProperty("status")
|
||||
private String status;
|
||||
|
||||
@JsonProperty("reason")
|
||||
private String reason;
|
||||
|
||||
public String getStreamId() {
|
||||
return streamId;
|
||||
}
|
||||
|
||||
public void setStreamId(String streamId) {
|
||||
this.streamId = streamId;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public String getReason() {
|
||||
return reason;
|
||||
}
|
||||
|
||||
public void setReason(String reason) {
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.keycloak.ssf.stream;
|
||||
|
||||
public enum StreamStatusValue {
|
||||
|
||||
/**
|
||||
* The Transmitter MUST transmit events over the stream, according to the stream's configured delivery method.
|
||||
*/
|
||||
enabled,
|
||||
|
||||
/**
|
||||
* The Transmitter MUST NOT transmit events over the stream. The Transmitter will hold any events it would have transmitted while paused, and SHOULD transmit them when the stream's status becomes "enabled". If a Transmitter holds successive events that affect the same Subject Principal, then the Transmitter MUST make sure that those events are transmitted in the order of time that they were generated OR the Transmitter MUST send only the last events that do not require the previous events affecting the same Subject Principal to be processed by the Receiver, because the previous events are either cancelled by the later events or the previous events are outdated.
|
||||
*/
|
||||
paused,
|
||||
|
||||
/**
|
||||
* The Transmitter MUST NOT transmit events over the stream and will not hold any events for later transmission.
|
||||
*/
|
||||
disabled;
|
||||
|
||||
public String getStatusCode() {
|
||||
return name().toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package org.keycloak.ssf.stream;
|
||||
|
||||
public class UpdateSsfStreamRequest extends SsfStreamRepresentation {
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.keycloak.ssf.subject;
|
||||
|
||||
/**
|
||||
* See: https://datatracker.ietf.org/doc/html/rfc9493#name-email-identifier-format
|
||||
*/
|
||||
public class AccountSubjectId extends SubjectId {
|
||||
|
||||
public static final String TYPE = "account";
|
||||
|
||||
protected String uri;
|
||||
|
||||
public AccountSubjectId() {
|
||||
super(TYPE);
|
||||
}
|
||||
|
||||
public String getUri() {
|
||||
return uri;
|
||||
}
|
||||
|
||||
public void setUri(String uri) {
|
||||
this.uri = uri;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "AccountSubjectId{" +
|
||||
"uri='" + uri + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.keycloak.ssf.subject;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* See: https://datatracker.ietf.org/doc/html/rfc9493#name-aliases-identifier-format
|
||||
*/
|
||||
public class AliasesSubjectId extends SubjectId {
|
||||
|
||||
public static final String TYPE = "aliases";
|
||||
|
||||
@JsonProperty("identifiers")
|
||||
protected List<Map<String, String>> identifiers;
|
||||
|
||||
public AliasesSubjectId() {
|
||||
super(TYPE);
|
||||
}
|
||||
|
||||
public List<Map<String, String>> getIdentifiers() {
|
||||
return identifiers;
|
||||
}
|
||||
|
||||
public void setIdentifiers(List<Map<String, String>> identifiers) {
|
||||
this.identifiers = identifiers;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "AliasesSubjectId{" +
|
||||
"identifiers=" + identifiers +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package org.keycloak.ssf.subject;
|
||||
|
||||
import org.keycloak.ssf.event.caep.CaepSessionRevoked;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
|
||||
/**
|
||||
* See: https://openid.net/specs/openid-sse-framework-1_0.html#complex-subjects
|
||||
*
|
||||
* <p>Every nested field is typed as the abstract {@link SubjectId} and
|
||||
* therefore annotated with {@link JsonDeserialize} using
|
||||
* {@link SubjectIdJsonDeserializer}, which dispatches on the {@code format}
|
||||
* discriminator. Without this, Jackson's default bean deserialization tries
|
||||
* to instantiate the abstract class and fails — a path hit as soon as a
|
||||
* receiver parses a real transmitter-emitted SET that carries a complex
|
||||
* subject (e.g. {@code ComplexSubjectId{user: IssuerSubjectId, session:
|
||||
* OpaqueSubjectId}} on a {@link CaepSessionRevoked}
|
||||
* event).
|
||||
*/
|
||||
public class ComplexSubjectId extends SubjectId {
|
||||
|
||||
public static final String TYPE = "complex";
|
||||
|
||||
/**
|
||||
* The user involved with the event
|
||||
*/
|
||||
@JsonProperty("user")
|
||||
@JsonDeserialize(using = SubjectIdJsonDeserializer.class)
|
||||
protected SubjectId user;
|
||||
|
||||
/**
|
||||
* The device involved with the event
|
||||
*/
|
||||
@JsonProperty("device")
|
||||
@JsonDeserialize(using = SubjectIdJsonDeserializer.class)
|
||||
protected SubjectId device;
|
||||
|
||||
/**
|
||||
* The session involved with the event
|
||||
*/
|
||||
@JsonProperty("session")
|
||||
@JsonDeserialize(using = SubjectIdJsonDeserializer.class)
|
||||
protected SubjectId session;
|
||||
|
||||
/**
|
||||
* The application involved with the event
|
||||
*/
|
||||
@JsonProperty("application")
|
||||
@JsonDeserialize(using = SubjectIdJsonDeserializer.class)
|
||||
protected SubjectId application;
|
||||
|
||||
/**
|
||||
* The tenant involved with the event
|
||||
*/
|
||||
@JsonProperty("tenant")
|
||||
@JsonDeserialize(using = SubjectIdJsonDeserializer.class)
|
||||
protected SubjectId tenant;
|
||||
|
||||
/**
|
||||
* The org_unit involved with the event
|
||||
*/
|
||||
@JsonProperty("org_unit")
|
||||
@JsonDeserialize(using = SubjectIdJsonDeserializer.class)
|
||||
protected SubjectId orgUnit;
|
||||
|
||||
/**
|
||||
* The group involved with the event
|
||||
*/
|
||||
@JsonProperty("group")
|
||||
@JsonDeserialize(using = SubjectIdJsonDeserializer.class)
|
||||
protected SubjectId group;
|
||||
|
||||
public ComplexSubjectId() {
|
||||
super(TYPE);
|
||||
}
|
||||
|
||||
public SubjectId getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public void setUser(SubjectId user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public SubjectId getDevice() {
|
||||
return device;
|
||||
}
|
||||
|
||||
public void setDevice(SubjectId device) {
|
||||
this.device = device;
|
||||
}
|
||||
|
||||
public SubjectId getSession() {
|
||||
return session;
|
||||
}
|
||||
|
||||
public void setSession(SubjectId session) {
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
public SubjectId getApplication() {
|
||||
return application;
|
||||
}
|
||||
|
||||
public void setApplication(SubjectId application) {
|
||||
this.application = application;
|
||||
}
|
||||
|
||||
public SubjectId getTenant() {
|
||||
return tenant;
|
||||
}
|
||||
|
||||
public void setTenant(SubjectId tenant) {
|
||||
this.tenant = tenant;
|
||||
}
|
||||
|
||||
public SubjectId getOrgUnit() {
|
||||
return orgUnit;
|
||||
}
|
||||
|
||||
public void setOrgUnit(SubjectId orgUnit) {
|
||||
this.orgUnit = orgUnit;
|
||||
}
|
||||
|
||||
public SubjectId getGroup() {
|
||||
return group;
|
||||
}
|
||||
|
||||
public void setGroup(SubjectId group) {
|
||||
this.group = group;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ComplexSubjectId{" +
|
||||
"user=" + user +
|
||||
", device=" + device +
|
||||
", session=" + session +
|
||||
", application=" + application +
|
||||
", tenant=" + tenant +
|
||||
", orgUnit=" + orgUnit +
|
||||
", group=" + group +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.keycloak.ssf.subject;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* See: https://datatracker.ietf.org/doc/html/rfc9493#name-decentralized-identifier-di
|
||||
*/
|
||||
public class DidSubjectId extends SubjectId {
|
||||
|
||||
public static final String DID = "did";
|
||||
|
||||
@JsonProperty("url")
|
||||
protected String url;
|
||||
|
||||
public DidSubjectId() {
|
||||
super(DID);
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public void setUrl(String url) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "DidSubjectId{" +
|
||||
"url='" + url + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.keycloak.ssf.subject;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* See: https://datatracker.ietf.org/doc/html/rfc9493#name-email-identifier-format
|
||||
*/
|
||||
public class EmailSubjectId extends SubjectId {
|
||||
|
||||
public static final String TYPE = "email";
|
||||
|
||||
@JsonProperty("email")
|
||||
protected String email;
|
||||
|
||||
public EmailSubjectId() {
|
||||
super(TYPE);
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "EmailSubjectId{" +
|
||||
"email='" + email + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.keycloak.ssf.subject;
|
||||
|
||||
public class GenericSubjectId extends SubjectId {
|
||||
|
||||
public GenericSubjectId() {
|
||||
super(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "GenericSubjectId{" +
|
||||
"format='" + format + '\'' +
|
||||
", attributes=" + attributes +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.keycloak.ssf.subject;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* See: https://datatracker.ietf.org/doc/html/rfc9493#name-issuer-and-subject-identifi
|
||||
*/
|
||||
public class IssuerSubjectId extends SubjectId {
|
||||
|
||||
public static final String TYPE = "iss_sub";
|
||||
|
||||
@JsonProperty("iss")
|
||||
protected String iss;
|
||||
|
||||
@JsonProperty("sub")
|
||||
protected String sub;
|
||||
|
||||
public IssuerSubjectId() {
|
||||
super(TYPE);
|
||||
}
|
||||
|
||||
public String getIss() {
|
||||
return iss;
|
||||
}
|
||||
|
||||
public void setIss(String iss) {
|
||||
this.iss = iss;
|
||||
}
|
||||
|
||||
public String getSub() {
|
||||
return sub;
|
||||
}
|
||||
|
||||
public void setSub(String sub) {
|
||||
this.sub = sub;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "IssuerSubjectId{" +
|
||||
"iss='" + iss + '\'' +
|
||||
", sub='" + sub + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.keycloak.ssf.subject;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* See: https://openid.net/specs/openid-sse-framework-1_0.html#sub-id-jwt-id
|
||||
*/
|
||||
public class JwtSubjectId extends SubjectId {
|
||||
|
||||
public static final String TYPE = "jwt_id";
|
||||
|
||||
@JsonProperty("iss")
|
||||
protected String iss;
|
||||
|
||||
@JsonProperty("jti")
|
||||
protected String jti;
|
||||
|
||||
public JwtSubjectId() {
|
||||
super(TYPE);
|
||||
}
|
||||
|
||||
public String getIss() {
|
||||
return iss;
|
||||
}
|
||||
|
||||
public void setIss(String iss) {
|
||||
this.iss = iss;
|
||||
}
|
||||
|
||||
public String getJti() {
|
||||
return jti;
|
||||
}
|
||||
|
||||
public void setJti(String jti) {
|
||||
this.jti = jti;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "JwtSubjectId{" +
|
||||
"iss='" + iss + '\'' +
|
||||
", jti='" + jti + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.keycloak.ssf.subject;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* See: https://datatracker.ietf.org/doc/html/rfc9493#name-opaque-identifier-format
|
||||
*/
|
||||
public class OpaqueSubjectId extends SubjectId {
|
||||
|
||||
public static final String TYPE = "opaque";
|
||||
|
||||
@JsonProperty("id")
|
||||
protected String id;
|
||||
|
||||
public OpaqueSubjectId() {
|
||||
super(TYPE);
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "OpaqueSubjectId{" +
|
||||
"id='" + id + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.keycloak.ssf.subject;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* See: https://datatracker.ietf.org/doc/html/rfc9493#name-phone-number-identifier-for
|
||||
*/
|
||||
public class PhoneNumberSubjectId extends SubjectId {
|
||||
|
||||
public static final String TYPE = "phone_number";
|
||||
|
||||
@JsonProperty("phone_number")
|
||||
protected String phoneNumber;
|
||||
|
||||
public PhoneNumberSubjectId() {
|
||||
super(TYPE);
|
||||
}
|
||||
|
||||
public String getPhoneNumber() {
|
||||
return phoneNumber;
|
||||
}
|
||||
|
||||
public void setPhoneNumber(String phoneNumber) {
|
||||
this.phoneNumber = phoneNumber;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "PhoneNumberSubjectId{" +
|
||||
"phoneNumber='" + phoneNumber + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.keycloak.ssf.subject;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* See: https://openid.net/specs/openid-sse-framework-1_0.html#sub-id-saml-assertion-id
|
||||
*/
|
||||
public class SamlAssertionSubjectId extends SubjectId {
|
||||
|
||||
public static final String TYPE = "saml_assertion_id";
|
||||
|
||||
@JsonProperty("issuer")
|
||||
protected String issuer;
|
||||
|
||||
@JsonProperty("assertion_id")
|
||||
protected String assertionId;
|
||||
|
||||
public SamlAssertionSubjectId() {
|
||||
super(TYPE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "SamlAssertionSubjectId{" +
|
||||
"issuer='" + issuer + '\'' +
|
||||
", assertionId='" + assertionId + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.keycloak.ssf.subject;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAnySetter;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* A Subject Identifier is structured information that describes a subject related to a security event, using named
|
||||
* formats to define its encoding as JSON objects within Security Event Tokens.
|
||||
*
|
||||
* <p>This class is deliberately NOT annotated with a class-level
|
||||
* {@code @JsonDeserialize}: doing so would propagate to every concrete
|
||||
* subclass via Jackson's annotation inheritance and {@link SubjectIdJsonDeserializer}
|
||||
* itself dispatches to a concrete subclass through {@code treeToValue},
|
||||
* which would loop. Call sites that need to deserialize the abstract
|
||||
* {@code SubjectId} type either use a field-level
|
||||
* {@code @JsonDeserialize(using = SubjectIdJsonDeserializer.class)}
|
||||
* (e.g. {@code SsfEmitEventRequest.sub_id}) or invoke the deserializer
|
||||
* via {@code SubjectIds.fromTree(...)}.
|
||||
*
|
||||
* See: https://datatracker.ietf.org/doc/html/rfc9493
|
||||
*/
|
||||
public abstract class SubjectId {
|
||||
|
||||
@JsonProperty("format")
|
||||
protected String format;
|
||||
|
||||
@JsonIgnore
|
||||
protected Map<String, Object> attributes = new HashMap<>();
|
||||
|
||||
public SubjectId(String format) {
|
||||
this.format = format;
|
||||
}
|
||||
|
||||
public Map<String, Object> getAttributes() {
|
||||
return attributes;
|
||||
}
|
||||
|
||||
public void setAttributes(Map<String, Object> attributes) {
|
||||
this.attributes = attributes;
|
||||
}
|
||||
|
||||
@JsonAnySetter
|
||||
public void setAttribute(String key, Object value) {
|
||||
attributes.put(key, value);
|
||||
}
|
||||
|
||||
public String getFormat() {
|
||||
return format;
|
||||
}
|
||||
|
||||
public void setFormat(String format) {
|
||||
this.format = format;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.keycloak.ssf.subject;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Custom dezerializer to deal with legacy SubjectIds.
|
||||
*/
|
||||
public class SubjectIdJsonDeserializer extends JsonDeserializer<SubjectId> {
|
||||
|
||||
@Override
|
||||
public SubjectId deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
|
||||
ObjectMapper mapper = (ObjectMapper) p.getCodec();
|
||||
JsonNode node = mapper.readTree(p);
|
||||
|
||||
// Extract the format field
|
||||
JsonNode formatNode = node.get("format");
|
||||
boolean legacyRiscEventType = false;
|
||||
if (formatNode == null) {
|
||||
// legacy subject type format for older OpenID RISC Event types, see: https://openid.net/specs/openid-risc-event-types-1_0.html
|
||||
formatNode = node.get("subject_type");
|
||||
if (formatNode != null && formatNode.isTextual()) {
|
||||
legacyRiscEventType = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (formatNode == null || !formatNode.isTextual()) {
|
||||
throw new IOException("Missing or invalid 'format' field in SubjectId");
|
||||
}
|
||||
|
||||
String format = formatNode.asText();
|
||||
if (legacyRiscEventType) {
|
||||
// legacy subject type format for older OpenID RISC Event types, see: https://openid.net/specs/openid-risc-event-types-1_0.html
|
||||
format = format.replace("-","_");
|
||||
}
|
||||
Class<? extends SubjectId> subjectClass = SubjectIds.getSubjectIdType(format);
|
||||
|
||||
if (subjectClass == null) {
|
||||
throw new SubjectParsingException("Unknown SubjectId format: " + format);
|
||||
}
|
||||
|
||||
SubjectId subjectId = mapper.treeToValue(node, subjectClass);
|
||||
subjectId.setFormat(format);
|
||||
|
||||
return subjectId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package org.keycloak.ssf.subject;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Registry of SubjectId formats defined in RFC9493 Subject Identifiers.
|
||||
* <p>
|
||||
* See: https://datatracker.ietf.org/doc/html/rfc9493
|
||||
*/
|
||||
public class SubjectIds {
|
||||
|
||||
/**
|
||||
* Holds all known standard SUBJECT_ID_FORMATS
|
||||
*/
|
||||
public final static Map<String, Class<? extends SubjectId>> SUBJECT_ID_FORMAT_TYPES;
|
||||
|
||||
static {
|
||||
var map = new HashMap<String, Class<? extends SubjectId>>();
|
||||
List.of(//
|
||||
new AccountSubjectId(), //
|
||||
new AliasesSubjectId(), //
|
||||
new ComplexSubjectId(), //
|
||||
new DidSubjectId(), //
|
||||
new EmailSubjectId(), //
|
||||
new IssuerSubjectId(), //
|
||||
new JwtSubjectId(), //
|
||||
new OpaqueSubjectId(), //
|
||||
new PhoneNumberSubjectId(), //
|
||||
new SamlAssertionSubjectId(), //
|
||||
new UriSubjectId() //
|
||||
).forEach(subjectId -> map.put(subjectId.getFormat(), subjectId.getClass()));
|
||||
SUBJECT_ID_FORMAT_TYPES = map;
|
||||
}
|
||||
|
||||
public static Class<? extends SubjectId> getSubjectIdType(String format) {
|
||||
var subjectIdType = SUBJECT_ID_FORMAT_TYPES.get(format);
|
||||
if (subjectIdType != null) {
|
||||
return subjectIdType;
|
||||
}
|
||||
return GenericSubjectId.class;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.keycloak.ssf.subject;
|
||||
|
||||
import org.keycloak.ssf.SsfException;
|
||||
|
||||
public class SubjectParsingException extends SsfException {
|
||||
|
||||
public SubjectParsingException() {
|
||||
}
|
||||
|
||||
public SubjectParsingException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public SubjectParsingException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.keycloak.ssf.subject;
|
||||
|
||||
import org.keycloak.models.OrganizationModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
|
||||
/**
|
||||
* Result of a {@link SubjectResolver#resolve} call. Unsealed so
|
||||
* third-party code can add custom variants (e.g. a
|
||||
* {@code Group(GroupModel)} record) and handle them via overridden
|
||||
* {@code resolveSubject} / {@code applySubjectResolution} methods
|
||||
* in the transmitter's extension points.
|
||||
*/
|
||||
public interface SubjectResolution {
|
||||
|
||||
SubjectResolution NOT_FOUND = new NotFound();
|
||||
|
||||
SubjectResolution UNSUPPORTED_FORMAT = new UnsupportedFormat();
|
||||
|
||||
record User(UserModel user) implements SubjectResolution {}
|
||||
|
||||
record Organization(OrganizationModel organization) implements SubjectResolution {}
|
||||
|
||||
record NotFound() implements SubjectResolution {}
|
||||
|
||||
record UnsupportedFormat() implements SubjectResolution {}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package org.keycloak.ssf.subject;
|
||||
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.organization.OrganizationProvider;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
/**
|
||||
* Resolves a {@link SubjectId} to a concrete Keycloak entity. Widens
|
||||
* {@link SubjectUserLookup}'s scope to also handle organization
|
||||
* subjects via {@link ComplexSubjectId#getTenant()}.
|
||||
*
|
||||
* <p>Supported subject shapes:
|
||||
* <ul>
|
||||
* <li>User-identifying ({@code email}, {@code iss_sub}, user-opaque)
|
||||
* → resolves to a {@link UserModel}.</li>
|
||||
* <li>Complex subject with a {@code tenant} component whose inner
|
||||
* subject carries an opaque id → resolves to an
|
||||
* {@code OrganizationModel} (only when
|
||||
* {@link Profile.Feature#ORGANIZATION} is enabled).</li>
|
||||
* </ul>
|
||||
* Everything else yields {@link SubjectResolution.UnsupportedFormat}.
|
||||
*/
|
||||
public class SubjectResolver {
|
||||
|
||||
private static final Logger log = Logger.getLogger(SubjectResolver.class);
|
||||
|
||||
/**
|
||||
* Attempts to resolve the given subject to a Keycloak entity.
|
||||
*/
|
||||
public static SubjectResolution resolve(KeycloakSession session, RealmModel realm, SubjectId subjectId) {
|
||||
|
||||
if (subjectId instanceof ComplexSubjectId complex) {
|
||||
return resolveComplex(session, realm, complex);
|
||||
}
|
||||
|
||||
UserModel user = SubjectUserLookup.lookupUser(session, realm, subjectId);
|
||||
if (user != null) {
|
||||
return new SubjectResolution.User(user);
|
||||
}
|
||||
|
||||
if (subjectId instanceof EmailSubjectId
|
||||
|| subjectId instanceof IssuerSubjectId
|
||||
|| subjectId instanceof OpaqueSubjectId) {
|
||||
return SubjectResolution.NOT_FOUND;
|
||||
}
|
||||
|
||||
return SubjectResolution.UNSUPPORTED_FORMAT;
|
||||
}
|
||||
|
||||
private static SubjectResolution resolveComplex(KeycloakSession session, RealmModel realm, ComplexSubjectId complex) {
|
||||
|
||||
// User component — try first, most common case.
|
||||
if (complex.getUser() != null) {
|
||||
UserModel user = SubjectUserLookup.lookupUser(session, realm, complex.getUser());
|
||||
if (user != null) {
|
||||
return new SubjectResolution.User(user);
|
||||
}
|
||||
return SubjectResolution.NOT_FOUND;
|
||||
}
|
||||
|
||||
// Tenant component → Organization.
|
||||
if (complex.getTenant() != null) {
|
||||
return resolveOrganization(session, complex.getTenant());
|
||||
}
|
||||
|
||||
return SubjectResolution.UNSUPPORTED_FORMAT;
|
||||
}
|
||||
|
||||
private static SubjectResolution resolveOrganization(KeycloakSession session, SubjectId tenantSubject) {
|
||||
if (!Profile.isFeatureEnabled(Profile.Feature.ORGANIZATION)) {
|
||||
log.debugf("Organization feature is disabled — cannot resolve tenant subject");
|
||||
return SubjectResolution.UNSUPPORTED_FORMAT;
|
||||
}
|
||||
|
||||
OrganizationProvider orgProvider = session.getProvider(OrganizationProvider.class);
|
||||
if (orgProvider == null) {
|
||||
return SubjectResolution.UNSUPPORTED_FORMAT;
|
||||
}
|
||||
|
||||
// opaque id → getById
|
||||
if (tenantSubject instanceof OpaqueSubjectId opaque) {
|
||||
return resolveOrgById(orgProvider, opaque.getId());
|
||||
}
|
||||
|
||||
// iss_sub → sub as org id
|
||||
if (tenantSubject instanceof IssuerSubjectId issSub) {
|
||||
return resolveOrgById(orgProvider, issSub.getSub());
|
||||
}
|
||||
|
||||
// email → treat as internet domain (e.g. "acme.com") → getByDomainName,
|
||||
// then fall back to alias
|
||||
if (tenantSubject instanceof EmailSubjectId email) {
|
||||
String domainOrAlias = email.getEmail();
|
||||
var org = orgProvider.getByDomainName(domainOrAlias);
|
||||
if (org != null) {
|
||||
return new SubjectResolution.Organization(org);
|
||||
}
|
||||
org = orgProvider.getByAlias(domainOrAlias);
|
||||
if (org != null) {
|
||||
return new SubjectResolution.Organization(org);
|
||||
}
|
||||
return SubjectResolution.NOT_FOUND;
|
||||
}
|
||||
|
||||
// uri → extract the path/fragment as alias or domain
|
||||
// e.g. "https://keycloak.example.com/orgs/acme" → "acme"
|
||||
// or "urn:keycloak:org:acme" → "acme"
|
||||
if (tenantSubject instanceof UriSubjectId uriSubject) {
|
||||
String alias = extractOrgAliasFromUri(uriSubject.getUri());
|
||||
if (alias != null) {
|
||||
return resolveOrgByAliasOrDomain(orgProvider, alias);
|
||||
}
|
||||
return SubjectResolution.NOT_FOUND;
|
||||
}
|
||||
|
||||
return SubjectResolution.UNSUPPORTED_FORMAT;
|
||||
}
|
||||
|
||||
private static SubjectResolution resolveOrgById(OrganizationProvider orgProvider, String orgId) {
|
||||
if (orgId == null) {
|
||||
return SubjectResolution.UNSUPPORTED_FORMAT;
|
||||
}
|
||||
var org = orgProvider.getById(orgId);
|
||||
if (org != null) {
|
||||
return new SubjectResolution.Organization(org);
|
||||
}
|
||||
// id didn't match — try as alias
|
||||
org = orgProvider.getByAlias(orgId);
|
||||
if (org != null) {
|
||||
return new SubjectResolution.Organization(org);
|
||||
}
|
||||
return SubjectResolution.NOT_FOUND;
|
||||
}
|
||||
|
||||
private static SubjectResolution resolveOrgByAliasOrDomain(OrganizationProvider orgProvider, String value) {
|
||||
var org = orgProvider.getByAlias(value);
|
||||
if (org != null) {
|
||||
return new SubjectResolution.Organization(org);
|
||||
}
|
||||
org = orgProvider.getByDomainName(value);
|
||||
if (org != null) {
|
||||
return new SubjectResolution.Organization(org);
|
||||
}
|
||||
return SubjectResolution.NOT_FOUND;
|
||||
}
|
||||
|
||||
private static String extractOrgAliasFromUri(String uri) {
|
||||
if (uri == null || uri.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate that the string is a syntactically valid URI before
|
||||
// attempting to extract an alias from it.
|
||||
java.net.URI parsed;
|
||||
try {
|
||||
parsed = java.net.URI.create(uri);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.debugf("Tenant URI subject is not a valid URI: %s", uri);
|
||||
return null;
|
||||
}
|
||||
|
||||
// urn:keycloak:org:<alias>
|
||||
if ("urn".equals(parsed.getScheme()) && uri.startsWith("urn:keycloak:org:")) {
|
||||
String alias = uri.substring("urn:keycloak:org:".length());
|
||||
return alias.isBlank() ? null : alias;
|
||||
}
|
||||
|
||||
// https://example.com/orgs/acme → last path segment "acme"
|
||||
String path = parsed.getPath();
|
||||
if (path != null && !path.isBlank()) {
|
||||
int lastSlash = path.lastIndexOf('/');
|
||||
if (lastSlash >= 0 && lastSlash < path.length() - 1) {
|
||||
return path.substring(lastSlash + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package org.keycloak.ssf.subject;
|
||||
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
|
||||
import org.keycloak.models.FederatedIdentityModel;
|
||||
import org.keycloak.models.IdentityProviderModel;
|
||||
import org.keycloak.models.IdentityProviderQuery;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.services.Urls;
|
||||
import org.keycloak.urls.UrlType;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
public class SubjectUserLookup {
|
||||
|
||||
protected static final Logger log = Logger.getLogger(SubjectUserLookup.class);
|
||||
|
||||
public static UserModel lookupUser(KeycloakSession session, RealmModel realm, SubjectId subjectId) {
|
||||
|
||||
if (subjectId instanceof EmailSubjectId) {
|
||||
return getUserByEmail(session, realm, ((EmailSubjectId) subjectId).getEmail());
|
||||
}
|
||||
|
||||
if (subjectId instanceof OpaqueSubjectId) {
|
||||
return getUserById(session, realm, ((OpaqueSubjectId) subjectId).getId());
|
||||
}
|
||||
|
||||
if (subjectId instanceof IssuerSubjectId) {
|
||||
var issuerSubjectId = (IssuerSubjectId) subjectId;
|
||||
return getUserByIssuerSub(session, realm, issuerSubjectId.getIss(), issuerSubjectId.getSub());
|
||||
}
|
||||
|
||||
log.warnf("Lookup failed for unknown subject id type. subjectId=%s", subjectId);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static UserModel getUserByIssuerSub(KeycloakSession session, RealmModel realm, String iss, String sub) {
|
||||
|
||||
// iss = current realm issuer
|
||||
UriInfo frontendUriInfo = session.getContext().getUri(UrlType.FRONTEND);
|
||||
String realmIssuer = Urls.realmIssuer(frontendUriInfo.getBaseUri(), session.getContext().getRealm().getName());
|
||||
if (realmIssuer.equals(iss)) {
|
||||
// Find realm user
|
||||
return getUserById(session, realm, sub);
|
||||
}
|
||||
|
||||
if (session.identityProviders().count() == 0) {
|
||||
log.warnf("No identity providers configured for realm. realm=%s", realm.getName());
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find identity provider whose issuer matches the iss claim
|
||||
IdentityProviderModel idp = session.identityProviders().getAllStream(IdentityProviderQuery.userAuthentication())
|
||||
.filter(i -> iss.equals(i.getConfig().get(IdentityProviderModel.ISSUER)))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
if (idp == null) {
|
||||
log.warnf("No identity provider found for issuer. iss=%s", iss);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Lookup user by federated identity link: the sub claim is the user ID at the external IdP
|
||||
FederatedIdentityModel federatedIdentity = new FederatedIdentityModel(idp.getAlias(), sub, null);
|
||||
UserModel user = session.users().getUserByFederatedIdentity(realm, federatedIdentity);
|
||||
if (user == null) {
|
||||
log.debugf("No user found for federated identity. idpAlias=%s sub=%s", idp.getAlias(), sub);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private static UserModel getUserById(KeycloakSession session, RealmModel realm, String userId) {
|
||||
return session.users().getUserById(realm, userId);
|
||||
}
|
||||
|
||||
private static UserModel getUserByEmail(KeycloakSession session, RealmModel realm, String email) {
|
||||
return session.users().getUserByEmail(realm, email);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.keycloak.ssf.subject;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* See: https://datatracker.ietf.org/doc/html/rfc9493#section-3.2.7
|
||||
*/
|
||||
public class UriSubjectId extends SubjectId {
|
||||
|
||||
public static final String TYPE = "uri";
|
||||
|
||||
@JsonProperty("uri")
|
||||
protected String uri;
|
||||
|
||||
public UriSubjectId() {
|
||||
super(TYPE);
|
||||
}
|
||||
|
||||
public String getUri() {
|
||||
return uri;
|
||||
}
|
||||
|
||||
public void setUri(String uri) {
|
||||
this.uri = uri;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "UriSubjectId{" +
|
||||
"uri='" + uri + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
org.keycloak.ssf.event.SsfEventSpi
|
||||
+1
@@ -0,0 +1 @@
|
||||
org.keycloak.ssf.event.DefaultSsfEventProviderFactory
|
||||
@@ -0,0 +1,98 @@
|
||||
package org.keycloak.ssf.event;
|
||||
|
||||
import org.keycloak.ssf.event.caep.CaepCredentialChange;
|
||||
import org.keycloak.ssf.event.caep.CaepSessionRevoked;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/**
|
||||
* Unit tests for the {@link SsfEvent#validate()} hook and the
|
||||
* required-field overrides on the bundled CAEP events.
|
||||
*
|
||||
* <p>Covers:
|
||||
* <ul>
|
||||
* <li>The default no-op behaviour (events without spec-required
|
||||
* fields, e.g. {@code CaepSessionRevoked}, never throw).</li>
|
||||
* <li>{@link CaepCredentialChange} rejects missing
|
||||
* {@code credential_type} and {@code change_type}.</li>
|
||||
* <li>The exception carries the stable
|
||||
* {@link SsfEventValidationException#MESSAGE_KEY}, the event
|
||||
* alias, and the wire-name of the offending field so callers
|
||||
* (REST emit response, admin UI) can compose a localised
|
||||
* message from the structured pieces.</li>
|
||||
* </ul>
|
||||
*/
|
||||
class SsfEventValidationTest {
|
||||
|
||||
@Test
|
||||
void defaultValidateIsNoOp_caepSessionRevokedNeverThrows() {
|
||||
CaepSessionRevoked event = new CaepSessionRevoked();
|
||||
// SessionRevoked has no required fields; the subject identifies
|
||||
// the session, so validate() must not reject the empty body.
|
||||
assertDoesNotThrow(event::validate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void caepCredentialChange_missingCredentialType_throws() {
|
||||
CaepCredentialChange event = new CaepCredentialChange();
|
||||
// Only change_type set; credential_type is REQUIRED per CAEP §3.3.1.
|
||||
event.setChangeType(CaepCredentialChange.ChangeType.UPDATE);
|
||||
|
||||
SsfEventValidationException ex = assertThrows(SsfEventValidationException.class, event::validate);
|
||||
assertEquals(SsfEventValidationException.MESSAGE_KEY, ex.getMessageKey(),
|
||||
"exception should carry the stable i18n key");
|
||||
assertEquals("CaepCredentialChange", ex.getEventAlias(),
|
||||
"alias should match SsfEvent.getAlias() so callers can localise per-event");
|
||||
assertEquals("credential_type", ex.getField(),
|
||||
"field should be the wire (@JsonProperty) name, not the Java field name, "
|
||||
+ "so the operator sees the same identifier they used in the JSON body");
|
||||
}
|
||||
|
||||
@Test
|
||||
void caepCredentialChange_missingChangeType_throws() {
|
||||
CaepCredentialChange event = new CaepCredentialChange();
|
||||
event.setCredentialType("password");
|
||||
// changeType deliberately not set
|
||||
|
||||
SsfEventValidationException ex = assertThrows(SsfEventValidationException.class, event::validate);
|
||||
assertEquals("change_type", ex.getField());
|
||||
}
|
||||
|
||||
@Test
|
||||
void caepCredentialChange_blankCredentialType_throws() {
|
||||
// Defensive: an empty string would slip past a plain null-check
|
||||
// but a CAEP receiver expects a meaningful identifier.
|
||||
CaepCredentialChange event = new CaepCredentialChange();
|
||||
event.setCredentialType(" ");
|
||||
event.setChangeType(CaepCredentialChange.ChangeType.UPDATE);
|
||||
|
||||
SsfEventValidationException ex = assertThrows(SsfEventValidationException.class, event::validate);
|
||||
assertEquals("credential_type", ex.getField());
|
||||
}
|
||||
|
||||
@Test
|
||||
void caepCredentialChange_bothFieldsPresent_doesNotThrow() {
|
||||
CaepCredentialChange event = new CaepCredentialChange();
|
||||
event.setCredentialType("password");
|
||||
event.setChangeType(CaepCredentialChange.ChangeType.UPDATE);
|
||||
|
||||
assertDoesNotThrow(event::validate);
|
||||
}
|
||||
|
||||
// ---- exception structure --------------------------------------------
|
||||
|
||||
@Test
|
||||
void exceptionMessageMatchesStableComposition() {
|
||||
// getMessage() must remain a mechanical "<key>: <alias>.<field>"
|
||||
// string — log lines and non-localised callers depend on it
|
||||
// staying stable. The wire status carries the same key, so
|
||||
// grepping for "invalid_event_data" turns up both the exception
|
||||
// log and the REST response.
|
||||
SsfEventValidationException ex = new SsfEventValidationException("CaepCredentialChange", "change_type");
|
||||
assertEquals("invalid_event_data: CaepCredentialChange.change_type", ex.getMessage());
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||
<parent>
|
||||
<artifactId>keycloak-parent</artifactId>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<version>999.0.0-SNAPSHOT</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<artifactId>keycloak-ssf-parent</artifactId>
|
||||
<packaging>pom</packaging>
|
||||
<name>Keycloak SSF Parent</name>
|
||||
<description>
|
||||
The parent module for SSF (Shared Signals Framework) support in Keycloak.
|
||||
</description>
|
||||
|
||||
<modules>
|
||||
<module>core</module>
|
||||
<module>transmitter</module>
|
||||
<module>services</module>
|
||||
<module>tests</module>
|
||||
</modules>
|
||||
</project>
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||
<parent>
|
||||
<artifactId>keycloak-parent</artifactId>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<version>999.0.0-SNAPSHOT</version>
|
||||
<relativePath>../../pom.xml</relativePath>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<artifactId>keycloak-ssf-services</artifactId>
|
||||
<name>Keycloak SSF Services</name>
|
||||
<description>
|
||||
SSF integration module: realm resource provider that dispatches to the SSF transmitter and receiver sub-resources.
|
||||
</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-ssf-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-ssf-transmitter</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-server-spi</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-services</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>jakarta.ws.rs</groupId>
|
||||
<artifactId>jakarta.ws.rs-api</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user