diff --git a/docs/030_user-guide/120_customization.md b/docs/030_user-guide/120_customization.md index 85b114b2f..33bf88292 100644 --- a/docs/030_user-guide/120_customization.md +++ b/docs/030_user-guide/120_customization.md @@ -28,9 +28,9 @@ You can display warnings in the logs by setting the `PEPR_NODE_WARNINGS` environ ## Customizing Log Format -The log format can be customized by setting the `PINO_TIME_STAMP` environment variable in the `package.json` file or directly on the Watcher or Admission `Deployment`. The default value is a partial JSON timestamp string representation of the time. If set to `iso`, the timestamp is displayed in an ISO format. +The log format can be customized by setting the `PINO_TIME_STAMP` environment variable in the `package.json` file or directly on the Watcher or Admission `Deployment`. The default value is a partial JSON timestamp string representation of the time. If set to `iso`, the timestamp is displayed in an ISO format. -**Caution**: attempting to format time in-process will significantly impact logging performance. +**Caution**: attempting to format time in-process will significantly impact logging performance. ```json { @@ -46,13 +46,13 @@ With ISO: {"level":30,"time":"2024-05-14T14:26:03.788Z","pid":16,"hostname":"pepr-static-test-7f4d54b6cc-9lxm6","method":"GET","url":"/healthz","status":200,"duration":"1 ms"} ``` -Default (without): +Default (without): ```json {"level":30,"time":"1715696764106","pid":16,"hostname":"pepr-static-test-watcher-559d94447f-xkq2h","method":"GET","url":"/healthz","status":200,"duration":"1 ms"} ``` -## Customizing Watch Configuration +## Customizing Watch Configuration The Watch configuration is a part of the Pepr module that allows you to watch for specific resources in the Kubernetes cluster. The Watch configuration can be customized by specific enviroment variables of the Watcher Deployment and can be set in the field in the `package.json` or in the helm `values.yaml` file. @@ -132,5 +132,29 @@ Below are the available configurations through `package.json`. | `alwaysIgnore` | Conditions to always ignore | `{namespaces: []}` | | `includedFiles` | For working with WebAssembly | ["main.wasm", "wasm_exec.js"] | | `env` | Environment variables for the container| `{LOG_LEVEL: "warn"}` | +| `rbac` | Custom RBAC rules | `{"rbac": [{"apiGroups": [""], "resources": [""], "verbs": [""]}]}` | These tables provide a comprehensive overview of the fields available for customization within the Helm overrides and the `package.json` file. Modify these according to your deployment requirements. + +### Example Custom RBAC Rules + +The following example demonstrates how to add custom RBAC rules to the Pepr module. + +```json +{ + "pepr": { + "rbac": [ + { + "apiGroups": ["pepr.dev"], + "resources": ["customresources"], + "verbs": ["get", "list"] + }, + { + "apiGroups": ["apps"], + "resources": ["deployments"], + "verbs": ["create", "delete"] + } + ] + } +} +``` diff --git a/journey/pepr-build-wasm.ts b/journey/pepr-build-wasm.ts index 3a2f53b76..14cd19662 100644 --- a/journey/pepr-build-wasm.ts +++ b/journey/pepr-build-wasm.ts @@ -12,13 +12,15 @@ import { cwd } from "./entrypoint.test"; // test npx pepr build -o dst const outputDir = "dist/pepr-test-module/child/folder"; export function peprBuild() { - it("should build artifacts in the dst folder", async () => { - await fs.mkdir(outputDir, { recursive: true }) + await fs.mkdir(outputDir, { recursive: true }); }); it("should successfully build the Pepr project with arguments", async () => { - execSync(`npx pepr build -r gchr.io/defenseunicorns --rbac-mode scoped -o ${outputDir}`, { cwd: cwd, stdio: "inherit" }); + execSync(`npx pepr build -r gchr.io/defenseunicorns --rbac-mode scoped -o ${outputDir}`, { + cwd: cwd, + stdio: "inherit", + }); }); it("should generate produce the K8s yaml file", async () => { @@ -37,10 +39,13 @@ export function peprBuild() { async function validateClusterRoleYaml() { // Read the generated yaml files - const k8sYaml = await fs.readFile(resolve(cwd, outputDir, "pepr-module-static-test.yaml"), "utf8"); + const k8sYaml = await fs.readFile( + resolve(cwd, outputDir, "pepr-module-static-test.yaml"), + "utf8", + ); const cr = await fs.readFile(resolve("journey", "resources", "clusterrole.yaml"), "utf8"); - expect(k8sYaml.includes(cr)).toEqual(true) + expect(k8sYaml.includes(cr)).toEqual(true); } async function validateZarfYaml() { @@ -48,7 +53,10 @@ async function validateZarfYaml() { const peprVer = execSync("npx pepr --version", { cwd }).toString().trim(); // Read the generated yaml files - const k8sYaml = await fs.readFile(resolve(cwd, outputDir, "pepr-module-static-test.yaml"), "utf8"); + const k8sYaml = await fs.readFile( + resolve(cwd, outputDir, "pepr-module-static-test.yaml"), + "utf8", + ); const zarfYAML = await fs.readFile(resolve(cwd, outputDir, "zarf.yaml"), "utf8"); // The expected image name diff --git a/journey/pepr-build.ts b/journey/pepr-build.ts index 7a1ea1bf8..e82369b66 100644 --- a/journey/pepr-build.ts +++ b/journey/pepr-build.ts @@ -6,16 +6,25 @@ import { loadYaml } from "@kubernetes/client-node"; import { execSync } from "child_process"; import { promises as fs } from "fs"; import { resolve } from "path"; -import { V1ObjectMeta, KubernetesObject } from '@kubernetes/client-node'; +import { V1ObjectMeta, KubernetesObject } from "@kubernetes/client-node"; import yaml from "js-yaml"; import { cwd } from "./entrypoint.test"; +const outputDir = "dist/pepr-test-module/child/folder"; + export function peprBuild() { it("should successfully build the Pepr project", async () => { execSync("npx pepr build", { cwd: cwd, stdio: "inherit" }); validateHelmChart(); }); + it("should successfully build the Pepr project with arguments", async () => { + execSync(`npx pepr build -r gchr.io/defenseunicorns --rbac-mode scoped -o ${outputDir}`, { + cwd: cwd, + stdio: "inherit", + }); + }); + it("should generate produce the K8s yaml file", async () => { await fs.access(resolve(cwd, "dist", "pepr-module-static-test.yaml")); }); @@ -25,6 +34,10 @@ export function peprBuild() { await validateZarfYaml(); }); + it("should generate a scoped ClusterRole", async () => { + await validateClusterRoleYaml(); + }); + it("should correctly merge in the package.json env vars into the values.yaml helm chart file", async () => { interface ValuesJSON { admission: { @@ -37,57 +50,73 @@ export function peprBuild() { const expectedWatcherEnv = [ { - "name": "PEPR_PRETTY_LOG", - "value": "false" + name: "PEPR_PRETTY_LOG", + value: "false", }, { - "name": "LOG_LEVEL", - "value": "info" + name: "LOG_LEVEL", + value: "info", }, { - "name": "MY_CUSTOM_VAR", - "value": "example-value" + name: "MY_CUSTOM_VAR", + value: "example-value", }, { - "name": "ZARF_VAR", - "value": "###ZARF_VAR_THING###" - } + name: "ZARF_VAR", + value: "###ZARF_VAR_THING###", + }, ]; const expectedAdmissionEnv = [ { - "name": "PEPR_PRETTY_LOG", - "value": "false" + name: "PEPR_PRETTY_LOG", + value: "false", }, { - "name": "LOG_LEVEL", - "value": "info" + name: "LOG_LEVEL", + value: "info", }, { - "name": "MY_CUSTOM_VAR", - "value": "example-value" + name: "MY_CUSTOM_VAR", + value: "example-value", }, { - "name": "ZARF_VAR", - "value": "###ZARF_VAR_THING###" - } - ] + name: "ZARF_VAR", + value: "###ZARF_VAR_THING###", + }, + ]; try { - const valuesYaml = await fs.readFile(resolve(cwd, "dist", "static-test-chart", "values.yaml"), "utf8"); + const valuesYaml = await fs.readFile( + resolve(cwd, "dist", "static-test-chart", "values.yaml"), + "utf8", + ); const valuesJSON = yaml.load(valuesYaml) as ValuesJSON; expect(valuesJSON.admission.env).toEqual(expectedAdmissionEnv); expect(valuesJSON.watcher!.env).toEqual(expectedWatcherEnv); } catch (error) { expect(error).toBeUndefined(); } - }) + }); } -async function validateHelmChart() { +async function validateClusterRoleYaml() { + // Read the generated yaml files + const k8sYaml = await fs.readFile( + resolve(cwd, outputDir, "pepr-module-static-test.yaml"), + "utf8", + ); + const cr = await fs.readFile(resolve("journey", "resources", "clusterrole.yaml"), "utf8"); + + expect(k8sYaml.includes(cr)).toEqual(true); +} + +async function validateHelmChart() { const k8sYaml = await fs.readFile(resolve(cwd, "dist", "pepr-module-static-test.yaml"), "utf8"); - const helmOutput = execSync('helm template .', { cwd: `${cwd}/dist/static-test-chart` }).toString(); + const helmOutput = execSync("helm template .", { + cwd: `${cwd}/dist/static-test-chart`, + }).toString(); const helmParsed = parseYAMLToJSON(helmOutput); const k8sParsed = parseYAMLToJSON(k8sYaml); @@ -162,8 +191,10 @@ function parseYAMLToJSON(yamlContent: string): KubernetesObject[] | null { function sortKubernetesObjects(objects: KubernetesObject[]): KubernetesObject[] { return objects.sort((a, b) => { if (a?.kind !== b?.kind) { - return (a?.kind ?? '').localeCompare(b?.kind ?? ''); + return (a?.kind ?? "").localeCompare(b?.kind ?? ""); } - return ((a && a.metadata && (a.metadata as V1ObjectMeta)?.name) ?? '').localeCompare((b && b.metadata && (b.metadata as V1ObjectMeta)?.name) ?? ''); + return ((a && a.metadata && (a.metadata as V1ObjectMeta)?.name) ?? "").localeCompare( + (b && b.metadata && (b.metadata as V1ObjectMeta)?.name) ?? "", + ); }); } diff --git a/src/cli/build.ts b/src/cli/build.ts index d0670ccbf..652fdeb47 100644 --- a/src/cli/build.ts +++ b/src/cli/build.ts @@ -133,6 +133,7 @@ export default function (program: RootCmd) { ...cfg.pepr, appVersion: cfg.version, description: cfg.description, + rbacMode: opts.rbacMode, }, path, ); @@ -164,7 +165,7 @@ export default function (program: RootCmd) { const yamlFile = `pepr-module-${uuid}.yaml`; const chartPath = `${uuid}-chart`; const yamlPath = resolve(outputDir, yamlFile); - const yaml = await assets.allYaml(opts.rbacMode, opts.withPullSecret); + const yaml = await assets.allYaml(opts.withPullSecret); try { // wait for capabilities to be loaded and test names diff --git a/src/lib/assets/deploy.ts b/src/lib/assets/deploy.ts index 77848a003..4e21ca2b9 100644 --- a/src/lib/assets/deploy.ts +++ b/src/lib/assets/deploy.ts @@ -4,6 +4,7 @@ import crypto from "crypto"; import { promises as fs } from "fs"; import { K8s, kind } from "kubernetes-fluent-client"; +import { V1PolicyRule as PolicyRule } from "@kubernetes/client-node"; import { Assets } from "."; import Log from "../logger"; @@ -84,18 +85,25 @@ export async function deploy(assets: Assets, force: boolean, webhookTimeout?: nu throw new Error("No code provided"); } - await setupRBAC(name, assets.capabilities, force); + await setupRBAC(name, assets.capabilities, force, assets.config); await setupController(assets, code, hash, force); await setupWatcher(assets, hash, force); } -async function setupRBAC(name: string, capabilities: CapabilityExport[], force: boolean) { +async function setupRBAC( + name: string, + capabilities: CapabilityExport[], + force: boolean, + config: { rbacMode?: string; rbac?: PolicyRule[] }, +) { + const { rbacMode, rbac } = config; + Log.info("Applying cluster role binding"); const crb = clusterRoleBinding(name); await K8s(kind.ClusterRoleBinding).Apply(crb, { force }); Log.info("Applying cluster role"); - const cr = clusterRole(name, capabilities); + const cr = clusterRole(name, capabilities, rbacMode, rbac); await K8s(kind.ClusterRole).Apply(cr, { force }); Log.info("Applying service account"); @@ -135,6 +143,7 @@ async function setupController(assets: Assets, code: Buffer, hash: string, force await K8s(kind.Deployment).Apply(dep, { force }); } +// Setup the watcher deployment and service async function setupWatcher(assets: Assets, hash: string, force: boolean) { // If the module has a watcher, deploy it const watchDeployment = watcher(assets, hash, assets.buildTimestamp); diff --git a/src/lib/assets/helm.ts b/src/lib/assets/helm.ts index 89aef5309..3f7124aa2 100644 --- a/src/lib/assets/helm.ts +++ b/src/lib/assets/helm.ts @@ -1,6 +1,20 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2023-Present The Pepr Authors +export function clusterRoleTemplate() { + return ` + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: {{ .Values.uuid }} + namespace: pepr-system + rules: + {{- if .Values.rbac }} + {{- toYaml .Values.rbac | nindent 2 }} + {{- end }} + `; +} + export function nsTemplate() { return ` apiVersion: v1 diff --git a/src/lib/assets/index.ts b/src/lib/assets/index.ts index e876f4ac5..3cb42fcef 100644 --- a/src/lib/assets/index.ts +++ b/src/lib/assets/index.ts @@ -13,13 +13,20 @@ import { allYaml, zarfYaml, overridesFile, zarfYamlChart } from "./yaml"; import { namespaceComplianceValidator, replaceString } from "../helpers"; import { createDirectoryIfNotExists, dedent } from "../helpers"; import { resolve } from "path"; -import { chartYaml, nsTemplate, admissionDeployTemplate, watcherDeployTemplate, serviceMonitorTemplate } from "./helm"; +import { + chartYaml, + nsTemplate, + admissionDeployTemplate, + watcherDeployTemplate, + clusterRoleTemplate, + serviceMonitorTemplate, +} from "./helm"; import { promises as fs } from "fs"; import { webhookConfig } from "./webhooks"; import { apiTokenSecret, service, tlsSecret, watcherService } from "./networking"; import { watcher, moduleSecret } from "./pods"; -import { clusterRole, clusterRoleBinding, serviceAccount, storeRole, storeRoleBinding } from "./rbac"; +import { clusterRoleBinding, serviceAccount, storeRole, storeRoleBinding } from "./rbac"; export class Assets { readonly name: string; readonly tls: TLSOut; @@ -61,14 +68,14 @@ export class Assets { zarfYamlChart = (path: string) => zarfYamlChart(this, path); - allYaml = async (rbacMode: string, imagePullSecret?: string) => { + allYaml = async (imagePullSecret?: string) => { this.capabilities = await loadCapabilities(this.path); // give error if namespaces are not respected for (const capability of this.capabilities) { namespaceComplianceValidator(capability, this.alwaysIgnore?.namespaces); } - return allYaml(this, rbacMode, imagePullSecret); + return allYaml(this, imagePullSecret); }; generateHelmChart = async (basePath: string) => { @@ -123,10 +130,7 @@ export class Assets { await fs.writeFile(moduleSecretPath, dumpYaml(moduleSecret(this.name, code, this.hash), { noRefs: true })); await fs.writeFile(storeRolePath, dumpYaml(storeRole(this.name), { noRefs: true })); await fs.writeFile(storeRoleBindingPath, dumpYaml(storeRoleBinding(this.name), { noRefs: true })); - await fs.writeFile( - clusterRolePath, - dumpYaml(clusterRole(this.name, this.capabilities, "rbac"), { noRefs: true }), - ); + await fs.writeFile(clusterRolePath, dedent(clusterRoleTemplate())); await fs.writeFile(clusterRoleBindingPath, dumpYaml(clusterRoleBinding(this.name), { noRefs: true })); await fs.writeFile(serviceAccountPath, dumpYaml(serviceAccount(this.name), { noRefs: true })); diff --git a/src/lib/assets/rbac.test.ts b/src/lib/assets/rbac.test.ts new file mode 100644 index 000000000..52dfa842e --- /dev/null +++ b/src/lib/assets/rbac.test.ts @@ -0,0 +1,689 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2023-Present The Pepr Authors +import { clusterRole, clusterRoleBinding, storeRole, serviceAccount, storeRoleBinding } from "./rbac"; +import { CapabilityExport } from "../types"; +import { it, describe, expect, beforeEach, jest } from "@jest/globals"; +import { GenericClass } from "kubernetes-fluent-client"; +import { V1PolicyRule as PolicyRule } from "@kubernetes/client-node"; +import { Event } from "../types"; +import fs from "fs"; +import * as helpers from "../helpers"; + +const mockCapabilities: CapabilityExport[] = [ + { + rbac: [ + { + apiGroups: ["pepr.dev"], + resources: ["peprstores"], + verbs: ["create", "get", "patch", "watch"], + }, + ], + bindings: [ + { + kind: { group: "pepr.dev", version: "v1", kind: "peprstore", plural: "peprstores" }, + isWatch: false, + event: Event.Create, + model: {} as GenericClass, + filters: { + name: "", + regexName: "", + namespaces: [], + regexNamespaces: [], + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + }, + ], + hasSchedule: false, + name: "", + description: "", + }, + { + rbac: [ + { + apiGroups: ["apiextensions.k8s.io"], + resources: ["customresourcedefinitions"], + verbs: ["patch", "create"], + }, + ], + bindings: [ + { + kind: { + group: "apiextensions.k8s.io", + version: "v1", + kind: "customresourcedefinition", + plural: "customresourcedefinitions", + }, + isWatch: false, + isFinalize: false, + event: Event.Create, + model: {} as GenericClass, + filters: { + name: "", + regexName: "", + namespaces: [], + regexNamespaces: [], + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + }, + ], + hasSchedule: false, + name: "", + description: "", + }, + { + rbac: [ + { + apiGroups: [""], + resources: ["namespaces"], + verbs: ["watch"], + }, + ], + bindings: [ + { + kind: { group: "", version: "v1", kind: "namespace", plural: "namespaces" }, + isWatch: true, + isFinalize: false, + event: Event.Create, + model: {} as GenericClass, + filters: { + name: "", + regexName: "", + namespaces: [], + regexNamespaces: [], + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + }, + ], + hasSchedule: false, + name: "", + description: "", + }, + { + rbac: [ + { + apiGroups: [""], + resources: ["configmaps"], + verbs: ["watch"], + }, + ], + bindings: [ + { + kind: { group: "", version: "v1", kind: "configmap", plural: "configmaps" }, + isWatch: true, + isFinalize: false, + event: Event.Create, + model: {} as GenericClass, + filters: { + name: "", + regexName: "", + namespaces: [], + regexNamespaces: [], + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + }, + ], + hasSchedule: false, + name: "", + description: "", + }, +]; + +describe("RBAC generation", () => { + beforeEach(() => { + jest.clearAllMocks(); + const mockPackageJsonRBAC = {}; + + jest.spyOn(fs, "readFileSync").mockImplementation((path: unknown) => { + if (typeof path === "string" && path.includes("package.json")) { + return JSON.stringify({ rbac: mockPackageJsonRBAC }); + } + return "{}"; + }); + }); + + it("should generate correct ClusterRole rules in scoped mode", () => { + const result = clusterRole("test-role", mockCapabilities, "scoped", []); + + expect(result.rules).toEqual([ + { + apiGroups: ["pepr.dev"], + resources: ["peprstores"], + verbs: ["create", "get", "patch", "watch"], + }, + { + apiGroups: ["apiextensions.k8s.io"], + resources: ["customresourcedefinitions"], + verbs: ["patch", "create"], + }, + { + apiGroups: [""], + resources: ["namespaces"], + verbs: ["watch"], + }, + { + apiGroups: [""], + resources: ["configmaps"], + verbs: ["watch"], + }, + ]); + }); + + it("should generate a ClusterRole with wildcard rules when not in scoped mode", () => { + const expectedWildcardRules = [ + { + apiGroups: ["*"], + resources: ["*"], + verbs: ["create", "delete", "get", "list", "patch", "update", "watch"], + }, + ]; + + const result = clusterRole("test-role", mockCapabilities, "admin", []); + + expect(result.rules).toEqual(expectedWildcardRules); + }); + + it("should return an empty rules array when capabilities are empty in scoped mode", () => { + const result = clusterRole("test-role", [], "scoped", []); + + expect(result.rules).toEqual([]); + }); + + it("should include finalize verbs if isFinalize is true in scoped mode", () => { + const capabilitiesWithFinalize: CapabilityExport[] = [ + { + rbac: [ + { + apiGroups: ["pepr.dev"], + resources: ["peprstores"], + verbs: ["patch"], + }, + ], + bindings: [ + { + kind: { group: "pepr.dev", version: "v1", kind: "peprstore", plural: "peprstores" }, + isWatch: false, + isFinalize: true, + event: Event.Create, + model: {} as GenericClass, + filters: { + name: "", + regexName: "", + namespaces: [], + regexNamespaces: [], + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + }, + ], + hasSchedule: false, + name: "", + description: "", + }, + ]; + + const result = clusterRole( + "test-role", + capabilitiesWithFinalize, + "scoped", + capabilitiesWithFinalize.flatMap(c => c.rbac).filter((rule): rule is PolicyRule => rule !== undefined), + ); + + expect(result.rules).toEqual([ + { + apiGroups: ["pepr.dev"], + resources: ["peprstores"], + verbs: ["patch"], + }, + { + apiGroups: ["apiextensions.k8s.io"], + resources: ["customresourcedefinitions"], + verbs: ["patch", "create"], + }, + ]); + }); + + it("should deduplicate verbs and resources in rules", () => { + const capabilitiesWithDuplicates: CapabilityExport[] = [ + { + rbac: [ + { + apiGroups: ["pepr.dev"], + resources: ["peprstores"], + verbs: ["create", "get"], + }, + ], + bindings: [ + { + kind: { group: "pepr.dev", version: "v1", kind: "peprlog", plural: "peprlogs" }, + isWatch: false, + event: Event.Create, + model: {} as GenericClass, + filters: { + name: "", + regexName: "", + namespaces: [], + regexNamespaces: [], + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + }, + ], + hasSchedule: false, + name: "", + description: "", + }, + { + rbac: [ + { + apiGroups: ["pepr.dev"], + resources: ["peprstores"], + verbs: ["get", "patch"], + }, + ], + bindings: [ + { + kind: { group: "pepr.dev", version: "v1", kind: "peprlog", plural: "peprlogs" }, + isWatch: false, + event: Event.Create, + model: {} as GenericClass, + filters: { + name: "", + regexName: "", + namespaces: [], + regexNamespaces: [], + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + }, + ], + hasSchedule: false, + name: "", + description: "", + }, + ]; + + const result = clusterRole( + "test-role", + capabilitiesWithDuplicates, + "scoped", + capabilitiesWithDuplicates.flatMap(c => c.rbac).filter((rule): rule is PolicyRule => rule !== undefined), + ); + + // Filter out only the rules for 'pepr.dev' and 'peprstores' + const filteredRules = result.rules?.filter( + rule => rule.apiGroups?.includes("pepr.dev") && rule.resources?.includes("peprstores"), + ); + + expect(filteredRules).toEqual([ + { + apiGroups: ["pepr.dev"], + resources: ["peprstores"], + verbs: ["create", "get", "patch", "watch"], + }, + ]); + }); +}); + +describe("RBAC generation with mocked package.json", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(fs, "readFileSync").mockImplementation((path: unknown) => { + if (typeof path === "string" && path.includes("package.json")) { + return JSON.stringify({ + pepr: { + rbac: [ + { + apiGroups: ["pepr.dev"], + resources: ["pods"], + verbs: ["get", "list"], + }, + { + apiGroups: ["pepr.dev"], + resources: ["pods"], + verbs: ["list"], + }, + { + apiGroups: ["apps"], + resources: ["deployments"], + verbs: ["create", "delete"], + }, + ], + }, + }); + } + return "{}"; + }); + }); + + it("should generate a ClusterRole with wildcard rules when not in scoped mode", () => { + const expectedWildcardRules = [ + { + apiGroups: ["*"], + resources: ["*"], + verbs: ["create", "delete", "get", "list", "patch", "update", "watch"], + }, + ]; + + const result = clusterRole( + "test-role", + mockCapabilities, + "admin", + mockCapabilities.flatMap(c => c.rbac).filter((rule): rule is PolicyRule => rule !== undefined), + ); + + expect(result.rules).toEqual(expectedWildcardRules); + }); +}); + +describe("clusterRoleBinding", () => { + it("should create a ClusterRoleBinding with the specified name", () => { + const roleName = "test-cluster-role"; + const expectedClusterRoleBinding = { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "ClusterRoleBinding", + metadata: { name: roleName }, + roleRef: { + apiGroup: "rbac.authorization.k8s.io", + kind: "ClusterRole", + name: roleName, + }, + subjects: [ + { + kind: "ServiceAccount", + name: roleName, + namespace: "pepr-system", + }, + ], + }; + + const result = clusterRoleBinding(roleName); + + expect(result).toEqual(expectedClusterRoleBinding); + }); +}); + +describe("serviceAccount", () => { + it("should create a ServiceAccount with the specified name", () => { + const accountName = "test-service-account"; + const expectedServiceAccount = { + apiVersion: "v1", + kind: "ServiceAccount", + metadata: { + name: accountName, + namespace: "pepr-system", + }, + }; + + const result = serviceAccount(accountName); + + expect(result).toEqual(expectedServiceAccount); + }); +}); + +describe("storeRole", () => { + it("should create a Role for managing peprstores with the specified name", () => { + const roleName = "test-role"; + const expectedRole = { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "Role", + metadata: { + name: `${roleName}-store`, + namespace: "pepr-system", + }, + rules: [ + { + apiGroups: ["pepr.dev"], + resources: ["peprstores"], + resourceNames: [""], + verbs: ["create", "get", "patch", "watch"], + }, + ], + }; + + const result = storeRole(roleName); + + expect(result).toEqual(expectedRole); + }); +}); + +describe("storeRoleBinding", () => { + it("should create a RoleBinding for the specified Role", () => { + const roleName = "test-role"; + const expectedRoleBinding = { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "RoleBinding", + metadata: { + name: `${roleName}-store`, + namespace: "pepr-system", + }, + roleRef: { + apiGroup: "rbac.authorization.k8s.io", + kind: "Role", + name: `${roleName}-store`, + }, + subjects: [ + { + kind: "ServiceAccount", + name: `${roleName}-store`, + namespace: "pepr-system", + }, + ], + }; + + const result = storeRoleBinding(roleName); + + expect(result).toEqual(expectedRoleBinding); + }); +}); + +describe("clusterRole", () => { + // Mocking the readRBACFromPackageJson function to return null + jest.mock("./rbac", () => ({ + ...(jest.requireActual("./rbac") as object), + readRBACFromPackageJson: jest.fn(() => null), + })); + + // Mocking createRBACMap to isolate the behavior of clusterRole function + jest.mock("../helpers", () => ({ + ...(jest.requireActual("../helpers") as object), + createRBACMap: jest.fn(), + })); + + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it("should handle keys with less than 3 segments and set group to an empty string", () => { + jest.spyOn(helpers, "createRBACMap").mockReturnValue({ + nodes: { + plural: "nodes", + verbs: ["get"], + }, + }); + + const capabilitiesWithShortKey: CapabilityExport[] = [ + { + rbac: [ + { + apiGroups: [""], + resources: ["nodes"], + verbs: ["get"], + }, + ], + bindings: [ + { + kind: { group: "", version: "v1", kind: "node", plural: "nodes" }, + isWatch: false, + event: Event.Create, + model: {} as GenericClass, + filters: { + name: "", + regexName: "", + namespaces: [], + regexNamespaces: [], + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + }, + ], + hasSchedule: false, + name: "", + description: "", + }, + ]; + + const result = clusterRole( + "test-role", + capabilitiesWithShortKey, + "scoped", + capabilitiesWithShortKey.flatMap(c => c.rbac).filter((rule): rule is PolicyRule => rule !== undefined), + ); + + expect(result.rules).toEqual([ + { + apiGroups: [""], + resources: ["nodes"], + verbs: ["get"], + }, + ]); + }); + + it("should handle keys with 3 or more segments and set group correctly", () => { + jest.spyOn(helpers, "createRBACMap").mockReturnValue({ + "apps/v1/deployments": { + plural: "deployments", + verbs: ["create"], + }, + }); + + const capabilitiesWithLongKey: CapabilityExport[] = [ + { + rbac: [ + { + apiGroups: ["apps"], + resources: ["deployments"], + verbs: ["create"], + }, + ], + bindings: [ + { + kind: { group: "apps", version: "v1", kind: "deployment", plural: "deployments" }, + isWatch: false, + event: Event.Create, + model: {} as GenericClass, + filters: { + name: "", + regexName: "", + namespaces: [], + regexNamespaces: [], + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + }, + ], + hasSchedule: false, + name: "", + description: "", + }, + ]; + + const result = clusterRole( + "test-role", + capabilitiesWithLongKey, + "scoped", + capabilitiesWithLongKey.flatMap(c => c.rbac).filter((rule): rule is PolicyRule => rule !== undefined), + ); + + expect(result.rules).toEqual([ + { + apiGroups: ["apps"], + resources: ["deployments"], + verbs: ["create"], + }, + ]); + }); + + it("should handle non-array custom RBAC by defaulting to an empty array", () => { + // Mock readRBACFromPackageJson to return a non-array value + jest.spyOn(fs, "readFileSync").mockImplementation(() => { + return JSON.stringify({ + pepr: { + rbac: "not-an-array", // Simulate invalid RBAC structure + }, + }); + }); + + const result = clusterRole( + "test-role", + mockCapabilities, + "scoped", + mockCapabilities.flatMap(c => c.rbac).filter((rule): rule is PolicyRule => rule !== undefined), + ); + + // The result should only contain rules from the capabilities, not from the invalid custom RBAC + expect(result.rules).toEqual([ + { + apiGroups: ["pepr.dev"], + resources: ["peprstores"], + verbs: ["create", "get", "patch", "watch"], + }, + { + apiGroups: ["apiextensions.k8s.io"], + resources: ["customresourcedefinitions"], + verbs: ["patch", "create"], + }, + { + apiGroups: [""], + resources: ["namespaces"], + verbs: ["watch"], + }, + { + apiGroups: [""], + resources: ["configmaps"], + verbs: ["watch"], + }, + ]); + }); + + it("should default to an empty verbs array if rule.verbs is undefined", () => { + // Simulate a custom RBAC rule with empty verbs + const customRbacWithNoVerbs: PolicyRule[] = [ + { + apiGroups: ["pepr.dev"], + resources: ["customresources"], + verbs: [], // Set verbs to an empty array to satisfy the V1PolicyRule type + }, + ]; + + jest.spyOn(fs, "readFileSync").mockImplementation(() => { + return JSON.stringify({ + pepr: { + rbac: customRbacWithNoVerbs, + }, + }); + }); + + const result = clusterRole("test-role", mockCapabilities, "scoped", customRbacWithNoVerbs); + + // Check that the verbs array is empty for the custom RBAC rule + expect(result.rules).toContainEqual({ + apiGroups: ["pepr.dev"], + resources: ["customresources"], + verbs: [], + }); + }); +}); diff --git a/src/lib/assets/rbac.ts b/src/lib/assets/rbac.ts index 03b67f3f4..92a2a55c6 100644 --- a/src/lib/assets/rbac.ts +++ b/src/lib/assets/rbac.ts @@ -2,35 +2,63 @@ // SPDX-FileCopyrightText: 2023-Present The Pepr Authors import { kind } from "kubernetes-fluent-client"; +import { V1PolicyRule as PolicyRule } from "@kubernetes/client-node"; import { CapabilityExport } from "../types"; import { createRBACMap } from "../helpers"; + /** - * Grants the controller access to cluster resources beyond the mutating webhook. + * Creates a Kubernetes ClusterRole based on capabilities and optional custom RBAC rules. * - * @todo: should dynamically generate this based on resources used by the module. will also need to explore how this should work for multiple modules. - * @returns + * @param {string} name - The name of the ClusterRole. + * @param {CapabilityExport[]} capabilities - Array of capabilities defining RBAC rules. + * @param {string} [rbacMode=""] - The RBAC mode; if "scoped", generates scoped rules, otherwise uses wildcard rules. + * @returns {kind.ClusterRole} - A Kubernetes ClusterRole object. */ -export function clusterRole(name: string, capabilities: CapabilityExport[], rbacMode: string = ""): kind.ClusterRole { +export function clusterRole( + name: string, + capabilities: CapabilityExport[], + rbacMode: string = "admin", + customRbac: PolicyRule[] | undefined, +): kind.ClusterRole { + // Create the RBAC map from capabilities const rbacMap = createRBACMap(capabilities); + + // Generate scoped rules from rbacMap + const scopedRules = Object.keys(rbacMap).map(key => { + let group: string; + key.split("/").length < 3 ? (group = "") : (group = key.split("/")[0]); + + return { + apiGroups: [group], + resources: [rbacMap[key].plural], + verbs: rbacMap[key].verbs, + }; + }); + + // Merge and deduplicate custom RBAC and scoped rules + const mergedRBAC = [...(Array.isArray(customRbac) ? customRbac : []), ...scopedRules]; + const deduper: Record = {}; + + mergedRBAC.forEach(rule => { + const key = `${rule.apiGroups}/${rule.resources}`; + if (deduper[key]) { + // Deduplicate verbs + deduper[key].verbs = Array.from(new Set([...deduper[key].verbs, ...rule.verbs])); + } else { + deduper[key] = { ...rule, verbs: rule.verbs || [] }; + } + }); + + // Convert deduplicated RBAC rules back to an array + const deduplicatedRules = Object.values(deduper); + return { apiVersion: "rbac.authorization.k8s.io/v1", kind: "ClusterRole", metadata: { name }, rules: rbacMode === "scoped" - ? [ - ...Object.keys(rbacMap).map(key => { - // let group:string, version:string, kind:string; - let group: string; - key.split("/").length < 3 ? (group = "") : (group = key.split("/")[0]); - - return { - apiGroups: [group], - resources: [rbacMap[key].plural], - verbs: rbacMap[key].verbs, - }; - }), - ] + ? deduplicatedRules : [ { apiGroups: ["*"], @@ -41,6 +69,12 @@ export function clusterRole(name: string, capabilities: CapabilityExport[], rbac }; } +/** + * Creates a Kubernetes ClusterRoleBinding for a specified ClusterRole. + * + * @param {string} name - The name of the ClusterRole to bind. + * @returns {kind.ClusterRoleBinding} - A Kubernetes ClusterRoleBinding object. + */ export function clusterRoleBinding(name: string): kind.ClusterRoleBinding { return { apiVersion: "rbac.authorization.k8s.io/v1", @@ -61,6 +95,12 @@ export function clusterRoleBinding(name: string): kind.ClusterRoleBinding { }; } +/** + * Creates a Kubernetes ServiceAccount with the specified name. + * + * @param {string} name - The name of the ServiceAccount. + * @returns {kind.ServiceAccount} - A Kubernetes ServiceAccount object. + */ export function serviceAccount(name: string): kind.ServiceAccount { return { apiVersion: "v1", @@ -72,6 +112,12 @@ export function serviceAccount(name: string): kind.ServiceAccount { }; } +/** + * Creates a Kubernetes Role for managing peprstores in a specified namespace. + * + * @param {string} name - The base name of the Role. + * @returns {kind.Role} - A Kubernetes Role object for peprstores. + */ export function storeRole(name: string): kind.Role { name = `${name}-store`; return { @@ -89,6 +135,12 @@ export function storeRole(name: string): kind.Role { }; } +/** + * Creates a Kubernetes RoleBinding for a specified Role in the pepr-system namespace. + * + * @param {string} name - The base name of the Role to bind. + * @returns {kind.RoleBinding} - A Kubernetes RoleBinding object. + */ export function storeRoleBinding(name: string): kind.RoleBinding { name = `${name}-store`; return { diff --git a/src/lib/assets/yaml.ts b/src/lib/assets/yaml.ts index 4e75115af..e1faffc0a 100644 --- a/src/lib/assets/yaml.ts +++ b/src/lib/assets/yaml.ts @@ -10,9 +10,13 @@ import { deployment, moduleSecret, namespace, watcher } from "./pods"; import { clusterRole, clusterRoleBinding, serviceAccount, storeRole, storeRoleBinding } from "./rbac"; import { webhookConfig } from "./webhooks"; import { genEnv } from "./pods"; + // Helm Chart overrides file (values.yaml) generated from assets -export async function overridesFile({ hash, name, image, config, apiToken }: Assets, path: string) { +export async function overridesFile({ hash, name, image, config, apiToken, capabilities }: Assets, path: string) { + const rbacOverrides = clusterRole(name, capabilities, config.rbacMode, config.rbac).rules; + const overrides = { + rbac: rbacOverrides, secrets: { apiToken: Buffer.from(apiToken).toString("base64"), }, @@ -219,8 +223,8 @@ export function zarfYamlChart({ name, image, config }: Assets, path: string) { return dumpYaml(zarfCfg, { noRefs: true }); } -export async function allYaml(assets: Assets, rbacMode: string, imagePullSecret?: string) { - const { name, tls, apiToken, path } = assets; +export async function allYaml(assets: Assets, imagePullSecret?: string) { + const { name, tls, apiToken, path, config } = assets; const code = await fs.readFile(path); // Generate a hash of the code @@ -232,7 +236,7 @@ export async function allYaml(assets: Assets, rbacMode: string, imagePullSecret? const resources = [ namespace(assets.config.customLabels?.namespace), - clusterRole(name, assets.capabilities, rbacMode), + clusterRole(name, assets.capabilities, config.rbacMode, config.rbac), clusterRoleBinding(name), serviceAccount(name), apiTokenSecret(name, apiToken), diff --git a/src/lib/helpers.ts b/src/lib/helpers.ts index 1df162a9a..b9d68c7b7 100644 --- a/src/lib/helpers.ts +++ b/src/lib/helpers.ts @@ -61,7 +61,7 @@ export function validateHash(expectedHash: string): void { } } -type RBACMap = { +export type RBACMap = { [key: string]: { verbs: string[]; plural: string; @@ -170,6 +170,14 @@ export function createRBACMap(capabilities: CapabilityExport[]): RBACMap { plural: binding.kind.plural || `${binding.kind.kind.toLowerCase()}s`, }; } + + // Add finalizer rbac + if (binding.isFinalize) { + acc[key] = { + verbs: ["patch"], + plural: binding.kind.plural || `${binding.kind.kind.toLowerCase()}s`, + }; + } }); return acc; diff --git a/src/lib/module.ts b/src/lib/module.ts index 1a68b4ce8..f7949ef24 100644 --- a/src/lib/module.ts +++ b/src/lib/module.ts @@ -8,6 +8,7 @@ import { MutateResponse, ValidateResponse, WebhookIgnore } from "./k8s"; import { CapabilityExport, AdmissionRequest } from "./types"; import { setupWatch } from "./watch-processor"; import { Log } from "../lib"; +import { V1PolicyRule as PolicyRule } from "@kubernetes/client-node"; /** Custom Labels Type for package.json */ export interface CustomLabels { @@ -35,6 +36,10 @@ export type ModuleConfig = { env?: Record; /** Custom Labels for Kubernetes Objects */ customLabels?: CustomLabels; + /** Custom RBAC rules */ + rbac?: PolicyRule[]; + /** The RBAC mode; if "scoped", generates scoped rules, otherwise uses wildcard rules. */ + rbacMode?: string; }; export type PackageJSON = { diff --git a/src/lib/types.ts b/src/lib/types.ts index ba5c66ff8..fed2b078a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -9,6 +9,7 @@ import { PeprMutateRequest } from "./mutate-request"; import { PeprValidateRequest } from "./validate-request"; import { Logger } from "pino"; +import { V1PolicyRule as PolicyRule } from "@kubernetes/client-node"; export enum Operation { CREATE = "CREATE", @@ -77,6 +78,7 @@ export interface CapabilityCfg { export interface CapabilityExport extends CapabilityCfg { bindings: Binding[]; hasSchedule: boolean; + rbac?: PolicyRule[]; } export type WhenSelector = {