Update Admin UI for SPIFFE Identity Provider (#42507)

Closes #42464

Signed-off-by: stianst <stianst@gmail.com>
This commit is contained in:
Stian Thorgersen
2025-09-11 11:44:44 +02:00
committed by GitHub
parent f26e487edb
commit 22cccdd1db
10 changed files with 229 additions and 2 deletions

View File

@@ -230,7 +230,7 @@ jobs:
- name: Start Keycloak server
run: |
tar xfvz keycloak-999.0.0-SNAPSHOT.tar.gz
keycloak-999.0.0-SNAPSHOT/bin/kc.sh start-dev --features=admin-fine-grained-authz:v2,transient-users &> ~/server.log &
keycloak-999.0.0-SNAPSHOT/bin/kc.sh start-dev --features=admin-fine-grained-authz:v2,transient-users,spiffe &> ~/server.log &
env:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin

View File

@@ -934,6 +934,9 @@ disableConfirmClient=Are you sure you want to disable this client?
custom=Custom Attribute...
keyTab=Key tab
addSamlProvider=Add SAML provider
addSpiffeProvider=Add SPIFFE provider
spiffeTrustDomain=SPIFFE Trust Domain name
spiffeBundleEndpoint=SPIFFE Bundle Endpoint
permission=Permission
saveEventListeners=Save Event Listeners
capabilityConfig=Capability config

View File

@@ -0,0 +1,99 @@
import type IdentityProviderRepresentation from "@keycloak/keycloak-admin-client/lib/defs/identityProviderRepresentation";
import {
ActionGroup,
AlertVariant,
Button,
PageSection,
} from "@patternfly/react-core";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { useAdminClient } from "../../admin-client";
import { useAlerts } from "@keycloak/keycloak-ui-shared";
import { FormAccess } from "../../components/form/FormAccess";
import { ViewHeader } from "../../components/view-header/ViewHeader";
import { useRealm } from "../../context/realm-context/RealmContext";
import { toIdentityProvider } from "../routes/IdentityProvider";
import { toIdentityProviders } from "../routes/IdentityProviders";
import { SpiffeSettings } from "./SpiffeSettings";
type DiscoveryIdentityProvider = IdentityProviderRepresentation & {
discoveryEndpoint?: string;
};
export default function AddSamlConnect() {
const { adminClient } = useAdminClient();
const { t } = useTranslation();
const navigate = useNavigate();
const id = "spiffe";
const form = useForm<DiscoveryIdentityProvider>({
defaultValues: { alias: id, config: { allowCreate: "true" } },
mode: "onChange",
});
const {
handleSubmit,
formState: { isDirty },
} = form;
const { addAlert, addError } = useAlerts();
const { realm } = useRealm();
const onSubmit = async (provider: DiscoveryIdentityProvider) => {
delete provider.discoveryEndpoint;
try {
await adminClient.identityProviders.create({
...provider,
providerId: id,
});
addAlert(t("createIdentityProviderSuccess"), AlertVariant.success);
navigate(
toIdentityProvider({
realm,
providerId: id,
alias: provider.alias!,
tab: "settings",
}),
);
} catch (error: any) {
addError("createIdentityProviderError", error);
}
};
return (
<>
<ViewHeader titleKey={t("addSpiffeProvider")} />
<PageSection variant="light">
<FormProvider {...form}>
<FormAccess
role="manage-identity-providers"
isHorizontal
onSubmit={handleSubmit(onSubmit)}
>
<SpiffeSettings />
<ActionGroup>
<Button
isDisabled={!isDirty}
variant="primary"
type="submit"
data-testid="createProvider"
>
{t("add")}
</Button>
<Button
variant="link"
data-testid="cancel"
component={(props) => (
<Link {...props} to={toIdentityProviders({ realm })} />
)}
>
{t("cancel")}
</Button>
</ActionGroup>
</FormAccess>
</FormProvider>
</PageSection>
</>
);
}

View File

@@ -66,6 +66,7 @@ import { OIDCAuthentication } from "./OIDCAuthentication";
import { OIDCGeneralSettings } from "./OIDCGeneralSettings";
import { ReqAuthnConstraints } from "./ReqAuthnConstraintsSettings";
import { SamlGeneralSettings } from "./SamlGeneralSettings";
import { SpiffeSettings } from "./SpiffeSettings";
import { AdminEvents } from "../../events/AdminEvents";
import { UserProfileClaimsSettings } from "./OAuth2UserProfileClaimsSettings";
@@ -412,6 +413,7 @@ export default function DetailSettings() {
const isOIDC = provider.providerId!.includes("oidc");
const isSAML = provider.providerId!.includes("saml");
const isOAuth2 = provider.providerId!.includes("oauth2");
const isSPIFFE = provider.providerId!.includes("spiffe");
const isSocial = !isOIDC && !isSAML && !isOAuth2;
const loader = async () => {
@@ -442,6 +444,7 @@ export default function DetailSettings() {
const sections = [
{
title: t("generalSettings"),
isHidden: isSPIFFE,
panel: (
<FormAccess
role="manage-identity-providers"
@@ -486,6 +489,20 @@ export default function DetailSettings() {
</>
),
},
{
title: t("generalSettings"),
isHidden: !isSPIFFE,
panel: (
<Form
isHorizontal
className="pf-v5-u-py-lg"
onSubmit={handleSubmit(save)}
>
<SpiffeSettings />
<FixedButtonsGroup name="idp-details" isSubmit reset={reset} />
</Form>
),
},
{
title: t("samlSettings"),
isHidden: !isSAML,
@@ -506,6 +523,7 @@ export default function DetailSettings() {
},
{
title: t("advancedSettings"),
isHidden: isSPIFFE,
panel: (
<FormAccess
role="manage-identity-providers"
@@ -557,6 +575,7 @@ export default function DetailSettings() {
</Tab>
<Tab
id="mappers"
isHidden={isSPIFFE}
data-testid="mappers-tab"
title={<TabTitleText>{t("mappers")}</TabTitleText>}
{...mappersTab}

View File

@@ -0,0 +1,35 @@
import { TextControl } from "@keycloak/keycloak-ui-shared";
import { useTranslation } from "react-i18next";
export const SpiffeSettings = () => {
const { t } = useTranslation();
return (
<>
<TextControl
name="alias"
label={t("alias")}
labelIcon={t("aliasHelp")}
rules={{
required: t("required"),
}}
/>
<TextControl
name="config.trustDomain"
label={t("spiffeTrustDomain")}
rules={{
required: t("required"),
}}
/>
<TextControl
name="config.bundleEndpoint"
label={t("spiffeBundleEndpoint")}
rules={{
required: t("required"),
}}
/>
</>
);
};

View File

@@ -3,6 +3,7 @@ import { IdentityProviderRoute } from "./routes/IdentityProvider";
import { IdentityProviderKeycloakOidcRoute } from "./routes/IdentityProviderKeycloakOidc";
import { IdentityProviderOidcRoute } from "./routes/IdentityProviderOidc";
import { IdentityProviderSamlRoute } from "./routes/IdentityProviderSaml";
import { IdentityProviderSpiffeRoute } from "./routes/IdentityProviderSpiffe";
import { IdentityProvidersRoute } from "./routes/IdentityProviders";
import { IdentityProviderAddMapperRoute } from "./routes/AddMapper";
import { IdentityProviderEditMapperRoute } from "./routes/EditMapper";
@@ -15,6 +16,7 @@ const routes: AppRouteObject[] = [
IdentityProvidersRoute,
IdentityProviderOidcRoute,
IdentityProviderSamlRoute,
IdentityProviderSpiffeRoute,
IdentityProviderKeycloakOidcRoute,
IdentityProviderCreateRoute,
IdentityProviderRoute,

View File

@@ -0,0 +1,23 @@
import { lazy } from "react";
import type { Path } from "react-router-dom";
import { generateEncodedPath } from "../../utils/generateEncodedPath";
import type { AppRouteObject } from "../../routes";
export type IdentityProviderSpiffeParams = { realm: string };
const AddSpiffeConnect = lazy(() => import("../add/AddSpiffeConnect"));
export const IdentityProviderSpiffeRoute: AppRouteObject = {
path: "/:realm/identity-providers/spiffe/add",
element: <AddSpiffeConnect />,
breadcrumb: (t) => t("addSpiffeProvider"),
handle: {
access: "manage-identity-providers",
},
};
export const toIdentityProviderSpiffe = (
params: IdentityProviderSpiffeParams,
): Partial<Path> => ({
pathname: generateEncodedPath(IdentityProviderSpiffeRoute.path, params),
});

View File

@@ -43,6 +43,18 @@ export async function createSAMLProvider(
await clickAddButton(page);
}
export async function createSPIFFEProvider(
page: Page,
providerName: string,
trustDomain: string,
bundleEndpoint: string,
) {
await clickProviderCard(page, providerName);
await page.getByTestId("config.trustDomain").fill(trustDomain);
await page.getByTestId("config.bundleEndpoint").fill(bundleEndpoint);
await clickAddButton(page);
}
export async function assertAuthorizationUrl(page: Page) {
await expect(page.getByTestId("config.authorizationUrl")).toHaveValue(
authorizationUrl,

View File

@@ -0,0 +1,34 @@
import { test } from "@playwright/test";
import adminClient from "../utils/AdminClient.ts";
import { login } from "../utils/login.ts";
import { assertNotificationMessage } from "../utils/masthead.ts";
import { goToIdentityProviders } from "../utils/sidebar.ts";
import { clickTableRowItem } from "../utils/table.ts";
import { clickSaveButton, createSPIFFEProvider } from "./main.ts";
test.beforeEach(async ({ page }) => {
await login(page);
await goToIdentityProviders(page);
});
test.afterAll(() => adminClient.deleteIdentityProvider("spiffe"));
test.describe.serial("SPIFFE identity provider test", () => {
test("should create a SPIFFE provider", async ({ page }) => {
await createSPIFFEProvider(page, "spiffe", "mytrust", "https://mytrust");
await assertNotificationMessage(
page,
"Identity provider successfully created",
);
await goToIdentityProviders(page);
await clickTableRowItem(page, "Spiffe");
await page.getByTestId("config.trustDomain").fill("mytrust2");
await page.getByTestId("config.bundleEndpoint").fill("https://mytrust2");
await clickSaveButton(page);
await assertNotificationMessage(page, "Provider successfully updated");
});
});

View File

@@ -60,7 +60,7 @@ async function startServer() {
path.join(SERVER_DIR, `bin/kc${SCRIPT_EXTENSION}`),
[
"start-dev",
`--features="login:v2,account:v3,admin-fine-grained-authz:v2,transient-users,oid4vc-vci,organization,declarative-ui,quick-theme"`,
`--features="login:v2,account:v3,admin-fine-grained-authz:v2,transient-users,oid4vc-vci,organization,declarative-ui,quick-theme,spiffe"`,
...keycloakArgs,
],
{