Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: introduce sso secret templating #276

Merged
merged 11 commits into from
Mar 22, 2024
278 changes: 104 additions & 174 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"name": "UDS Core",
"uuid": "uds-core",
"onError": "reject",
"logLevel": "debug",
"alwaysIgnore": {
"namespaces": [
"uds-dev-stack",
Expand All @@ -33,7 +34,7 @@
"k3d-setup": "k3d cluster delete pepr-dev && k3d cluster create pepr-dev --k3s-arg '--debug@server:0'"
},
"dependencies": {
"pepr": "0.28.3"
"pepr": "0.28.5"
},
"devDependencies": {
"@jest/globals": "29.7.0",
Expand Down
65 changes: 63 additions & 2 deletions src/pepr/operator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
The UDS Operator manages the lifecycle of UDS Package CRs and their corresponding resources (e.g. NetworkPolicies, Istio VirtualServices, etc.) as well UDS Exemption CRs. The operator uses [Pepr](https://pepr.dev) to bind the watch operations to the enqueue and reconciler. The operator is responsible for:

#### Package

- enabling Istio sidecar injection in namespaces where the CR is deployed
- establishing default-deny ingress/egress network policies
- creating a layered allow-list based approach on top of the default deny network policies including some basic defaults such as Istio requirements and DNS egress
- providing targeted remote endpoints network policies such as `KubeAPI` and `CloudMetadata` to make policies more DRY and provide dynamic bindings where a static definition is not possible
- creating Istio Virtual Services & related ingress gateway network policies

#### Exemption

- allowing exemption custom resources only in the `uds-policy-exemptions` namespace unless configured to allow in all namespaces (see [configuring policy exemptions](../../../docs/CONFIGURE_POLICY_EXEMPTIONS.md))
- updating the policies Pepr store with registered exemptions

Expand All @@ -23,6 +25,7 @@ metadata:
namespace: grafana
spec:
network:
# Expose rules generate Istio VirtualServices and related network policies
expose:
- service: grafana
selector:
Expand All @@ -32,6 +35,7 @@ spec:
port: 80
targetPort: 3000

# Allow rules generate NetworkPolicies
allow:
- direction: Egress
selector:
Expand All @@ -44,6 +48,13 @@ spec:
app.kubernetes.io/name: tempo
port: 9411
description: "Tempo"

# SSO allows for the creation of Keycloak clients and with automatic secret generation
sso:
- name: Grafana Dashboard
clientId: uds-core-admin-grafana
redirectUris:
- "https://grafana.admin.uds.dev/login/generic_oauth"
```

### Example UDS Exemption CR
Expand All @@ -66,7 +77,7 @@ spec:
matcher:
namespace: neuvector
name: "^neuvector-enforcer-pod.*"

- policies:
- DisallowPrivileged
- RequireNonRootUser
Expand All @@ -81,7 +92,56 @@ spec:
- DropAllCapabilities
matcher:
namespace: neuvector
name: "^neuvector-prometheus-exporter-pod.*"
name: "^neuvector-prometheus-exporter-pod.*"
```

### Example UDS Package CR with SSO Templating

By default UDS generates a secret for the SSO client with all the contents of the client as an opaque secret such that each key is it's own env variable or file (depending on how you mount the secret). If you need to customize how the secret is rendered, you can perform some basic templating with the `secretTemplate` property. Below are some examples of this usage. You can also see how templating works via this regex site: https://regex101.com/r/e41Dsk/3.

```yaml
apiVersion: uds.dev/v1alpha1
kind: Package
metadata:
name: grafana
namespace: grafana
spec:
sso:
- name: My Keycloak Client
clientId: demo-client
redirectUris:
- "https://demo.uds.dev/login"
# Customize the name of the generated secret
secretName: my-cool-auth-client
secretTemplate:
# Raw text examples
rawTextClientId: "clientField(clientId)"
rawTextClientSecret: "clientField(secret)"

# JSON example
auth.json: |
{
"client_id": "clientField(clientId)",
"client_secret": "clientField(secret)",
"defaultScopes": clientField(defaultClientScopes).json(),
"redirect_uri": "clientField(redirectUris)[0]",
"bearerOnly": clientField(bearerOnly),
}

# Properties example
auth.properties: |
client-id=clientField(clientId)
client-secret=clientField(secret)
default-scopes=clientField(defaultClientScopes)
redirect-uri=clientField(redirectUris)[0]

# YAML example (uses JSON for the defaultScopes array)
auth.yaml: |
client_id: clientField(clientId)
client_secret: clientField(secret)
default_scopes: clientField(defaultClientScopes).json()
redirect_uri: clientField(redirectUris)[0]
bearer_only: clientField(bearerOnly)
```

### Key Files and Folders
Expand All @@ -102,6 +162,7 @@ src/pepr/operator/
├── index.ts # Entrypoint for the UDS Operator
└── reconcilers # Reconciles Custom Resources via the controllers
```

### Flow

The UDS Operator leverages a Pepr Watch. The following diagram shows the flow of the UDS Operator:
Expand Down
99 changes: 99 additions & 0 deletions src/pepr/operator/controllers/keycloak/client-sync.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, expect, it } from "@jest/globals";
import { generateSecretData } from "./client-sync";
import { Client } from "./types";

const mockClient: Client = {
alwaysDisplayInConsole: true,
attributes: { first: "attribute" },
authenticationFlowBindingOverrides: {},
bearerOnly: true,
clientAuthenticatorType: "",
clientId: "testId",
consentRequired: true,
defaultClientScopes: [],
defaultRoles: [],
directAccessGrantsEnabled: true,
enabled: true,
frontchannelLogout: true,
fullScopeAllowed: true,
implicitFlowEnabled: true,
nodeReRegistrationTimeout: 1,
notBefore: 1,
optionalClientScopes: [],
protocol: "",
publicClient: true,
secret: "",
redirectUris: ["https://demo.uds.dev/login"],
registrationAccessToken: "",
surrogateAuthRequired: true,
serviceAccountsEnabled: true,
webOrigins: [],
standardFlowEnabled: true,
};

const mockClientStringified: Record<string, string> = {
alwaysDisplayInConsole: "true",
attributes: '{"first":"attribute"}',
authenticationFlowBindingOverrides: "{}",
bearerOnly: "true",
clientAuthenticatorType: "",
clientId: "testId",
consentRequired: "true",
defaultClientScopes: "[]",
defaultRoles: "[]",
directAccessGrantsEnabled: "true",
enabled: "true",
frontchannelLogout: "true",
fullScopeAllowed: "true",
implicitFlowEnabled: "true",
nodeReRegistrationTimeout: "1",
notBefore: "1",
optionalClientScopes: "[]",
protocol: "",
publicClient: "true",
secret: "",
redirectUris: '["https://demo.uds.dev/login"]',
registrationAccessToken: "",
surrogateAuthRequired: "true",
serviceAccountsEnabled: "true",
webOrigins: "[]",
standardFlowEnabled: "true",
};

describe("Test Secret & Template Data Generation", () => {
it("generates data without template", async () => {
const expected: Record<string, string> = {};

for (const key in mockClientStringified) {
expected[key] = Buffer.from(mockClientStringified[key]).toString("base64");
}
expect(generateSecretData(mockClient)).toEqual(expected);
});

it("generates data from template: no key or .json()", () => {
const mockTemplate = {
"auth.json": JSON.stringify({ client_id: "clientField(clientId)" }),
};
expect(generateSecretData(mockClient, mockTemplate)).toEqual({
"auth.json": Buffer.from('{"client_id":"testId"}').toString("base64"),
});
});

it("generates data from template: has key", () => {
const mockTemplate = {
"auth.json": JSON.stringify({ redirect_uri: "clientField(redirectUris)[0]" }),
};
expect(generateSecretData(mockClient, mockTemplate)).toEqual({
"auth.json": Buffer.from('{"redirect_uri":"https://demo.uds.dev/login"}').toString("base64"),
});
});

it("generates data from template: has .json()", () => {
const mockTemplate = {
"auth.json": JSON.stringify({ defaultScopes: "clientField(attributes).json()" }),
};
expect(generateSecretData(mockClient, mockTemplate)).toEqual({
"auth.json": Buffer.from('{"defaultScopes":"{"first":"attribute"}"}').toString("base64"),
});
});
});
93 changes: 75 additions & 18 deletions src/pepr/operator/controllers/keycloak/client-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,18 @@ import { K8s, Log, fetch, kind } from "pepr";
import { UDSConfig } from "../../../config";
import { Store } from "../../common";
import { Sso, UDSPackage } from "../../crd";
import { getOwnerRef } from "../utils";
import { Client } from "./types";

const apiURL =
"http://keycloak-http.keycloak.svc.cluster.local: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\\(\\)))?',
"gm",
);

/**
* Create or update the Keycloak clients for the package
*
Expand Down Expand Up @@ -45,6 +52,7 @@ export async function purgeSSOClients(pkg: UDSPackage, refs: string[] = []) {
const token = Store.getItem(ref);
const clientId = ref.replace("sso-client-", "");
if (token) {
Store.removeItem(ref);
await apiCall({ clientId }, "DELETE", token);
Comment on lines +55 to 56
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure which order of these is best, I think I would put Store.removeItem(ref); after await apiCall() and wrap both of these in a try/catch. It looks like there's no error handling at the moment and the error won't be caught.

Need to decide what behavior you want in case of Keycloak API errors. My suggestion would be to not drop the client from the store unless the DELETE succeeds. Unsure what's best, if it goes wrong someone may need to manually drop the item and I don't think it's really possible to hack the Pepr store directly. Deleting an item from the .spec manually is possible though, I don't think the client names are stored there though?

} else {
Log.warn(pkg.metadata, `Failed to remove client ${clientId}, token not found`);
Expand All @@ -53,20 +61,20 @@ export async function purgeSSOClients(pkg: UDSPackage, refs: string[] = []) {
}

async function syncClient(
{ isAuthSvcClient, ...clientReq }: Sso,
{ isAuthSvcClient, secretName, secretTemplate, ...clientReq }: Sso,
pkg: UDSPackage,
isRetry = false,
) {
Log.debug(pkg.metadata, `Processing client request: ${clientReq.clientId}`);

try {
// Not including the CR data in the ref because Keycloak client IDs must be unique already
const name = getClientName(clientReq);
const name = `sso-client-${clientReq.clientId}`;
const token = Store.getItem(name);

let client: Client;

// If and existing client is found, update it
// If an existing client is found, update it
if (token && !isRetry) {
Log.debug(pkg.metadata, `Found existing token for ${clientReq.clientId}`);
client = await apiCall(clientReq, "PUT", token);
Expand All @@ -86,9 +94,14 @@ async function syncClient(
metadata: {
namespace: pkg.metadata!.namespace,
// Use the CR secret name if provided, otherwise use the client name
name: clientReq.secretName || name,
name: secretName || name,
labels: {
"uds/package": pkg.metadata!.name,
},
// Use the CR as the owner ref for each VirtualService
ownerReferences: getOwnerRef(pkg),
},
stringData: clientToStringmap(client),
data: generateSecretData(client, secretTemplate),
});

if (isAuthSvcClient) {
Expand All @@ -99,12 +112,12 @@ async function syncClient(
} catch (err) {
const msg =
`Failed to process client request '${clientReq.clientId}' for ` +
`${pkg.metadata?.namespace}/${pkg.metadata?.name}`;
`${pkg.metadata?.namespace}/${pkg.metadata?.name}. This can occur if a client already exists with the same ID that Pepr isn't tracking.`;
Log.error({ err }, msg);

if (isRetry) {
Log.error(`${msg}, retry failed, aborting`);
throw err;
throw new Error(`${msg}. RETRY FAILED, aborting: ${JSON.stringify(err)}`);
}

// Retry the request
Expand Down Expand Up @@ -155,23 +168,67 @@ async function apiCall(sso: Partial<Sso>, method = "POST", authToken = "") {
return resp.data;
}

function getClientName(client: Partial<Sso>) {
return `sso-client-${client.clientId}`;
}
export function generateSecretData(client: Client, secretTemplate?: { [key: string]: string }) {
if (secretTemplate) {
Log.debug(`Using secret template for client: ${client.clientId}`);
// Iterate over the secret template entry and process each value
return templateData(secretTemplate, client);
}

function clientToStringmap(client: Client) {
const stringMap: Record<string, string> = {};

Log.debug(`Using client data for secret: ${client.clientId}`);

// iterate over the client object and convert all values to strings
for (const [key, value] of Object.entries(client)) {
if (typeof value === "object") {
// For objects and arrays, convert to a JSON string
stringMap[key] = JSON.stringify(value);
} else {
// For primitive values, convert directly to string
stringMap[key] = String(value);
}
// For objects and arrays, convert to a JSON string
const processed = typeof value === "object" ? JSON.stringify(value) : String(value);

// Convert the value to a base64 encoded string
stringMap[key] = Buffer.from(processed).toString("base64");
}

return stringMap;
}

/**
* Process the secret template and convert the client data to base64 encoded strings for use in a secret
*
* @param secretTemplate The template to use for generating the secret
* @param client
* @returns
*/
function templateData(secretTemplate: { [key: string]: string }, client: Client) {
const stringMap: Record<string, string> = {};

// Iterate over the secret template and process each entry
for (const [key, value] of Object.entries(secretTemplate)) {
// Replace any clientField() references with the actual client data
const templated = value.replace(
secretTemplateRegex,
(_match, fieldName: keyof Client, key, json) => {
// Make typescript happy with a more generic type
const value = client[fieldName] as Record<string | number, string> | string;

// If a key is provided, use it to get the value
if (key) {
return String(value[key] ?? "");
}

// If .json() is provided, convert the value to a JSON string
if (json) {
return JSON.stringify(value);
}

// Otherwise, convert the value to a string
return value !== undefined ? String(value) : "";
},
);

// Convert the templated value to a base64 encoded string
stringMap[key] = Buffer.from(templated).toString("base64");
}

// Return the processed secret template without any further processing
return stringMap;
}
Loading