diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac45b187eef..fd674226c33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java index c4e4cce9a03..e1601bafed7 100755 --- a/common/src/main/java/org/keycloak/common/Profile.java +++ b/common/src/main/java/org/keycloak/common/Profile.java @@ -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), diff --git a/common/src/main/java/org/keycloak/common/constants/KeycloakOpenAPI.java b/common/src/main/java/org/keycloak/common/constants/KeycloakOpenAPI.java index 034bfb745ab..2c725117cf2 100644 --- a/common/src/main/java/org/keycloak/common/constants/KeycloakOpenAPI.java +++ b/common/src/main/java/org/keycloak/common/constants/KeycloakOpenAPI.java @@ -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() { } + } + } } diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 7a868b72b4d..c13c8a56a00 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -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. diff --git a/js/apps/admin-ui/src/clients/ClientDetails.tsx b/js/apps/admin-ui/src/clients/ClientDetails.tsx index 926ffb1cdb9..648cd93bb62 100644 --- a/js/apps/admin-ui/src/clients/ClientDetails.tsx +++ b/js/apps/admin-ui/src/clients/ClientDetails.tsx @@ -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("attributes.ssf.enabled"), + }); + + const ssfAllowEmitEvents = useWatch({ + control: form.control, + name: convertAttributeNameToForm( + "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(); 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() { > + {client.protocol === "openid-connect" && + !client.publicClient && + showSsfTab && ( + {t("ssf")}} + {...ssfTab} + > + + {t("ssfTabReceiver")}} + {...ssfReceiverTab} + > + + + {t("ssfTabStream")}} + {...ssfStreamTab} + > + + + {t("ssfTabSubjects")}} + {...ssfSubjectsTab} + > + + + {t("ssfTabEventSearch")} + } + {...ssfEventSearchTab} + > + + + {showSsfEmitEventsTab && ( + {t("ssfTabEmitEvents")} + } + {...ssfEmitEventsTab} + > + + + )} + + + )} {hasAccess("view-events") && ( ( + "attributes.ssf.enabled", + ), + false, + ); } }} aria-label={t("clientAuthentication")} @@ -498,6 +513,16 @@ export const CapabilityConfig = ({ )} )} + {!clientAuthentication && showSsfReceiverToggle && ( + ( + "attributes.ssf.enabled", + )} + label={t("ssfReceiverEnabled")} + labelIcon={t("ssfReceiverEnabledHelp")} + stringify + /> + )} )} {protocol === "saml" && ( diff --git a/js/apps/admin-ui/src/clients/routes.ts b/js/apps/admin-ui/src/clients/routes.ts index efa7d553841..ac0ed819ea2 100644 --- a/js/apps/admin-ui/src/clients/routes.ts +++ b/js/apps/admin-ui/src/clients/routes.ts @@ -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, diff --git a/js/apps/admin-ui/src/clients/routes/Client.tsx b/js/apps/admin-ui/src/clients/routes/Client.tsx index 7bb24f64c7f..5b82b0bea59 100644 --- a/js/apps/admin-ui/src/clients/routes/Client.tsx +++ b/js/apps/admin-ui/src/clients/routes/Client.tsx @@ -15,7 +15,8 @@ export type ClientTab = | "serviceAccount" | "permissions" | "sessions" - | "events"; + | "events" + | "ssf"; export type ClientParams = { realm: string; diff --git a/js/apps/admin-ui/src/clients/routes/ClientSsfTab.tsx b/js/apps/admin-ui/src/clients/routes/ClientSsfTab.tsx new file mode 100644 index 00000000000..c728dd516a0 --- /dev/null +++ b/js/apps/admin-ui/src/clients/routes/ClientSsfTab.tsx @@ -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: , + handle: { + access: "view-clients", + breadcrumb: (t) => t("clientSettings"), + }, +}; + +export const toSsfClientTab = (params: ClientSsfTabParams): Partial => ({ + pathname: generateEncodedPath(ClientSsfTabRoute.path, params), +}); diff --git a/js/apps/admin-ui/src/clients/ssf/SsfTab.tsx b/js/apps/admin-ui/src/clients/ssf/SsfTab.tsx new file mode 100644 index 00000000000..bb3f494ffe8 --- /dev/null +++ b/js/apps/admin-ui/src/clients/ssf/SsfTab.tsx @@ -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(); + + const [defaultSupportedEvents, setDefaultSupportedEvents] = useState( + FALLBACK_DEFAULT_SUPPORTED_EVENTS, + ); + const [availableSupportedEvents, setAvailableSupportedEvents] = useState< + string[] + >([]); + const [nativelyEmittedEvents, setNativelyEmittedEvents] = useState( + [], + ); + const [defaultPushConnectTimeoutMillis, setDefaultPushConnectTimeoutMillis] = + useState(FALLBACK_DEFAULT_PUSH_CONNECT_TIMEOUT_MILLIS); + const [defaultPushSocketTimeoutMillis, setDefaultPushSocketTimeoutMillis] = + useState(FALLBACK_DEFAULT_PUSH_SOCKET_TIMEOUT_MILLIS); + const [defaultUserSubjectFormat, setDefaultUserSubjectFormat] = + useState(FALLBACK_DEFAULT_USER_SUBJECT_FORMAT); + const [clientStream, setClientStream] = useState( + 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( + "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( + "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( + "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( + "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("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("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(`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 ( + + {activeTab === "receiver" && ( + + )} + {activeTab === "stream" && ( + + )} + {activeTab === "subjects" && } + {activeTab === "event-search" && } + {activeTab === "emit-events" && ( + + )} + + ); +}; diff --git a/js/apps/admin-ui/src/clients/ssf/tabs/EmitEventsTab.tsx b/js/apps/admin-ui/src/clients/ssf/tabs/EmitEventsTab.tsx new file mode 100644 index 00000000000..145027b3d77 --- /dev/null +++ b/js/apps/admin-ui/src/clients/ssf/tabs/EmitEventsTab.tsx @@ -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({ + 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(null); + const [emitError, setEmitError] = useState(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 ( + + + {t("ssfEmitTitle")} + + + + {t("ssfEmitTitleHelp")} + + + + + + } + isRequired + > + ( + { + field.onChange(value.toString()); + setEmitEventTypeOpen(false); + setEmitEventTypeFilter(""); + }} + onClear={() => { + field.onChange(""); + setEmitEventTypeFilter(""); + }} + onFilter={setEmitEventTypeFilter} + > + {emittableEvents + .filter((eventType) => + eventType + .toLowerCase() + .includes(emitEventTypeFilter.toLowerCase()), + ) + .map((eventType) => ( + + {eventType} + {nativelyEmittedEvents.includes(eventType) && ( + + )} + + ))} + + )} + /> + + + ( + { + field.onChange(value as EmitSubjectType); + setEmitSubjectTypeOpen(false); + }} + > + + {t("ssfSubjectType.userEmail")} + + + {t("ssfSubjectType.userId")} + + + {t("ssfSubjectType.userUsername")} + + + {t("ssfSubjectType.orgAlias")} + + + )} + /> + + + } + isRequired + > + + value.trim() !== "" || t("ssfEmitSubjectValueRequired"), + }} + render={({ field }) => ( + field.onChange(value)} + placeholder={ + emitSubjectType === "user-email" + ? "user@example.com" + : emitSubjectType === "user-id" + ? "user-uuid" + : emitSubjectType === "user-username" + ? "username" + : "org-alias" + } + /> + )} + /> + + + } + > + ( + { + 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 && ( + + {emitPayloadParseError} + + )} + + + + + {emitError && ( + + )} + {emitResult && ( + + + {t("ssfEmitResult", { + status: emitResult.status, + jti: emitResult.jti, + })} + + {emitResult.jti && ( + + + {t("ssfEmitResultLookupLink")} + + + )} + + )} + + + + ); +}; diff --git a/js/apps/admin-ui/src/clients/ssf/tabs/EventSearchTab.tsx b/js/apps/admin-ui/src/clients/ssf/tabs/EventSearchTab.tsx new file mode 100644 index 00000000000..7118219f175 --- /dev/null +++ b/js/apps/admin-ui/src/clients/ssf/tabs/EventSearchTab.tsx @@ -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; + // 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(null); + const [pendingLookupError, setPendingLookupError] = useState( + 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 ( + + + {t("ssfLookupTitle")} + + + + {t("ssfLookupTitleHelp")} + + + + { + e.preventDefault(); + void handlePendingLookup(); + }} + > + + } + isRequired + > + + + setPendingLookupJti(value)} + placeholder={t("ssfPendingLookupJtiPlaceholder")} + /> + + + + + + + {pendingLookupError && ( + + {pendingLookupError} + + )} + {pendingLookupResult && ( + + + + {t("ssfPendingFieldStatus")}:{" "} + {pendingLookupResult.status ?? "-"} + + + {t("ssfPendingFieldEventType")}:{" "} + {pendingLookupResult.eventType ?? "-"} + + + {t("ssfPendingFieldDeliveryMethod")}:{" "} + {pendingLookupResult.deliveryMethod ?? "-"} + + {/* 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" && ( + + {t("ssfPendingFieldAttempts")}:{" "} + {pendingLookupResult.attempts ?? 0} + + )} + + {t("ssfPendingFieldCreatedAt")}:{" "} + {pendingLookupResult.createdAt + ? formatDate(new Date(pendingLookupResult.createdAt * 1000)) + : "-"} + + {pendingLookupResult.deliveryMethod !== "POLL" && ( + + {t("ssfPendingFieldNextAttemptAt")}:{" "} + {pendingLookupResult.nextAttemptAt + ? formatDate( + new Date(pendingLookupResult.nextAttemptAt * 1000), + ) + : "-"} + + )} + + {t("ssfPendingFieldDeliveredAt")}:{" "} + {pendingLookupResult.deliveredAt + ? formatDate( + new Date(pendingLookupResult.deliveredAt * 1000), + ) + : "-"} + + {pendingLookupResult.lastError && ( + + {t("ssfPendingFieldLastError")}:{" "} + {pendingLookupResult.lastError} + + )} + {pendingLookupResult.userId && ( + + {t("ssfPendingFieldUserId")}:{" "} + {pendingLookupResult.userId} + + )} + {pendingLookupResult.decodedSet && ( + <> + + {t("ssfPendingFieldDecodedSet")}: + +
+                      {JSON.stringify(pendingLookupResult.decodedSet, null, 2)}
+                    
+ + )} +
+
+ )} +
+
+
+ ); +}; diff --git a/js/apps/admin-ui/src/clients/ssf/tabs/ReceiverTab.tsx b/js/apps/admin-ui/src/clients/ssf/tabs/ReceiverTab.tsx new file mode 100644 index 00000000000..90e5df5dbd7 --- /dev/null +++ b/js/apps/admin-ui/src/clients/ssf/tabs/ReceiverTab.tsx @@ -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(); + + 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("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("clients"); + + const requiredRoleFieldName = convertAttributeNameToForm( + "attributes.ssf.requiredRole", + ); + + const emitEventsRoleFieldName = convertAttributeNameToForm( + "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("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("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("attributes.ssf.supportedEvents"), + ); + const ssfEmitOnlyEvents = watch( + convertAttributeNameToForm("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( + "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(["push", "poll"]) + : new Set(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 ( + + + {t("ssfReceiver")} + + + + {t("ssfReceiverHelp")} + + + + + + + {t("ssfSectionGeneralHelp")} + + + {!clientStream && ( + + )} + {clientStream?.status === "enabled" && ( + + )} + {clientStream?.status === "paused" && ( + + )} + {clientStream?.status === "disabled" && ( + + )} + {clientStream && !clientStream.status && ( + + )} + + ( + "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"), + }, + ]} + /> + ( + "attributes.ssf.description", + )} + label={t("ssfDescription")} + labelIcon={t("ssfDescriptionHelp")} + rules={{ + maxLength: { + value: 255, + message: t("maxLength", { length: 255 }), + }, + }} + /> + ( + "attributes.ssf.streamAudience", + )} + label={t("ssfStreamAudience")} + labelIcon={t("ssfStreamAudienceHelp")} + /> + ( + "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"), + }, + ]} + /> + ( + "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"), + }, + ]} + /> + ( + "attributes.ssf.autoNotifyOnLogin", + )} + label={t("ssfAutoNotifyOnLogin")} + labelIcon={t("ssfAutoNotifyOnLoginHelp")} + stringify + /> + + } + > + ( + "attributes.ssf.subjectRemovalGraceSeconds", + )} + defaultValue="" + control={control} + render={({ field }) => ( + + )} + /> + + ( + "attributes.ssf.autoVerifyStream", + )} + label={t("ssfAutoVerifyStream")} + labelIcon={t("ssfAutoVerifyStreamHelp")} + stringify + /> + {ssfAutoVerifyStream === "true" && ( + ( + "attributes.ssf.verificationDelayMillis", + )} + label={t("ssfVerificationDelay")} + labelIcon={t("ssfVerificationDelayHelp")} + controller={{ + defaultValue: 1500, + rules: { + min: 0, + }, + }} + /> + )} + ( + "attributes.ssf.minVerificationInterval", + )} + label={t("ssfMinVerificationInterval")} + labelIcon={t("ssfMinVerificationIntervalHelp")} + controller={{ + defaultValue: "", + rules: { + min: 0, + }, + }} + /> + + } + > + ( + "attributes.ssf.maxEventAgeSeconds", + )} + defaultValue="" + control={control} + render={({ field }) => ( + + )} + /> + + + } + > + ( + "attributes.ssf.inactivityTimeoutSeconds", + )} + defaultValue="" + control={control} + render={({ field }) => ( + + )} + /> + + + ), + }, + { + title: t("ssfSectionAuthentication"), + panel: ( +
+ + {t("ssfSectionAuthenticationHelp")} + + ( + "attributes.ssf.requireServiceAccount", + )} + label={t("ssfRequireServiceAccount")} + labelIcon={t("ssfRequireServiceAccountHelp")} + stringify + /> + + } + > + ( + + {rolePickerOpen && ( + { + 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 !== "" && ( + + field.onChange("")} + > + + + + )} + + { + setRoleFilterType(type); + setRolePickerOpen(true); + }} + variant="secondary" + data-testid="ssfRequiredRoleSelect" + isDisabled={false} + /> + + + )} + /> + +
+ ), + }, + { + title: t("ssfSectionDelivery"), + panel: ( +
+ + {t("ssfSectionDeliveryHelp")} + + + } + > + + + + updateAllowedDeliveryMethod("push", checked) + } + /> + + + + updateAllowedDeliveryMethod("poll", checked) + } + /> + + + + {pushDeliveryAllowed && ( + + } + > + ( + "attributes.ssf.validPushUrls", + )} + aria-label={t("ssfValidPushUrls")} + addButtonLabel="ssfValidPushUrls.add" + stringify + /> + + )} +
+ ), + }, + { + title: t("ssfSectionEvents"), + panel: ( +
+ + {t("ssfSectionEventsHelp")} + + + } + > + { + const option = value.toString(); + if (!option) return; + const next = supportedEventsSelected.includes(option) + ? supportedEventsSelected.filter( + (s) => s !== option, + ) + : [...supportedEventsSelected, option]; + setValue( + convertAttributeNameToForm( + "attributes.ssf.supportedEvents", + ), + next.join(","), + { shouldDirty: true }, + ); + setSupportedEventsFilter(""); + }} + onClear={() => { + setValue( + convertAttributeNameToForm( + "attributes.ssf.supportedEvents", + ), + "", + { shouldDirty: true }, + ); + setSupportedEventsFilter(""); + }} + onFilter={setSupportedEventsFilter} + > + {availableSupportedEvents + .filter((event) => + event + .toLowerCase() + .includes(supportedEventsFilter.toLowerCase()), + ) + .map((event) => ( + + {event} + {nativelyEmittedEvents.includes(event) && ( + + )} + + ))} + + + ( + "attributes.ssf.allowEmitEvents", + )} + label={t("ssfAllowEmitEvents")} + labelIcon={t("ssfAllowEmitEventsHelp")} + stringify + /> + + } + > + { + const option = value.toString(); + if (!option) return; + const next = emitOnlyEventsSelected.includes(option) + ? emitOnlyEventsSelected.filter((s) => s !== option) + : [...emitOnlyEventsSelected, option]; + setValue( + convertAttributeNameToForm( + "attributes.ssf.emitOnlyEvents", + ), + next.join(","), + { shouldDirty: true }, + ); + setEmitOnlyEventsFilter(""); + }} + onClear={() => { + setValue( + convertAttributeNameToForm( + "attributes.ssf.emitOnlyEvents", + ), + "", + { shouldDirty: true }, + ); + setEmitOnlyEventsFilter(""); + }} + onFilter={setEmitOnlyEventsFilter} + isDisabled={supportedEventsSelected.length === 0} + > + {supportedEventsSelected + .filter((event) => + event + .toLowerCase() + .includes(emitOnlyEventsFilter.toLowerCase()), + ) + .map((event) => ( + + {event} + + ))} + + + {String(ssfAllowEmitEvents) === "true" && ( + + } + > + ( + + {emitRolePickerOpen && ( + { + 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 !== "" && ( + + field.onChange("")} + > + + + + )} + + { + setEmitRoleFilterType(type); + setEmitRolePickerOpen(true); + }} + variant="secondary" + data-testid="ssfEmitEventsRoleSelect" + isDisabled={false} + /> + + + )} + /> + + )} +
+ ), + }, + ]} + /> + + + + +
+
+
+ ); +}; diff --git a/js/apps/admin-ui/src/clients/ssf/tabs/StreamTab.tsx b/js/apps/admin-ui/src/clients/ssf/tabs/StreamTab.tsx new file mode 100644 index 00000000000..7e6c56a6150 --- /dev/null +++ b/js/apps/admin-ui/src/clients/ssf/tabs/StreamTab.tsx @@ -0,0 +1,1211 @@ +import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation"; +import { + HelpItem, + KeycloakSelect, + ListEmptyState, + NumberControl, + PasswordInput, + SelectVariant, + useAlerts, +} from "@keycloak/keycloak-ui-shared"; +import { + ActionGroup, + AlertVariant, + Button, + ButtonVariant, + Card, + CardBody, + CardHeader, + CardTitle, + FormGroup, + FormHelperText, + HelperText, + HelperTextItem, + InputGroup, + InputGroupItem, + Label, + SelectOption, + Text, + TextContent, + TextInput, +} from "@patternfly/react-core"; +import { + CheckCircleIcon, + InfoCircleIcon, + PauseCircleIcon, + SyncAltIcon, + TimesCircleIcon, +} from "@patternfly/react-icons"; +import { useState } from "react"; +import { useFormContext, useWatch } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +import { useAdminClient } from "../../../admin-client"; +import { useConfirmDialog } from "../../../components/confirm-dialog/ConfirmDialog"; +import { CopyToClipboardButton } from "../../../components/copy-to-clipboard-button/CopyToClipboardButton"; +import { FormAccess } from "../../../components/form/FormAccess"; +import { useRealm } from "../../../context/realm-context/RealmContext"; +import { addTrailingSlash, convertAttributeNameToForm } from "../../../util"; +import { getAuthorizationHeaders } from "../../../utils/getAuthorizationHeaders"; +import useFormatDate from "../../../utils/useFormatDate"; +import type { FormFields, SaveOptions } from "../../ClientDetails"; + +const DELIVERY_METHOD_PUSH_URI = "urn:ietf:rfc:8935"; +const DELIVERY_METHOD_POLL_URI = "urn:ietf:rfc:8936"; + +const isPollDeliveryMethod = (method: string | undefined): boolean => + method === DELIVERY_METHOD_POLL_URI || + method === "https://schemas.openid.net/secevent/risc/delivery-method/poll"; + +const noop = () => { + // no-op handler for the read-only Events Delivered KeycloakSelect +}; + +/** + * Best-effort URL validation for the receiver's push endpoint. Uses + * the URL constructor (no regex juggling) and additionally requires + * an http/https protocol — the SSF dispatcher only knows how to push + * over HTTP, so accepting e.g. ftp:// or javascript: would just + * dead-letter the queued events on first push attempt. An empty + * string is treated as "not yet typed" and not validated here so the + * field's existing isRequired check owns that case. + */ +const isValidPushEndpointUrl = (value: string): boolean => { + const trimmed = value.trim(); + if (trimmed === "") return true; + try { + const parsed = new URL(trimmed); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +}; + +type SsfClientStreamDelivery = { + method?: string; + endpoint_url?: string; + authorization_header?: string; +}; + +export type SsfClientStream = { + streamId?: string; + description?: string; + status?: string; + statusReason?: string; + audience?: string[]; + delivery?: SsfClientStreamDelivery; + eventsSupported?: string[]; + eventsRequested?: string[]; + eventsDelivered?: string[]; + createdAt?: number; + updatedAt?: number; + lastVerifiedAt?: number; +}; + +export type StreamTabProps = { + client: ClientRepresentation; + clientStream: SsfClientStream | null; + setClientStream: (stream: SsfClientStream | null) => void; + defaultSupportedEvents: string; + nativelyEmittedEvents: string[]; + defaultPushConnectTimeoutMillis: number; + defaultPushSocketTimeoutMillis: number; + save: (options?: SaveOptions) => void; + reset: () => void; + refresh: () => void; +}; + +export const StreamTab = ({ + client, + clientStream, + setClientStream, + defaultSupportedEvents, + nativelyEmittedEvents, + defaultPushConnectTimeoutMillis, + defaultPushSocketTimeoutMillis, + save, + reset, + refresh, +}: StreamTabProps) => { + const { t } = useTranslation(); + const { adminClient } = useAdminClient(); + const { realm } = useRealm(); + const { control, setValue } = useFormContext(); + const { addAlert, addError } = useAlerts(); + const formatDate = useFormatDate(); + + // Watch the delivery method via useWatch so the controlled context + // hook plays nicely with this child component. + const ssfDelivery = useWatch({ + control, + name: convertAttributeNameToForm( + "attributes.ssf.delivery", + ) as never, + }) as string | undefined; + + // The Events Requested dropdown on the create-stream form should + // surface only events the receiver has marked as Supported, not the + // full transmitter-wide registry. Watch the form value so the list + // reflects unsaved Receiver-tab edits without a round-trip; fall + // back to the realm default when the receiver hasn't customised it. + const ssfSupportedEvents = useWatch({ + control, + name: convertAttributeNameToForm( + "attributes.ssf.supportedEvents", + ) as never, + }) as string | undefined; + const receiverSupportedEvents = (ssfSupportedEvents ?? defaultSupportedEvents) + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0) + // Storage order is whatever the operator picked when configuring the + // receiver — sort here so the create-stream dropdown is alphabetical + // and matches the deterministic order the backend now returns for + // availableSupportedEvents. + .sort((a, b) => a.localeCompare(b)); + + // Admin-side create-stream form state — used by the empty-state form + // when no stream is registered yet. Kept as plain useState because the + // surrounding react-hook-form context is bound to the client + // representation's attributes, not to stream-create parameters. + const [createStreamAudience, setCreateStreamAudience] = useState(""); + const [createStreamMethod, setCreateStreamMethod] = useState<"PUSH" | "POLL">( + "PUSH", + ); + const [createStreamEndpointUrl, setCreateStreamEndpointUrl] = useState(""); + const [createStreamAuthHeader, setCreateStreamAuthHeader] = useState(""); + const [createStreamEvents, setCreateStreamEvents] = useState([]); + const [createStreamEventsFilter, setCreateStreamEventsFilter] = useState(""); + const [createStreamProfile, setCreateStreamProfile] = useState< + "SSF_1_0" | "SSE_CAEP" + >( + (client.attributes?.["ssf.profile"] as "SSF_1_0" | "SSE_CAEP") || "SSF_1_0", + ); + const [createStreamDescription, setCreateStreamDescription] = useState(""); + const [createStreamEventsOpen, setCreateStreamEventsOpen] = useState(false); + const [createStreamSubmitting, setCreateStreamSubmitting] = useState(false); + const [createStreamFormOpen, setCreateStreamFormOpen] = useState(false); + + const [statusActionLoading, setStatusActionLoading] = useState(false); + + /** + * Drives the admin stream-status endpoint + * (POST /admin/realms/{realm}/ssf/clients/{clientId}/stream/status). + * The backend funnels through StreamService.updateStreamStatus, so + * this triggers the spec-mandated stream-updated SET dispatch and + * the outbox HELD ↔ PENDING alignment that a generic client-save + * doesn't. + */ + const triggerStreamStatusUpdate = async ( + targetStatus: "enabled" | "paused" | "disabled", + ) => { + if (!client.id || !clientStream?.streamId) { + return; + } + setStatusActionLoading(true); + try { + const response = await fetch( + `${addTrailingSlash( + adminClient.baseUrl, + )}admin/realms/${realm}/ssf/clients/${client.clientId}/stream/status`, + { + method: "POST", + headers: { + ...getAuthorizationHeaders(await adminClient.getAccessToken()), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + stream_id: clientStream.streamId, + status: targetStatus, + }), + }, + ); + if (!response.ok) { + const text = await response.text(); + throw new Error( + text || + `${response.status} ${response.statusText || "Request failed"}`, + ); + } + // Keep the form-bound status field in sync immediately so a + // subsequent generic Save doesn't clobber the just-applied + // backend status with a stale form value (the parent's + // useFetch refresh below is async and races the click). + setValue( + convertAttributeNameToForm("attributes.ssf.status"), + targetStatus, + { shouldDirty: false }, + ); + addAlert(t("ssfStreamStatusUpdateSuccess"), AlertVariant.success); + // Refresh so the read-only status indicator + lastVerifiedAt / + // updatedAt in the UI reflect the new state. + refresh(); + } catch (error) { + addError("ssfStreamStatusUpdateError", error); + } finally { + setStatusActionLoading(false); + } + }; + + const triggerVerifyStream = async () => { + if (!client.id) { + return; + } + try { + const response = await fetch( + `${addTrailingSlash( + adminClient.baseUrl, + )}admin/realms/${realm}/ssf/clients/${client.clientId}/stream/verify`, + { + method: "POST", + headers: getAuthorizationHeaders(await adminClient.getAccessToken()), + }, + ); + if (!response.ok) { + throw new Error( + `${response.status} ${response.statusText || "Request failed"}`, + ); + } + addAlert(t("ssfVerifyStreamSuccess"), AlertVariant.success); + // Refetch the stream endpoint so the Last verified field in the UI + // picks up the new timestamp the backend just stamped. + refresh(); + } catch (error) { + addError("ssfVerifyStreamError", error); + } + }; + + const resetCreateStreamForm = () => { + setCreateStreamAudience(""); + setCreateStreamEndpointUrl(""); + setCreateStreamAuthHeader(""); + setCreateStreamEvents([]); + setCreateStreamDescription(""); + setCreateStreamMethod("PUSH"); + }; + + // Prefill events_requested with the receiver's Supported Events on + // open. The vast majority of streams want every event the receiver + // declared support for; the operator can still narrow the selection + // before submitting. Seeded fresh each open so unsaved Receiver-tab + // edits flow through. + const openCreateStreamForm = () => { + setCreateStreamEvents([...receiverSupportedEvents]); + setCreateStreamFormOpen(true); + }; + + const submitCreateStream = async () => { + if (!client.id) { + return; + } + // PUSH requires the receiver to supply its own endpoint URL; POLL + // doesn't (the transmitter generates the URL itself per SSF §6.1.2 + // and writes it back on the response). + if (createStreamMethod === "PUSH" && !createStreamEndpointUrl.trim()) { + addError( + "ssfCreateStreamError", + new Error(t("ssfCreateStreamEndpointUrlRequired")), + ); + return; + } + if ( + createStreamMethod === "PUSH" && + !isValidPushEndpointUrl(createStreamEndpointUrl) + ) { + addError( + "ssfCreateStreamError", + new Error(t("ssfCreateStreamEndpointUrlInvalid")), + ); + return; + } + setCreateStreamSubmitting(true); + try { + // SSF profile is a per-receiver attribute; the create-stream + // backend reads it from the client (resolveReceiverProfile). + // If the operator picked a different profile in the create + // form, persist the receiver attribute first so the subsequent + // create-stream call sees the new value. Two API calls instead + // of one, but the side effect is OK — profile is a one-time + // decision per receiver, and the create form is precisely + // where you'd be making it the first time. + const savedProfile = client.attributes?.["ssf.profile"] || "SSF_1_0"; + if (createStreamProfile !== savedProfile) { + await adminClient.clients.update( + { id: client.id! }, + { + ...client, + attributes: { + ...(client.attributes ?? {}), + "ssf.profile": createStreamProfile, + }, + }, + ); + } + + // Event aliases stored in local state are resolved back to their + // canonical URIs by the transmitter at create time — the admin UI + // shows/stores aliases because that's what the admin picks from + // the receiver's supported-events list, and the backend accepts + // either form. + const delivery: Record = { + method: + createStreamMethod === "POLL" + ? DELIVERY_METHOD_POLL_URI + : DELIVERY_METHOD_PUSH_URI, + }; + if (createStreamMethod === "PUSH") { + delivery.endpoint_url = createStreamEndpointUrl.trim(); + if (createStreamAuthHeader.trim()) { + delivery.authorization_header = createStreamAuthHeader.trim(); + } + } + const body: Record = { delivery }; + if (createStreamAudience.trim()) { + body.aud = [createStreamAudience.trim()]; + } + if (createStreamEvents.length > 0) { + body.events_requested = createStreamEvents; + } + if (createStreamDescription.trim()) { + body.description = createStreamDescription.trim(); + } + + const response = await fetch( + `${addTrailingSlash( + adminClient.baseUrl, + )}admin/realms/${realm}/ssf/clients/${client.clientId}/stream`, + { + method: "POST", + headers: { + ...getAuthorizationHeaders(await adminClient.getAccessToken()), + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }, + ); + if (!response.ok) { + const text = await response.text(); + throw new Error( + text || + `${response.status} ${response.statusText || "Request failed"}`, + ); + } + addAlert(t("ssfCreateStreamSuccess"), AlertVariant.success); + resetCreateStreamForm(); + setCreateStreamFormOpen(false); + refresh(); + } catch (error) { + addError("ssfCreateStreamError", error); + } finally { + setCreateStreamSubmitting(false); + } + }; + + const [toggleDeleteStreamDialog, DeleteStreamConfirm] = useConfirmDialog({ + titleKey: "ssfDeleteStreamConfirmTitle", + messageKey: "ssfDeleteStreamConfirmMessage", + continueButtonLabel: "delete", + continueButtonVariant: ButtonVariant.danger, + onConfirm: async () => { + if (!client.id) { + return; + } + try { + const response = await fetch( + `${addTrailingSlash( + adminClient.baseUrl, + )}admin/realms/${realm}/ssf/clients/${client.clientId}/stream`, + { + method: "DELETE", + headers: getAuthorizationHeaders( + await adminClient.getAccessToken(), + ), + }, + ); + if (!response.ok) { + throw new Error( + `${response.status} ${response.statusText || "Request failed"}`, + ); + } + addAlert(t("ssfDeleteStreamSuccess"), AlertVariant.success); + setClientStream(null); + refresh(); + } catch (error) { + addError("ssfDeleteStreamError", error); + } + }, + }); + + return ( + <> + + + + {t("ssfStream")} + + + + {t("ssfStreamHelp")} + + + + {!createStreamFormOpen && ( + + + + )} + {!clientStream ? ( + <> + {!createStreamFormOpen && ( + + )} + {createStreamFormOpen && ( + + + } + > + setCreateStreamAudience(value)} + /> + + + } + > + + + + } + > + + + {createStreamMethod === "PUSH" && ( + <> + + } + > + + setCreateStreamEndpointUrl(value) + } + /> + {!isValidPushEndpointUrl(createStreamEndpointUrl) && ( + + + + {t("ssfCreateStreamEndpointUrlInvalid")} + + + + )} + + + } + > + + + + setCreateStreamAuthHeader( + (event.target as HTMLInputElement).value, + ) + } + /> + + + + + + + + )} + + } + > + { + const option = value.toString(); + if (!option) return; + setCreateStreamEvents((current) => + current.includes(option) + ? current.filter((e) => e !== option) + : [...current, option], + ); + setCreateStreamEventsFilter(""); + }} + onClear={() => { + setCreateStreamEvents([]); + setCreateStreamEventsFilter(""); + }} + onFilter={setCreateStreamEventsFilter} + > + {receiverSupportedEvents + .filter((event) => + event + .toLowerCase() + .includes(createStreamEventsFilter.toLowerCase()), + ) + .map((event) => ( + + {event} + {nativelyEmittedEvents.includes(event) && ( + + )} + + ))} + + + + } + > + + setCreateStreamDescription(value) + } + /> + + + + + + + )} + + ) : ( + + + + + + + + + + + + {clientStream.description && ( + + } + > + + + )} + {clientStream.createdAt && ( + + } + > + + + )} + {clientStream.updatedAt && ( + + } + > + + + )} + + } + > + + + + } + > + 0 + ? clientStream.audience.join(", ") + : "" + } + /> + + + } + > + + + {clientStream.delivery?.endpoint_url && ( + + } + > + + + + + + + + + + )} + {/* Push auth header is irrelevant for POLL — receivers + authenticate themselves with their own bearer token to + call the transmitter-hosted poll endpoint, and the + transmitter doesn't store an outbound auth header for + POLL streams. */} + {clientStream.delivery?.endpoint_url && + !isPollDeliveryMethod(clientStream.delivery.method) && ( + + } + > + + + + + + + + + + )} + + } + > + {clientStream.status === "enabled" && ( + + )} + {clientStream.status === "paused" && ( + + )} + {clientStream.status === "disabled" && ( + + )} + {!clientStream.status && ( + + )} + + + {clientStream.status !== "enabled" && ( + + )} + {clientStream.status !== "paused" && ( + + )} + {clientStream.status !== "disabled" && ( + + )} + + + } + > + + + + } + > + {clientStream.eventsRequested && + clientStream.eventsRequested.length > 0 ? ( + + {clientStream.eventsRequested.map((event) => ( + + {event} + + ))} + + ) : ( + + )} + + + } + > + {clientStream.eventsDelivered && + clientStream.eventsDelivered.length > 0 ? ( + + {clientStream.eventsDelivered.map((event) => ( + + {event} + + ))} + + ) : ( + + )} + + {/* Push timeouts only apply when Keycloak makes outbound + HTTP push requests. POLL is inbound (receivers call + the transmitter), so the timeout knobs have no effect + and we hide them to avoid suggesting otherwise. */} + {(ssfDelivery === "PUSH" || !ssfDelivery) && + !isPollDeliveryMethod(clientStream.delivery?.method) && ( + <> + ( + "attributes.ssf.pushEndpointConnectTimeoutMillis", + )} + label={t("ssfPushEndpointConnectTimeout")} + labelIcon={t("ssfPushEndpointConnectTimeoutHelp")} + controller={{ + defaultValue: defaultPushConnectTimeoutMillis, + rules: { + min: 0, + }, + }} + /> + ( + "attributes.ssf.pushEndpointSocketTimeoutMillis", + )} + label={t("ssfPushEndpointSocketTimeout")} + labelIcon={t("ssfPushEndpointSocketTimeoutHelp")} + controller={{ + defaultValue: defaultPushSocketTimeoutMillis, + rules: { + min: 0, + }, + }} + /> + + )} + + + + + + + + )} + + + + ); +}; diff --git a/js/apps/admin-ui/src/clients/ssf/tabs/SubjectsTab.tsx b/js/apps/admin-ui/src/clients/ssf/tabs/SubjectsTab.tsx new file mode 100644 index 00000000000..db6820a6b51 --- /dev/null +++ b/js/apps/admin-ui/src/clients/ssf/tabs/SubjectsTab.tsx @@ -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("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( + 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 ( + + + {t("ssfTabSubjects")} + + + + {t("ssfSubjectsHelp")} + + + + { + e.preventDefault(); + void handleSubjectAction("check"); + }} + > + + } + > + + + + { + 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 && ( + + + + {subjectValueError} + + + + )} + + {subjectStatus && ( + + + + )} + + + + + + + + + + ); +}; diff --git a/js/apps/admin-ui/src/realm-settings/GeneralTab.tsx b/js/apps/admin-ui/src/realm-settings/GeneralTab.tsx index aadb956158f..62917d52477 100644 --- a/js/apps/admin-ui/src/realm-settings/GeneralTab.tsx +++ b/js/apps/admin-ui/src/realm-settings/GeneralTab.tsx @@ -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(); const { control, @@ -121,6 +128,38 @@ function RealmSettingsGeneralTabForm({ ); const isScimApiEnabled = isFeatureEnabled(Feature.ScimApi); + const isSsfEnabled = isFeatureEnabled(Feature.Ssf); + + const ssfTransmitterEnabled = useWatch({ + control, + name: convertAttributeNameToForm( + "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( + "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 && ( + <> + ( + "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(); + } + }} + /> + + + )} )} + {isSsfEnabled && ssfTransmitterEnabled?.toString() === "true" && ( + + + + )} 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. + * + *

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 => { + 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. + * + *

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: ( + + {t("ssfTransmitterDisableConfirmIntro")} + + + {t("ssfTransmitterDisableConfirmBulletEndpoints")} + + + {t("ssfTransmitterDisableConfirmBulletEvents")} + + + {t("ssfTransmitterDisableConfirmBulletDelivery")} + + + {t("ssfTransmitterDisableConfirmBulletReceivers")} + + + {t("ssfTransmitterDisableConfirmRecommendation")} + + ), + }); +}; diff --git a/js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts b/js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts index 96a3dd0c023..be22af73ce0 100644 --- a/js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts +++ b/js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts @@ -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", } diff --git a/js/libs/ui-shared/src/controls/HelpItem.tsx b/js/libs/ui-shared/src/controls/HelpItem.tsx index f7ace8b502b..53d92c0967d 100644 --- a/js/libs/ui-shared/src/controls/HelpItem.tsx +++ b/js/libs/ui-shared/src/controls/HelpItem.tsx @@ -26,6 +26,7 @@ export const HelpItem = ({ <> {!unWrap && (