diff --git a/docs/.images/diagrams/uds-core-operator-authservice-keycloak.svg b/docs/.images/diagrams/uds-core-operator-authservice-keycloak.svg index 04cda0c21c..02c693cef0 100644 --- a/docs/.images/diagrams/uds-core-operator-authservice-keycloak.svg +++ b/docs/.images/diagrams/uds-core-operator-authservice-keycloak.svg @@ -1,4 +1,4 @@ -UDS PackageUDS Operator, Istio Authservice and KeycloakpackageReconciler(UDSPackage)New Package CR gets deployedConfigure Keycloak ClientUse Client Registration ServiceStore Registration Access Token in Pepr StoreConfigure IstioCreate AuthorizationPolicy[JWT]Create RequestAuthenticationCreate AuthorizationPolicy[Request Headers]Is enableAuthserviceSelector presentIs spec.sso presentDoneYesNoYesNo \ No newline at end of file +UDS PackageUDS Operator, Istio Authservice and KeycloakpackageReconciler(UDSPackage)New Package CR gets deployedConfigure Keycloak ClientUse Client Registration ServiceStore Registration Access Token in Pepr StoreConfigure IstioCreate AuthorizationPolicy[JWT]Create RequestAuthenticationCreate AuthorizationPolicy[Request Headers]Is enableAuthserviceSelector presentIs spec.sso presentDoneYesNoYesNoUse Keycloak Admin for ClientsUDS OperatorConfiguration \ No newline at end of file diff --git a/docs/.images/diagrams/uds-core-pepr-operator-flow.drawio b/docs/.images/diagrams/uds-core-pepr-operator-flow.drawio index 5b3e62f6ce..5b20f778c2 100644 --- a/docs/.images/diagrams/uds-core-pepr-operator-flow.drawio +++ b/docs/.images/diagrams/uds-core-pepr-operator-flow.drawio @@ -1,140 +1,161 @@ - + - + - + - + - + - + - + - + - + - + + + + - + - + - + - + - + - + - + - + - + - + - - + + - - + + - - + + - - + + - - + + - - + + - + - + - - + + - - - - - - + + - - + + - + - - + + + + + + + + + + + + + + + + + + + + + + + + @@ -1196,7 +1217,7 @@ - + diff --git a/docs/reference/configuration/Single Sign-On/overview.md b/docs/reference/configuration/Single Sign-On/overview.md index 98ad5c2c14..1fed24ae97 100644 --- a/docs/reference/configuration/Single Sign-On/overview.md +++ b/docs/reference/configuration/Single Sign-On/overview.md @@ -10,7 +10,11 @@ UDS Core automates Keycloak Client configuration, secret management, and advance  -When a new UDS Package CR with the `sso` configuration gets deployed, the UDS Operator creates a new Keycloak Client using the [Dynamic Client Registration](https://www.keycloak.org/securing-apps/client-registration). The Registration Token that is used for updating and removing the newly created Keycloak Client is stored in Pepr Store. Once the Keycloak Client is ready, and the `enableAuthserviceSelector` is defined in the spec, the UDS Operator deploys Istio [Request Authentication](https://istio.io/latest/docs/reference/config/security/request_authentication/) and [AuthorizationPolicy](https://istio.io/latest/docs/reference/config/security/authorization-policy/) for both JWT and Request Headers. Both actions combined enable seamless and transparent application authentication and authorization capabilities. +When a new UDS Package CR with the `sso` configuration gets deployed, the UDS Operator creates a new Keycloak Client. This process happens in one of two modes - using [Dynamic Client Registration](https://www.keycloak.org/securing-apps/client-registration) or [Keycloak Admin endpoint](https://www.keycloak.org/docs-api/latest/rest-api/index.html#_clients) for managing Clients. Depending on the Keycloak Realm configuration, the Operator automatically picks the right mode. In the case of the former mode, the Registration Token that is used for updating and removing the newly created Keycloak Client is stored in Pepr Store. The latter mode reads the Client Secrets from the `keycloak-client-secrets` Kubernetes Secret deployed in `keycloak` namespace. This Secret is managed automatically by the UDS Operator. Once the Keycloak Client is ready, and the `enableAuthserviceSelector` is defined in the spec, the UDS Operator deploys Istio [Request Authentication](https://istio.io/latest/docs/reference/config/security/request_authentication/) and [AuthorizationPolicy](https://istio.io/latest/docs/reference/config/security/authorization-policy/) for both JWT and Request Headers. Both actions combined, enables seamless and transparent application authentication and authorization capabilities. + +## Rotating the UDS Operator Client Secret + +The UDS Operator uses a dedicated Client in Keycloak. In some cases, the Client Secret needs to be rotated. In order to do so, you need to manually modify the `keycloak-client-secrets` Kubernetes Secret in the `keycloak` namespace and delete the `uds-operator` key. The UDS Operator will automatically re-create it. ## User Groups @@ -20,7 +24,7 @@ UDS Core deploys Keycloak which has some preconfigured groups that applications #### Grafana -Grafana [maps the groups](https://github.com/defenseunicorns/uds-core/blob/49cb11a058a9209cee7019fa552b8c0b2ef73368/src/grafana/values/values.yaml#L37) from Keycloak to it's internal `Admin` and `Viewer` groups. +Grafana [maps the groups](https://github.com/defenseunicorns/uds-core/blob/49cb11a058a9209cee7019fa552b8c0b2ef73368/src/grafana/values/values.yaml#L37) from Keycloak to its internal `Admin` and `Viewer` groups. | Keycloak Group | Mapped Grafana Group | |----------------|----------------------| @@ -31,7 +35,7 @@ If a user doesn't belong to either of these Keycloak groups the user will be una #### Neuvector -Neuvector [maps the groups](https://github.com/defenseunicorns/uds-core/blob/main/src/neuvector/chart/templates/uds-package.yaml#L31-L35) from Keycloak to it's internal `admin` and `reader` groups. +Neuvector [maps the groups](https://github.com/defenseunicorns/uds-core/blob/main/src/neuvector/chart/templates/uds-package.yaml#L31-L35) from Keycloak to its internal `admin` and `reader` groups. | Keycloak Group | Mapped Neuvector Group | |----------------|------------------------| diff --git a/pepr.ts b/pepr.ts index 64646428cf..f991c45c59 100644 --- a/pepr.ts +++ b/pepr.ts @@ -14,6 +14,7 @@ import { registerCRDs } from "./src/pepr/operator/crd/register"; import { patches } from "./src/pepr/patches"; import { policies, startExemptionWatch } from "./src/pepr/policies"; import { prometheus } from "./src/pepr/prometheus"; +import { setupKeycloakClientSecret } from "./src/pepr/operator/controllers/keycloak/config"; const log = setupLogger(Component.STARTUP); @@ -23,6 +24,7 @@ const log = setupLogger(Component.STARTUP); // KFC watch for exemptions and update in-memory map await startExemptionWatch(); await setupAuthserviceSecret(); + await setupKeycloakClientSecret(); new PeprModule(cfg, [ // UDS Core Operator operator, diff --git a/src/keycloak/chart/templates/istio-admin.yaml b/src/keycloak/chart/templates/istio-admin.yaml index 612e53d686..c3a66775fd 100644 --- a/src/keycloak/chart/templates/istio-admin.yaml +++ b/src/keycloak/chart/templates/istio-admin.yaml @@ -15,7 +15,7 @@ spec: rules: - to: - operation: - ports: + ports: - "8080" paths: - "/admin*" @@ -24,9 +24,10 @@ spec: - source: notNamespaces: - istio-admin-gateway + - "pepr-system" - to: - operation: - ports: + ports: - "8080" paths: - /metrics* @@ -37,7 +38,7 @@ spec: - monitoring - to: - operation: - ports: + ports: - "8080" paths: # Never allow anonymous client registration except from the pepr-system namespace @@ -45,14 +46,25 @@ spec: - "/realms/{{ .Values.realm }}/clients-registrations/*" from: - source: - notNamespaces: + notNamespaces: - "pepr-system" + - to: + - operation: + ports: + - "8080" + paths: + - "/admin/realms/{{ .Values.realm }}/clients" + from: + - source: + notNamespaces: + - pepr-system + - istio-admin-gateway - when: - key: request.headers[istio-mtls-client-certificate] values: ["*"] to: - operation: - ports: + ports: - "8080" from: - source: diff --git a/src/keycloak/chart/templates/statefulset.yaml b/src/keycloak/chart/templates/statefulset.yaml index d11ae1b2ef..733d4d7dd6 100644 --- a/src/keycloak/chart/templates/statefulset.yaml +++ b/src/keycloak/chart/templates/statefulset.yaml @@ -231,6 +231,8 @@ spec: - name: conf mountPath: /opt/keycloak/conf readOnly: true + - name: client-secrets + mountPath: /var/run/secrets/uds/client-secrets enableServiceLinks: {{ .Values.enableServiceLinks }} restartPolicy: {{ .Values.restartPolicy }} {{- with .Values.nodeSelector }} @@ -254,6 +256,9 @@ spec: {{- end }} terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }} volumes: + - name: client-secrets + secret: + secretName: {{ include "keycloak.fullname" . }}-client-secrets - name: providers {{- if .Values.persistence.providers.enabled }} persistentVolumeClaim: diff --git a/src/pepr/operator/README.md b/src/pepr/operator/README.md index bc6079a8de..d507158260 100644 --- a/src/pepr/operator/README.md +++ b/src/pepr/operator/README.md @@ -155,6 +155,20 @@ spec: bearer_only: clientField(bearerOnly) ``` +### Controlling how UDS Operator interacts with Keycloak + +The UDS Operator can interact with Keycloak in two primary ways: using the Dynamic Client Registration or Client Credentials Grant. The method used is determined automatically or by specifying the environment variable `PEPR_KEYCLOAK_CLIENT_STRATEGY`. + +Dynamic Client Registration allows the UDS Operator to dynamically register new Clients in the Keycloak server. A successful registration flow results in the Registration Token to be stored in Pepr store, which can be later used for modifying and removing the client. + +Client Credentials Grant uses the OAuth 2.0 Client Credentials Grant to authenticate against the `uds-operator` client defined in Keycloak. This special client has a limited control over managing Keycloak Clients for the UDS Operator. + +The `PEPR_KEYCLOAK_CLIENT_STRATEGY` can be set to one of the following values: + +* `auto` (default): The UDS Operator will automatically determine the best strategy to use based on the Keycloak server configuration +* `dynamic_client_registration`: The UDS Operator will use the Dynamic Client Registration strategy +* `client_credentials`: The UDS Operator will use the Client Credentials Grant strategy + ### Key Files and Folders ```bash diff --git a/src/pepr/operator/controllers/keycloak/client-secret-sync.spec.ts b/src/pepr/operator/controllers/keycloak/client-secret-sync.spec.ts new file mode 100644 index 0000000000..e642e71bc8 --- /dev/null +++ b/src/pepr/operator/controllers/keycloak/client-secret-sync.spec.ts @@ -0,0 +1,61 @@ +/** + * Copyright 2025 Defense Unicorns + * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + */ + +import { describe, expect, it } from "@jest/globals"; +import { + updateKeycloakClientsSecret, + KEYCLOAK_CLIENT_SECRET_KEY, + KEYCLOAK_CLIENTS_SECRET_NAME, + KEYCLOAK_CLIENTS_SECRET_NAMESPACE, +} from "./client-secret-sync"; + +interface Config { + metadata: { + name: string; + namespace: string; + }; + data: { + [key: string]: string; + }; +} + +const createConfig = (data: { [key: string]: string } = {}): Config => ({ + metadata: { + name: KEYCLOAK_CLIENTS_SECRET_NAME, + namespace: KEYCLOAK_CLIENTS_SECRET_NAMESPACE, + }, + data, +}); + +describe("updateKeycloakClientsSecret Tests", () => { + it("should generate a new secret if KEYCLOAK_CLIENT_SECRET_KEY does not exist", async () => { + const config = createConfig(); + + await updateKeycloakClientsSecret(config); + + expect(config.data[KEYCLOAK_CLIENT_SECRET_KEY]).not.toBe(""); + }); + + it("should generate a new secret if forceRotation is true", async () => { + const config = createConfig({ + [KEYCLOAK_CLIENT_SECRET_KEY]: "existing-secret", + }); + + await updateKeycloakClientsSecret(config, true); + + expect(config.data[KEYCLOAK_CLIENT_SECRET_KEY]).not.toBe(""); + expect(config.data[KEYCLOAK_CLIENT_SECRET_KEY]).not.toBe("existing-secret"); + }); + + it("should not generate a new secret if KEYCLOAK_CLIENT_SECRET_KEY exists and forceRotation is false", async () => { + const config = createConfig({ + [KEYCLOAK_CLIENT_SECRET_KEY]: "existing-secret", + }); + + await updateKeycloakClientsSecret(config); + + expect(config.data[KEYCLOAK_CLIENT_SECRET_KEY]).toBe("existing-secret"); + }); +}); diff --git a/src/pepr/operator/controllers/keycloak/client-secret-sync.ts b/src/pepr/operator/controllers/keycloak/client-secret-sync.ts new file mode 100644 index 0000000000..b9a14fb40d --- /dev/null +++ b/src/pepr/operator/controllers/keycloak/client-secret-sync.ts @@ -0,0 +1,39 @@ +/** + * Copyright 2025 Defense Unicorns + * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + */ + +import { K8s, kind } from "pepr"; +import { v4 as uuidv4 } from "uuid"; +import { Component, setupLogger } from "../../../logger"; + +export const KEYCLOAK_CLIENT_SECRET_KEY = "uds-operator"; + +export const KEYCLOAK_CLIENTS_SECRET_NAMESPACE = "keycloak"; +export const KEYCLOAK_CLIENTS_SECRET_NAME = "keycloak-client-secrets"; + +const log = setupLogger(Component.OPERATOR_CONFIG); + +/** + * Updates the Keycloak client secret in the provided config. + * If the secret does not exist or forceRotation is true, a new secret is generated. + * The secret is then applied to the Kubernetes cluster. + * + * @param {kind.Secret} config - The Kubernetes Secret object to update. + * @param {boolean} [forceRotation=false] - Whether to force rotation of the secret. + */ +export async function updateKeycloakClientsSecret( + config: kind.Secret, + forceRotation: boolean = false, +) { + config.data = config.data || {}; + + // This might be a bug but it seems Zarf adds managedFields, which is prohibited in Secrets. + delete config.metadata?.managedFields; + + if (!config.data[KEYCLOAK_CLIENT_SECRET_KEY] || forceRotation) { + log.info("Generating new Keycloak client secret"); + config.data[KEYCLOAK_CLIENT_SECRET_KEY] = Buffer.from(uuidv4()).toString("base64"); + await K8s(kind.Secret).Apply(config); + } +} diff --git a/src/pepr/operator/controllers/keycloak/client-sync.ts b/src/pepr/operator/controllers/keycloak/client-sync.ts index 9fa31e4b25..83133614d4 100644 --- a/src/pepr/operator/controllers/keycloak/client-sync.ts +++ b/src/pepr/operator/controllers/keycloak/client-sync.ts @@ -6,21 +6,14 @@ import { fetch, K8s, kind } from "pepr"; import { Component, setupLogger } from "../../../logger"; -import { Store } from "../../common"; import { Sso, UDSPackage } from "../../crd"; -import { getOwnerRef, purgeOrphans, retryWithDelay, sanitizeResourceName } from "../utils"; +import { getOwnerRef, purgeOrphans, sanitizeResourceName } from "../utils"; import { Client, clientKeys } from "./types"; +import { createOrUpdateClient, deleteClient } from "./clients/keycloak-client"; -let apiURL = - "http://keycloak-http.keycloak.svc.cluster.local:8080/realms/uds/clients-registrations/default"; const samlDescriptorUrl = "http://keycloak-http.keycloak.svc.cluster.local:8080/realms/uds/protocol/saml/descriptor"; -// Support dev mode with port-forwarded keycloak svc -if (process.env.PEPR_MODE === "dev") { - apiURL = "http://localhost:8080/realms/uds/clients-registrations/default"; -} - // Template regex to match clientField() references, see https://regex101.com/r/e41Dsk/3 for details const secretTemplateRegex = new RegExp( 'clientField\\(([a-zA-Z]+)\\)(?:\\["?([\\w]+)"?\\]|(\\.json\\(\\)))?', @@ -86,13 +79,13 @@ export async function purgeSSOClients(pkg: UDSPackage, newClients: string[] = [] const currentClients = pkg.status?.ssoClients || []; const toRemove = currentClients.filter(client => !newClients.includes(client)); for (const ref of toRemove) { - const storeKey = `sso-client-${ref}`; - const token = Store.getItem(storeKey); - if (token) { - await apiCall({ clientId: ref }, "DELETE", token); - await Store.removeItemAndWait(storeKey); - } else { - log.warn(pkg.metadata, `Failed to remove client ${ref}, token not found`); + try { + await deleteClient({ clientId: ref }); + } catch (err) { + log.warn( + pkg.metadata, + `Failed to remove client ${ref}, package ${pkg.metadata?.namespace}/${pkg.metadata?.name}. Error: ${err.message}`, + ); throw new Error(`Failed to remove client ${ref}, token not found`); } } @@ -138,31 +131,22 @@ async function syncClient( const name = `sso-client-${clientReq.clientId}`; let client = convertSsoToClient(clientReq); - // Get keycloak client token from the store if this is an existing client - const token = Store.getItem(name); - try { - // If an existing client is found, use the token to update the client - if (token && !isRetry) { - log.debug(pkg.metadata, `Found existing token for ${client.clientId}`); - client = await apiCall(client, "PUT", token); - } else { - log.debug(pkg.metadata, `Creating new client for ${client.clientId}`); - client = await apiCall(client); - } + client = await createOrUpdateClient(client, isRetry); } catch (err) { const msg = `Failed to process Keycloak request for client '${client.clientId}', package ` + `${pkg.metadata?.namespace}/${pkg.metadata?.name}. Error: ${err.message}`; // Throw the error if this is the retry or was an initial client creation attempt - if (isRetry || !token) { + // if (isRetry || !token) { + if (isRetry) { log.error(`${msg}, retry failed.`); // Throw the original error captured from the first attempt throw new Error(msg); } else { // Retry the request without the token in case we have a bad token stored - log.error(msg); + log.error(`${msg}, retrying...`); try { return await syncClient(clientReq, pkg, true); @@ -178,18 +162,6 @@ async function syncClient( } } - // Write the new token to the store - try { - await retryWithDelay(async function setStoreToken() { - return Store.setItemAndWait(name, client.registrationAccessToken!); - }, log); - } catch { - throw Error( - `Failed to set token in store for client '${client.clientId}', package ` + - `${pkg.metadata?.namespace}/${pkg.metadata?.name}`, - ); - } - // Remove the registrationAccessToken from the client object to avoid problems (one-time use token) delete client.registrationAccessToken; @@ -221,43 +193,6 @@ async function syncClient( return client; } -async function apiCall(client: Partial, method = "POST", authToken = "") { - const req = { - body: JSON.stringify(client) as string | undefined, - method, - headers: { - "Content-Type": "application/json", - } as Record, - }; - - let url = apiURL; - - // When not creating a new client, add the client ID and registrationAccessToken - if (authToken) { - req.headers.Authorization = `Bearer ${authToken}`; - // Ensure that we URI encode the clientId in the request URL - url += `/${encodeURIComponent(client.clientId!)}`; - } - - // Remove the body for DELETE requests - if (method === "DELETE" || method === "GET") { - delete req.body; - } - - // Make the request - const resp = await fetch(url, req); - - if (!resp.ok) { - if (resp.data) { - throw new Error(`${JSON.stringify(resp.statusText)}, ${JSON.stringify(resp.data)}`); - } else { - throw new Error(`${JSON.stringify(resp.statusText)}`); - } - } - - return resp.data; -} - export function generateSecretData(client: Client, secretTemplate?: { [key: string]: string }) { if (secretTemplate) { log.debug(`Using secret template for client: ${client.clientId}`); diff --git a/src/pepr/operator/controllers/keycloak/clients/client-credentials.ts b/src/pepr/operator/controllers/keycloak/clients/client-credentials.ts new file mode 100644 index 0000000000..e9d4c61451 --- /dev/null +++ b/src/pepr/operator/controllers/keycloak/clients/client-credentials.ts @@ -0,0 +1,153 @@ +/** + * Copyright 2025 Defense Unicorns + * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + */ + +import { fetch, K8s, kind } from "pepr"; +import { Client } from "../types"; +import { baseUrl, log, throwErrorIfNeeded } from "./common"; + +export interface ClientWithId extends Client { + id: string; +} + +export interface KeycloakAccessTokenResponse { + access_token: string; +} + +export function parseKeycloakToken(token: string) { + return JSON.parse(Buffer.from(token.split(".")[1], "base64").toString()) as KeycloakToken; +} + +const clientsAdminUrl = `${baseUrl}/admin/realms/uds/clients`; +const clientCredentialsUrl = `${baseUrl}/realms/uds/protocol/openid-connect/token`; +const SECRET_NAMESPACE = "keycloak"; +const SECRET_NAME = "keycloak-client-secrets"; +const UDS_OPERATOR_CLIENT_ID = "uds-operator"; +let cachedToken: string | null = null; + +export interface KeycloakToken { + exp: number; + resource_access: { + "realm-management": { + roles: string[]; + }; + [key: string]: unknown; + }; + + [key: string]: unknown; +} + +export function resetCachedToken() { + cachedToken = null; +} + +export async function credentialsGetAccessToken() { + if (cachedToken) { + try { + const jwt = parseKeycloakToken(cachedToken); + if (jwt.exp && jwt.exp > Math.floor(Date.now() / 1000) + 5) return cachedToken; + } catch (e) { + log.error(e, "Failed to parse cached token"); + cachedToken = null; + } + } + + const secret = await K8s(kind.Secret).InNamespace(SECRET_NAMESPACE).Get(SECRET_NAME); + if (!secret) throw new Error("Missing secret"); + const encodedSecret = secret.data?.[UDS_OPERATOR_CLIENT_ID]; + if (!encodedSecret) throw new Error("Missing client secret"); + + const clientSecret = Buffer.from(encodedSecret, "base64").toString("utf-8"); + const params = new URLSearchParams(); + params.append("grant_type", "client_credentials"); + params.append("client_id", UDS_OPERATOR_CLIENT_ID); + params.append("client_secret", clientSecret); + + const response = await fetch(clientCredentialsUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }); + await throwErrorIfNeeded(response); + cachedToken = response.data.access_token; + return cachedToken; +} + +export async function credentialsCreateOrUpdate(client: Partial) { + log.info(`credentialsCreateOrUpdate: creating/updating client ${JSON.stringify(client)}`); + const existingClient = await credentialsGet(client); + if (existingClient) { + return credentialsUpdate(client); + } else { + return credentialsCreate(client); + } +} + +export async function credentialsCreate(client: Partial) { + log.info(`credentialsCreate: creating client ${JSON.stringify(client)}`); + const token = await credentialsGetAccessToken(); + const response = await fetch(clientsAdminUrl, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify(client), + }); + await throwErrorIfNeeded(response, () => { + resetCachedToken(); + }); + return credentialsGet(client); +} + +export async function credentialsGet(client: Partial) { + log.info(`credentialsGet: retrieving client ${JSON.stringify(client)}`); + const token = await credentialsGetAccessToken(); + const url = `${clientsAdminUrl}?clientId=${encodeURIComponent(client.clientId!)}`; + // There's no Client GET REST endpoint that obtains a client based on client_id (the logical client name, like uds-operator). + // All Admin REST endpoints for client operator on the database Client ID, which is a UUID. The only interface that allows to + // obtain the Client using the client_id is the collection interface which returns a singular collection with + // the Client in it. + const response = await fetch(url, { + method: "GET", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + }); + await throwErrorIfNeeded(response, () => { + resetCachedToken(); + }); + return response.data[0]; +} + +export async function credentialsUpdate(client: Partial) { + log.info(`credentialsUpdate: updating client ${JSON.stringify(client)}`); + const token = await credentialsGetAccessToken(); + const existing = await credentialsGet(client); + if (!existing || !existing.id) { + throw new Error(`Failed to retrieve existing client, ${client.clientId}`); + } + const url = `${clientsAdminUrl}/${encodeURIComponent(existing.id)}`; + const response = await fetch(url, { + method: "PUT", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify(client), + }); + await throwErrorIfNeeded(response, () => { + resetCachedToken(); + }); + return credentialsGet(client); +} + +export async function credentialsDelete(client: Partial) { + log.info(`credentialsDelete: deleting client ${JSON.stringify(client)}`); + const token = await credentialsGetAccessToken(); + const existing = await credentialsGet(client); + if (!existing || !existing.id) { + throw new Error(`Failed to retrieve existing client, ${client.clientId}`); + } + const url = `${clientsAdminUrl}/${encodeURIComponent(existing.id)}`; + const response = await fetch(url, { + method: "DELETE", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + }); + await throwErrorIfNeeded(response, () => { + resetCachedToken(); + }); +} diff --git a/src/pepr/operator/controllers/keycloak/clients/common.ts b/src/pepr/operator/controllers/keycloak/clients/common.ts new file mode 100644 index 0000000000..b3c01fa071 --- /dev/null +++ b/src/pepr/operator/controllers/keycloak/clients/common.ts @@ -0,0 +1,32 @@ +/** + * Copyright 2025 Defense Unicorns + * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + */ + +import { Component, setupLogger } from "../../../../logger"; + +export let baseUrl = "http://keycloak-http.keycloak.svc.cluster.local:8080"; +// Support dev mode with port-forwarded keycloak svc +if (process.env.PEPR_MODE === "dev") { + baseUrl = "http://localhost:8080"; +} + +export const log = setupLogger(Component.OPERATOR_KEYCLOAK); + +export interface RestResponse { + ok: boolean; + status: number; + statusText: string; + data: unknown; +} + +export async function throwErrorIfNeeded(response: RestResponse, onError?: (error: Error) => void) { + if (!response.ok) { + const { status, statusText, data } = response; + const err = new Error(`${status}, ${statusText}, ${data ? JSON.stringify(data) : ""}`); + if (onError) { + onError(err); + } + throw err; + } +} diff --git a/src/pepr/operator/controllers/keycloak/clients/dynamic-client-registration.spec.ts b/src/pepr/operator/controllers/keycloak/clients/dynamic-client-registration.spec.ts new file mode 100644 index 0000000000..cf0a1b0c6b --- /dev/null +++ b/src/pepr/operator/controllers/keycloak/clients/dynamic-client-registration.spec.ts @@ -0,0 +1,22 @@ +/** + * Copyright 2025 Defense Unicorns + * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + */ + +import { addRequiredAttributesToClient } from "./dynamic-client-registration"; + +describe("addRequiredAttributesToClient", () => { + it("should add 'created-by' attribute if attributes are present", async () => { + const client = { attributes: { existing: "value" } }; + const result = addRequiredAttributesToClient(client); + expect(result.attributes).toEqual({ existing: "value", "created-by": "uds-operator" }); + }); + + it("should add 'created-by' attribute if attributes are not present", async () => { + const client = { + attributes: undefined, + }; + const result = addRequiredAttributesToClient(client); + expect(result.attributes).toEqual({ "created-by": "uds-operator" }); + }); +}); diff --git a/src/pepr/operator/controllers/keycloak/clients/dynamic-client-registration.ts b/src/pepr/operator/controllers/keycloak/clients/dynamic-client-registration.ts new file mode 100644 index 0000000000..6faf64a252 --- /dev/null +++ b/src/pepr/operator/controllers/keycloak/clients/dynamic-client-registration.ts @@ -0,0 +1,99 @@ +/** + * Copyright 2025 Defense Unicorns + * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + */ + +import { fetch } from "pepr"; +import { Store } from "../../../common"; +import { retryWithDelay } from "../../utils"; +import { Client } from "../types"; +import { baseUrl, log, throwErrorIfNeeded } from "./common"; + +const dynamicUrl = `${baseUrl}/realms/uds/clients-registrations/default`; + +/** + * This Client Attribute is checked by the UDSClientPolicyPermissionsExecutor from the UDS Identity Config and ensures + * the UDS Operator can access the Client + */ +const client_policy_required_attribute_name = "created-by"; + +/** + * This Client Attribute is checked by the UDSClientPolicyPermissionsExecutor from the UDS Identity Config and ensures + * the UDS Operator can access the Client + */ +const client_policy_required_attribute_value = "uds-operator"; + +export async function dynamicCreateOrUpdate(client: Partial, isRetry: boolean = false) { + log.info(`dynamicCreateOrUpdate: creating/updating client ${JSON.stringify(client)}`); + const token = Store.getItem(`sso-client-${client.clientId}`); + // The additional check for `isRetry` is address a situation, where a Client has been deleted in Keycloak + // but Peprs Store update failed. In this case, in a retry loop, we try to create a Client again and the worst + // case we'll get an HTTP 409 Conflict error. + if (token && !isRetry) { + return dynamicUpdate(client); + } + return dynamicCreate(client); +} + +export async function dynamicCreate(client: Partial) { + log.info(`dynamicCreate: creating client ${JSON.stringify(client)}`); + client = addRequiredAttributesToClient(client); + const response = await fetch(dynamicUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(client), + }); + await throwErrorIfNeeded(response); + await updateDynamicToken(client.clientId!, response.data.registrationAccessToken); + return response.data; +} + +export async function dynamicUpdate(client: Partial) { + log.info(`dynamicUpdate: updating client ${JSON.stringify(client)}`); + const token = Store.getItem(`sso-client-${client.clientId}`); + const url = `${dynamicUrl}/${encodeURIComponent(client.clientId!)}`; + client = addRequiredAttributesToClient(client); + const response = await fetch(url, { + method: "PUT", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify(client), + }); + await throwErrorIfNeeded(response); + await updateDynamicToken(client.clientId!, response.data.registrationAccessToken); + return response.data; +} + +export async function dynamicDelete(client: Partial) { + log.info(`dynamicDelete: deleting client ${JSON.stringify(client)}`); + const token = Store.getItem(`sso-client-${client.clientId}`); + const url = `${dynamicUrl}/${encodeURIComponent(client.clientId!)}`; + const response = await fetch(url, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + await throwErrorIfNeeded(response); + await updateDynamicToken(client.clientId!, undefined); +} + +async function updateDynamicToken(clientId: string, token: string | undefined) { + const storeKey = `sso-client-${clientId}`; + await retryWithDelay(async () => { + if (token === undefined) { + await Store.removeItemAndWait(storeKey); + } else { + await Store.setItemAndWait(storeKey, token); + } + }, log); +} + +export function addRequiredAttributesToClient(client: Partial) { + if (client.attributes) { + client.attributes[client_policy_required_attribute_name] = + client_policy_required_attribute_value; + } else { + client.attributes = { + [client_policy_required_attribute_name]: client_policy_required_attribute_value, + }; + } + return client; +} diff --git a/src/pepr/operator/controllers/keycloak/clients/keycloak-client.spec.ts b/src/pepr/operator/controllers/keycloak/clients/keycloak-client.spec.ts new file mode 100644 index 0000000000..36ff7fc7cd --- /dev/null +++ b/src/pepr/operator/controllers/keycloak/clients/keycloak-client.spec.ts @@ -0,0 +1,71 @@ +/** + * Copyright 2025 Defense Unicorns + * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + */ + +import { ClientStrategy, createOrUpdateClient, getStrategy } from "./keycloak-client"; +import { credentialsGetAccessToken } from "./client-credentials"; +import { dynamicCreateOrUpdate } from "./dynamic-client-registration"; + +jest.mock("./dynamic-client-registration"); +jest.mock("./client-credentials"); +jest.mock("./common"); + +describe("Test picking proper strategy", () => { + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.PEPR_KEYCLOAK_CLIENT_STRATEGY; + }); + + it('should return "client_credentials" if PEPR_KEYCLOAK_CLIENT_STRATEGY is set to "client_credentials"', async () => { + process.env.PEPR_KEYCLOAK_CLIENT_STRATEGY = "client_credentials"; + const strategy = await getStrategy(); + expect(strategy).toBe(ClientStrategy.CLIENT_CREDENTIALS); + }); + + it('should return "client_credentials" if PEPR_KEYCLOAK_CLIENT_STRATEGY is set to "auto" and credentialsGetAccessToken succeeds', async () => { + process.env.PEPR_KEYCLOAK_CLIENT_STRATEGY = "auto"; + (credentialsGetAccessToken as jest.Mock).mockResolvedValue("token"); + const strategy = await getStrategy(); + expect(strategy).toBe(ClientStrategy.CLIENT_CREDENTIALS); + }); + + it('should return "dynamic" if PEPR_KEYCLOAK_CLIENT_STRATEGY is set to "auto" and credentialsGetAccessToken fails', async () => { + process.env.PEPR_KEYCLOAK_CLIENT_STRATEGY = "auto"; + (credentialsGetAccessToken as jest.Mock).mockRejectedValue(new Error("error")); + const strategy = await getStrategy(); + expect(strategy).toBe(ClientStrategy.DYNAMIC_CLIENT_REGISTRATION); + }); + + it('should return "dynamic" if PEPR_KEYCLOAK_CLIENT_STRATEGY is set to an invalid value', async () => { + process.env.PEPR_KEYCLOAK_CLIENT_STRATEGY = "invalid_value"; + const strategy = await getStrategy(); + expect(strategy).toBe(ClientStrategy.DYNAMIC_CLIENT_REGISTRATION); + }); + + it('should return "client_credentials" if PEPR_KEYCLOAK_CLIENT_STRATEGY is not set and credentialsGetAccessToken succeeds', async () => { + (credentialsGetAccessToken as jest.Mock).mockResolvedValue("token"); + const strategy = await getStrategy(); + expect(strategy).toBe("client_credentials"); + }); + + it('should return "dynamic" if PEPR_KEYCLOAK_CLIENT_STRATEGY is not set and credentialsGetAccessToken fails', async () => { + (credentialsGetAccessToken as jest.Mock).mockRejectedValue(new Error("error")); + const strategy = await getStrategy(); + expect(strategy).toBe(ClientStrategy.DYNAMIC_CLIENT_REGISTRATION); + }); +}); + +describe("Test createOrUpdateClient function", () => { + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.PEPR_KEYCLOAK_CLIENT_STRATEGY; + }); + + it("should call dynamicCreateOrUpdate with isRetry set to true", async () => { + process.env.PEPR_KEYCLOAK_CLIENT_STRATEGY = "dynamic_client_registration"; + const client = { clientId: "test-client" }; + await createOrUpdateClient(client, true); + expect(dynamicCreateOrUpdate).toHaveBeenCalledWith(client, true); + }); +}); diff --git a/src/pepr/operator/controllers/keycloak/clients/keycloak-client.ts b/src/pepr/operator/controllers/keycloak/clients/keycloak-client.ts new file mode 100644 index 0000000000..bd3145a901 --- /dev/null +++ b/src/pepr/operator/controllers/keycloak/clients/keycloak-client.ts @@ -0,0 +1,59 @@ +/** + * Copyright 2025 Defense Unicorns + * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + */ + +import { Client } from "../types"; +import { + credentialsCreateOrUpdate, + credentialsDelete, + credentialsGetAccessToken, +} from "./client-credentials"; +import { dynamicCreateOrUpdate, dynamicDelete } from "./dynamic-client-registration"; +import { log } from "./common"; + +export enum ClientStrategy { + AUTO = "auto", + CLIENT_CREDENTIALS = "client_credentials", + DYNAMIC_CLIENT_REGISTRATION = "dynamic_client_registration", +} + +export async function getStrategy() { + const strategy = process.env.PEPR_KEYCLOAK_CLIENT_STRATEGY || ClientStrategy.AUTO; + switch (strategy) { + case ClientStrategy.CLIENT_CREDENTIALS: + log.debug("Using Client Credentials strategy"); + return ClientStrategy.CLIENT_CREDENTIALS; + case ClientStrategy.AUTO: + try { + log.debug("Probing Client Credentials strategy"); + await credentialsGetAccessToken(); + log.debug("Using Client Credentials strategy"); + return ClientStrategy.CLIENT_CREDENTIALS; + } catch { + log.warn("Cannot use Client Credentials, falling back to dynamic registration"); + return ClientStrategy.DYNAMIC_CLIENT_REGISTRATION; + } + default: + log.warn( + `Invalid ${process.env.PEPR_KEYCLOAK_CLIENT_STRATEGY} parameter value, falling back to dynamic registration`, + ); + return ClientStrategy.DYNAMIC_CLIENT_REGISTRATION; + } +} + +export async function createOrUpdateClient(client: Partial, isRetry: boolean) { + const strategy = await getStrategy(); + if (strategy === ClientStrategy.CLIENT_CREDENTIALS) { + return credentialsCreateOrUpdate(client); + } + return dynamicCreateOrUpdate(client, isRetry); +} + +export async function deleteClient(client: Partial) { + const strategy = await getStrategy(); + if (strategy === ClientStrategy.CLIENT_CREDENTIALS) { + return credentialsDelete(client); + } + return dynamicDelete(client); +} diff --git a/src/pepr/operator/controllers/keycloak/config.ts b/src/pepr/operator/controllers/keycloak/config.ts new file mode 100644 index 0000000000..14eb9497bc --- /dev/null +++ b/src/pepr/operator/controllers/keycloak/config.ts @@ -0,0 +1,48 @@ +/** + * Copyright 2024 Defense Unicorns + * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + */ + +import { K8s, kind } from "pepr"; +import { + KEYCLOAK_CLIENTS_SECRET_NAME, + KEYCLOAK_CLIENTS_SECRET_NAMESPACE, + updateKeycloakClientsSecret, +} from "./client-secret-sync"; +import { Component, setupLogger } from "../../../logger"; + +export const log = setupLogger(Component.OPERATOR_KEYCLOAK); + +export async function setupKeycloakClientSecret() { + if (process.env.PEPR_WATCH_MODE === "true" || process.env.PEPR_MODE === "dev") { + // Ensure the namespace exists in the Kubernetes cluster + await K8s(kind.Namespace).Apply({ + metadata: { + name: KEYCLOAK_CLIENTS_SECRET_NAMESPACE, + }, + }); + + // Create the secret if it doesn't exist + try { + await K8s(kind.Secret) + .InNamespace(KEYCLOAK_CLIENTS_SECRET_NAMESPACE) + .Get(KEYCLOAK_CLIENTS_SECRET_NAME); + log.info(`Keycloak Clients Secret exists, skipping creation`); + } catch { + log.info("Keycloak Clients Secret does not exist, creating it"); + try { + const secret = { + metadata: { + namespace: KEYCLOAK_CLIENTS_SECRET_NAMESPACE, + name: KEYCLOAK_CLIENTS_SECRET_NAME, + }, + type: "Opaque", + }; + await updateKeycloakClientsSecret(secret, false); + } catch (err) { + log.error(err, "Failed to create Keycloak Clients Secret"); + throw err; + } + } + } +} diff --git a/src/pepr/operator/index.ts b/src/pepr/operator/index.ts index 1b6e17f9bd..77ca558283 100644 --- a/src/pepr/operator/index.ts +++ b/src/pepr/operator/index.ts @@ -31,6 +31,11 @@ import { Component, setupLogger } from "../logger"; import { updateUDSConfig } from "./controllers/config/config"; import { exemptValidator } from "./crd/validators/exempt-validator"; import { packageFinalizer, packageReconciler } from "./reconcilers/package-reconciler"; +import { + KEYCLOAK_CLIENTS_SECRET_NAME, + KEYCLOAK_CLIENTS_SECRET_NAMESPACE, + updateKeycloakClientsSecret, +} from "./controllers/keycloak/client-secret-sync"; // Export the operator capability for registration in the root pepr.ts export { operator } from "./common"; @@ -114,3 +119,10 @@ When(a.Secret) .InNamespace("pepr-system") .WithName("uds-operator-config") .Reconcile(updateUDSConfig); + +// Watch the Kubernetes Clients Secret +When(a.Secret) + .IsCreatedOrUpdated() + .InNamespace(KEYCLOAK_CLIENTS_SECRET_NAMESPACE) + .WithName(KEYCLOAK_CLIENTS_SECRET_NAME) + .Reconcile(s => updateKeycloakClientsSecret(s, false)); diff --git a/src/pepr/uds-operator-config/values.yaml b/src/pepr/uds-operator-config/values.yaml index 24df6a49ea..1b4c87495a 100644 --- a/src/pepr/uds-operator-config/values.yaml +++ b/src/pepr/uds-operator-config/values.yaml @@ -17,3 +17,5 @@ operator: PEPR_RELIST_INTERVAL_SECONDS: "600" # Configure Pepr reconcile strategy to have separate queues for faster reconciliation PEPR_RECONCILE_STRATEGY: "kindNsName" + # Keycloak Client Mode. Possible values: "dynamic_client_registration", "client_credentials" and "auto" + PEPR_KEYCLOAK_CLIENT_STRATEGY: "auto"