8000 Add Shared Signals Framework Transmitter capability by thomasdarimont · Pull Request #48256 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions common/src/main/java/org/keycloak/common/Profile.java
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ public enum Feature {

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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,19 @@ public static class Tags {
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() { }
}
}
}

Large diffs are not rendered by default.

121 changes: 120 additions & 1 deletion js/apps/admin-ui/src/clients/ClientDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -225,6 +227,31 @@ export default function ClientDetails() {
defaultValue: "client-secret",
});

const ssfEnabled = useWatch({
control: form.control,
name: convertAttributeNameToForm<FormFields>("attributes.ssf.enabled"),
});

const ssfAllowEmitEvents = useWatch({
control: form.control,
name: convertAttributeNameToForm<FormFields>(
"attributes.ssf.allowEmitEvents",
),
});
const showSsfEmitEventsTab = ssfAllowEmitEvents?.toString() === "true";

// Gate every SSF surface on three conditions: server feature flag,
// realm-level transmitter toggle, and per-client opt-in. Missing any
// one means SSF endpoints aren't available — surfacing the tab would
// crash on the first API call (the original bug from the review).
const isSsfFeatureEnabled = isFeatureEnabled(Feature.Ssf);
const isSsfRealmEnabled =
realmRepresentation?.attributes?.["ssf.transmitterEnabled"] === "true";
const showSsfTab =
isSsfFeatureEnabled &&
isSsfRealmEnabled &&
ssfEnabled?.toString() === "true";

const [client, setClient] = useState<ClientRepresentation>();

const loader = async () => {
Expand All @@ -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");
Expand All @@ -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,
Expand Down Expand Up @@ -688,6 +728,85 @@ export default function ClientDetails() {
>
<AdvancedTab save={save} client={client} />
</Tab>
{client.protocol === "openid-connect" &&
!client.publicClient &&
showSsfTab && (
<Tab
id="ssf"
data-testid="ssfTab"
title={<TabTitleText>{t("ssf")}</TabTitleText>}
{...ssfTab}
>
<RoutableTabs
defaultLocation={ssfTabRoute("receiver")}
mountOnEnter
unmountOnExit
>
<Tab
id="ssfReceiverTab"
data-testid="ssfReceiverTab"
title={<TabTitleText>{t("ssfTabReceiver")}</TabTitleText>}
{...ssfReceiverTab}
>
<SsfTab
save={save}
client={client}
activeTab="receiver"
/>
</Tab>
<Tab
id="ssfStreamTab"
data-testid="ssfStreamTab"
title={<TabTitleText>{t("ssfTabStream")}</TabTitleText>}
{...ssfStreamTab}
>
<SsfTab save={save} client={client} activeTab="stream" />
</Tab>
<Tab
id="ssfSubjectsTab"
data-testid="ssfSubjectsTab"
title={<TabTitleText>{t("ssfTabSubjects")}</TabTitleText>}
{...ssfSubjectsTab}
>
<SsfTab
save={save}
client={client}
activeTab="subjects"
/>
</Tab>
<Tab
id="ssfEventSearchTab"
data-testid="ssfEventSearchTab"
title={
<TabTitleText>{t("ssfTabEventSearch")}</TabTitleText>
}
{...ssfEventSearchTab}
>
<SsfTab
save={save}
client={client}
activeTab="event-search"
/>
</Tab>
{showSsfEmitEventsTab && (
<Tab
id="ssfEmitEventsTab"
data-testid="ssfEmitEventsTab"
title={
<TabTitleText>{t("ssfTabEmitEvents")}</TabTitleText>
}
{...ssfEmitEventsTab}
>
<SsfTab
save={save}
client={client}
activeTab="emit-events"
/>
</Tab>
)}
</RoutableTabs>
</Tab>
)}
{hasAccess("view-events") && (
<Tab
data-testid="events-tab"
Expand Down
25 changes: 25 additions & 0 deletions js/apps/admin-ui/src/clients/add/CapabilityConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { FormFields } from "../ClientDetails";
import { IdentityProviderSelect } from "../../components/identity-provider/IdentityProviderSelect";
import { IdentityProviderType } from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation";
import { useAccess } from "../../context/access/Access";
import { useRealm } from "../../context/realm-context/RealmContext";

type CapabilityConfigProps = {
unWrap?: boolean;
Expand Down Expand Up @@ -50,7 +51,15 @@ export const CapabilityConfig = ({
);
const isFeatureEnabled = useIsFeatureEnabled();
const { hasSomeAccess } = useAccess();
const { realmRepresentation } = useRealm();
const showIdentityProviders = hasSomeAccess("view-identity-providers");
// Mirror the gate in ClientDetails: only expose the SSF capability
// toggle when both the server feature is enabled and the realm has
// opted in. Otherwise enabling the toggle would just render a tab
// that crashes on its first API call.
const showSsfReceiverToggle =
isFeatureEnabled(Feature.Ssf) &&
realmRepresentation?.attributes?.["ssf.transmitterEnabled"] === "true";
return (
<FormAccess
isHorizontal
Expand Down Expand Up @@ -106,6 +115,12 @@ export const CapabilityConfig = ({
),
false,
);
setValue(
convertAttributeNameToForm<FormFields>(
"attributes.ssf.enabled",
),
false,
);
}
}}
aria-label={t("clientAuthentication")}
Expand Down Expand Up @@ -498,6 +513,16 @@ export const CapabilityConfig = ({
)}
</>
)}
{!clientAuthentication && showSsfReceiverToggle && (
<DefaultSwitchControl
name={convertAttributeNameToForm<FormFields>(
"attributes.ssf.enabled",
)}
label={t("ssfReceiverEnabled")}
labelIcon={t("ssfReceiverEnabledHelp")}
stringify
/>
)}
</>
)}
{protocol === "saml" && (
Expand Down
2 changes: 2 additions & 0 deletions js/apps/admin-ui/src/clients/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -50,6 +51,7 @@ const routes: AppRouteObject[] = [
DedicatedScopeDetailsRoute,
DedicatedScopeDetailsWithTabRoute,
ClientScopesRoute,
ClientSsfTabRoute,
ClientRoleRoute,
AuthorizationRoute,
NewResourceRoute,
Expand Down
3 changes: 2 additions & 1 deletion js/apps/admin-ui/src/clients/routes/Client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export type ClientTab =
| "serviceAccount"
| "permissions"
| "sessions"
| "events";
| "events"
| "ssf";

export type ClientParams = {
realm: string;
Expand Down
37 changes: 37 additions & 0 deletions js/apps/admin-ui/src/clients/routes/ClientSsfTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { lazy } from "react";
import type { Path } from "react-router-dom";
import { generateEncodedPath } from "../../utils/generateEncodedPath";
import type { AppRouteObject } from "../../routes";

/**
* The five sub-tabs of the SSF view on a client. Mirrors the pattern
* used by Client Scopes (setup / evaluate) so the SSF view's sub-tabs
* are deep-linkable from URLs and bookmarkable per section.
*/
export type SsfClientTab =
| "receiver"
| "stream"
| "subjects"
| "event-search"
| "emit-events";

export type ClientSsfTabParams = {
realm: string;
clientId: string;
tab: SsfClientTab;
};

const ClientDetails = lazy(() => import("../ClientDetails"));

export const ClientSsfTabRoute: AppRouteObject = {
path: "/:realm/clients/:clientId/ssf/:tab",
element: <ClientDetails />,
handle: {
access: "view-clients",
breadcrumb: (t) => t("clientSettings"),
},
};

export const toSsfClientTab = (params: ClientSsfTabParams): Partial<Path> => ({
pathname: generateEncodedPath(ClientSsfTabRoute.path, params),
});
Loading
Loading
0