From fe4c0693f2428da69143047ba97141da25aa290f Mon Sep 17 00:00:00 2001 From: Sam Mayer Date: Wed, 6 Nov 2024 11:14:32 -0600 Subject: [PATCH] revert: filter-chain refactor (#1396) ## Description Reverts filter-chaining from #1333 due to regressions, but keeps subsequent work. End to End Test: (See [Pepr Excellent Examples](https://github.com/defenseunicorns/pepr-excellent-examples)) ## Related Issue Relates to #1248 Closes #1389 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Other (security config, docs update, etc) ## Checklist before merging - [x] Unit, [Journey](https://github.com/defenseunicorns/pepr/tree/main/journey), [E2E Tests](https://github.com/defenseunicorns/pepr-excellent-examples), [docs](https://github.com/defenseunicorns/pepr/tree/main/docs), [adr](https://github.com/defenseunicorns/pepr/tree/main/adr) added or updated as needed - [x] [Contributor Guide Steps](https://docs.pepr.dev/main/contribute/#submitting-a-pull-request) followed --- src/lib/filter/filter.test.ts | 692 +++++++++++++++++++++++ src/lib/filter/filter.ts | 144 +++++ src/lib/filter/filterChain.ts | 24 - src/lib/filter/filtersWithLogs.ts | 146 ----- src/lib/filter/logMessages.test.ts | 9 - src/lib/filter/logMessages.ts | 94 --- src/lib/filter/shouldSkipRequest.test.ts | 6 +- src/lib/filter/shouldSkipRequest.ts | 63 --- src/lib/mutate-processor.ts | 2 +- src/lib/types.ts | 10 - src/lib/validate-processor.ts | 2 +- 11 files changed, 841 insertions(+), 351 deletions(-) create mode 100644 src/lib/filter/filter.test.ts create mode 100644 src/lib/filter/filter.ts delete mode 100644 src/lib/filter/filterChain.ts delete mode 100644 src/lib/filter/filtersWithLogs.ts delete mode 100644 src/lib/filter/logMessages.test.ts delete mode 100644 src/lib/filter/logMessages.ts delete mode 100644 src/lib/filter/shouldSkipRequest.ts diff --git a/src/lib/filter/filter.test.ts b/src/lib/filter/filter.test.ts new file mode 100644 index 000000000..91b20040c --- /dev/null +++ b/src/lib/filter/filter.test.ts @@ -0,0 +1,692 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2023-Present The Pepr Authors + +import { expect, test, describe } from "@jest/globals"; +import { kind, modelToGroupVersionKind } from "kubernetes-fluent-client"; +import * as fc from "fast-check"; +import { CreatePod, DeletePod } from "../../fixtures/loader"; +import { shouldSkipRequest } from "./filter"; +import { AdmissionRequest, Binding } from "../types"; +import { Event } from "../enums"; + +export const callback = () => undefined; + +export const podKind = modelToGroupVersionKind(kind.Pod.name); + +describe("Fuzzing shouldSkipRequest", () => { + test("should handle random inputs without crashing", () => { + fc.assert( + fc.property( + fc.record({ + event: fc.constantFrom("CREATE", "UPDATE", "DELETE", "ANY"), + kind: fc.record({ + group: fc.string(), + version: fc.string(), + kind: fc.string(), + }), + filters: fc.record({ + name: fc.string(), + namespaces: fc.array(fc.string()), + labels: fc.dictionary(fc.string(), fc.string()), + annotations: fc.dictionary(fc.string(), fc.string()), + deletionTimestamp: fc.boolean(), + }), + }), + fc.record({ + operation: fc.string(), + uid: fc.string(), + name: fc.string(), + namespace: fc.string(), + kind: fc.record({ + group: fc.string(), + version: fc.string(), + kind: fc.string(), + }), + object: fc.record({ + metadata: fc.record({ + deletionTimestamp: fc.option(fc.date()), + }), + }), + }), + fc.array(fc.string()), + (binding, req, capabilityNamespaces) => { + expect(() => + shouldSkipRequest(binding as Binding, req as AdmissionRequest, capabilityNamespaces), + ).not.toThrow(); + }, + ), + { numRuns: 100 }, + ); + }); +}); + +describe("Property-Based Testing shouldSkipRequest", () => { + test("should only skip requests that do not match the binding criteria", () => { + fc.assert( + fc.property( + fc.record({ + event: fc.constantFrom("CREATE", "UPDATE", "DELETE", "ANY"), + kind: fc.record({ + group: fc.string(), + version: fc.string(), + kind: fc.string(), + }), + filters: fc.record({ + name: fc.string(), + namespaces: fc.array(fc.string()), + labels: fc.dictionary(fc.string(), fc.string()), + annotations: fc.dictionary(fc.string(), fc.string()), + deletionTimestamp: fc.boolean(), + }), + }), + fc.record({ + operation: fc.string(), + uid: fc.string(), + name: fc.string(), + namespace: fc.string(), + kind: fc.record({ + group: fc.string(), + version: fc.string(), + kind: fc.string(), + }), + object: fc.record({ + metadata: fc.record({ + deletionTimestamp: fc.option(fc.date()), + }), + }), + }), + fc.array(fc.string()), + (binding, req, capabilityNamespaces) => { + const shouldSkip = shouldSkipRequest(binding as Binding, req as AdmissionRequest, capabilityNamespaces); + expect(typeof shouldSkip).toBe("string"); + }, + ), + { numRuns: 100 }, + ); + }); +}); + +test("create: should reject when regex name does not match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "", + namespaces: [], + regexNamespaces: [], + regexName: "^default$", + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + callback, + }; + const pod = CreatePod(); + expect(shouldSkipRequest(binding, pod, [])).toMatch( + /Ignoring Admission Callback: Binding defines name regex '.*' but Object carries '.*'./, + ); +}); + +test("create: should not reject when regex name does match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "", + namespaces: [], + regexNamespaces: [], + regexName: "^cool", + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + callback, + }; + const pod = CreatePod(); + expect(shouldSkipRequest(binding, pod, [])).toBe(""); +}); + +test("delete: should reject when regex name does not match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "", + namespaces: [], + regexNamespaces: [], + regexName: "^default$", + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + callback, + }; + const pod = DeletePod(); + expect(shouldSkipRequest(binding, pod, [])).toMatch( + /Ignoring Admission Callback: Binding defines name regex '.*' but Object carries '.*'./, + ); +}); + +test("delete: should not reject when regex name does match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "", + namespaces: [], + regexNamespaces: [], + regexName: "^cool", + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + callback, + }; + const pod = DeletePod(); + expect(shouldSkipRequest(binding, pod, [])).toBe(""); +}); + +test("create: should not reject when regex namespace does match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "", + namespaces: [], + regexNamespaces: ["^helm"], + regexName: "", + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + callback, + }; + const pod = CreatePod(); + expect(shouldSkipRequest(binding, pod, [])).toBe(""); +}); + +test("create: should reject when regex namespace does not match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "", + namespaces: [], + regexNamespaces: ["^argo"], + regexName: "", + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + callback, + }; + const pod = CreatePod(); + expect(shouldSkipRequest(binding, pod, [])).toMatch( + /Ignoring Admission Callback: Binding defines namespace regexes '.*' but Object carries '.*'./, + ); +}); + +test("delete: should reject when regex namespace does not match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "", + namespaces: [], + regexNamespaces: ["^argo"], + regexName: "", + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + callback, + }; + const pod = DeletePod(); + expect(shouldSkipRequest(binding, pod, [])).toMatch( + /Ignoring Admission Callback: Binding defines namespace regexes '.*' but Object carries '.*'./, + ); +}); + +test("delete: should not reject when regex namespace does match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "", + namespaces: [], + regexNamespaces: ["^helm"], + regexName: "", + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + callback, + }; + const pod = DeletePod(); + expect(shouldSkipRequest(binding, pod, [])).toBe(""); +}); + +test("delete: should reject when name does not match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "bleh", + namespaces: [], + regexNamespaces: [], + regexName: "^not-cool", + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + callback, + }; + const pod = DeletePod(); + expect(shouldSkipRequest(binding, pod, [])).toMatch( + /Ignoring Admission Callback: Binding defines name '.*' but Object carries '.*'./, + ); +}); + +test("should reject when kind does not match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: { + group: "", + version: "v1", + kind: "Nope", + }, + filters: { + name: "", + namespaces: [], + regexNamespaces: [], + regexName: "", + labels: {}, + annotations: {}, + deletionTimestamp: false, + }, + callback, + }; + const pod = CreatePod(); + + expect(shouldSkipRequest(binding, pod, [])).toMatch( + /Ignoring Admission Callback: Binding defines kind '.*' but Request declares '.*'./, + ); +}); + +test("should reject when group does not match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: { + group: "Nope", + version: "v1", + kind: "Pod", + }, + filters: { + name: "", + namespaces: [], + labels: {}, + annotations: {}, + deletionTimestamp: false, + regexNamespaces: [], + regexName: "", + }, + callback, + }; + const pod = CreatePod(); + + expect(shouldSkipRequest(binding, pod, [])).toMatch( + /Ignoring Admission Callback: Binding defines group '.*' but Request declares '.*'./, + ); +}); + +test("should reject when version does not match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: { + group: "", + version: "Nope", + kind: "Pod", + }, + filters: { + name: "", + namespaces: [], + labels: {}, + annotations: {}, + deletionTimestamp: false, + regexNamespaces: [], + regexName: "", + }, + callback, + }; + const pod = CreatePod(); + + expect(shouldSkipRequest(binding, pod, [])).toMatch( + /Ignoring Admission Callback: Binding defines version '.*' but Request declares '.*'./, + ); +}); + +test("should allow when group, version, and kind match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "", + namespaces: [], + labels: {}, + annotations: {}, + deletionTimestamp: false, + regexNamespaces: [], + regexName: "", + }, + callback, + }; + const pod = CreatePod(); + + expect(shouldSkipRequest(binding, pod, [])).toBe(""); +}); + +test("should allow when kind match and others are empty", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: { + group: "", + version: "", + kind: "Pod", + }, + filters: { + name: "", + namespaces: [], + labels: {}, + annotations: {}, + deletionTimestamp: false, + regexNamespaces: [], + regexName: "", + }, + callback, + }; + const pod = CreatePod(); + + expect(shouldSkipRequest(binding, pod, [])).toBe(""); +}); + +test("should reject when the capability namespace does not match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "", + namespaces: [], + labels: {}, + annotations: {}, + deletionTimestamp: false, + regexNamespaces: [], + regexName: "", + }, + callback, + }; + const pod = CreatePod(); + + expect(shouldSkipRequest(binding, pod, ["bleh", "bleh2"])).toMatch( + /Ignoring Admission Callback: Object carries namespace '.*' but namespaces allowed by Capability are '.*'./, + ); +}); + +test("should reject when namespace does not match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "", + namespaces: ["bleh"], + labels: {}, + annotations: {}, + deletionTimestamp: false, + regexNamespaces: [], + regexName: "", + }, + callback, + }; + const pod = CreatePod(); + + expect(shouldSkipRequest(binding, pod, [])).toMatch( + /Ignoring Admission Callback: Binding defines namespaces '.*' but Object carries '.*'./, + ); +}); + +test("should allow when namespace is match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "", + namespaces: ["helm-releasename", "unicorn", "things"], + labels: {}, + annotations: {}, + deletionTimestamp: false, + regexNamespaces: [], + regexName: "", + }, + callback, + }; + const pod = CreatePod(); + + expect(shouldSkipRequest(binding, pod, [])).toBe(""); +}); + +test("should reject when label does not match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "", + namespaces: [], + labels: { + foo: "bar", + }, + annotations: {}, + deletionTimestamp: false, + regexNamespaces: [], + regexName: "", + }, + callback, + }; + const pod = CreatePod(); + + expect(shouldSkipRequest(binding, pod, [])).toMatch( + /Ignoring Admission Callback: Binding defines labels '.*' but Object carries '.*'./, + ); +}); + +test("should allow when label is match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "", + deletionTimestamp: false, + namespaces: [], + regexNamespaces: [], + regexName: "", + labels: { + foo: "bar", + test: "test1", + }, + annotations: {}, + }, + callback, + }; + + const pod = CreatePod(); + pod.object.metadata = pod.object.metadata || {}; + pod.object.metadata.labels = { + foo: "bar", + test: "test1", + test2: "test2", + }; + + expect(shouldSkipRequest(binding, pod, [])).toBe(""); +}); + +test("should reject when annotation does not match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "", + namespaces: [], + labels: {}, + annotations: { + foo: "bar", + }, + deletionTimestamp: false, + regexNamespaces: [], + regexName: "", + }, + callback, + }; + const pod = CreatePod(); + + expect(shouldSkipRequest(binding, pod, [])).toMatch( + /Ignoring Admission Callback: Binding defines annotations '.*' but Object carries '.*'./, + ); +}); + +test("should allow when annotation is match", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "", + namespaces: [], + labels: {}, + annotations: { + foo: "bar", + test: "test1", + }, + deletionTimestamp: false, + regexNamespaces: [], + regexName: "", + }, + callback, + }; + + const pod = CreatePod(); + pod.object.metadata = pod.object.metadata || {}; + pod.object.metadata.annotations = { + foo: "bar", + test: "test1", + test2: "test2", + }; + + expect(shouldSkipRequest(binding, pod, [])).toBe(""); +}); + +test("should use `oldObject` when the operation is `DELETE`", () => { + const binding = { + model: kind.Pod, + event: Event.Delete, + kind: podKind, + filters: { + name: "", + namespaces: [], + regexNamespaces: [], + regexName: "", + deletionTimestamp: false, + labels: { + "test-op": "delete", + }, + annotations: {}, + }, + callback, + }; + + const pod = DeletePod(); + + expect(shouldSkipRequest(binding, pod, [])).toBe(""); +}); + +test("should allow when deletionTimestamp is present on pod", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "", + namespaces: [], + labels: {}, + regexNamespaces: [], + regexName: "", + annotations: { + foo: "bar", + test: "test1", + }, + deletionTimestamp: true, + }, + callback, + }; + + const pod = CreatePod(); + pod.object.metadata = pod.object.metadata || {}; + pod.object.metadata!.deletionTimestamp = new Date("2021-09-01T00:00:00Z"); + pod.object.metadata.annotations = { + foo: "bar", + test: "test1", + test2: "test2", + }; + + expect(shouldSkipRequest(binding, pod, [])).toBe(""); +}); + +test("should reject when deletionTimestamp is not present on pod", () => { + const binding = { + model: kind.Pod, + event: Event.Any, + kind: podKind, + filters: { + name: "", + namespaces: [], + labels: {}, + regexNamespaces: [], + regexName: "", + annotations: { + foo: "bar", + test: "test1", + }, + deletionTimestamp: true, + }, + callback, + }; + + const pod = CreatePod(); + pod.object.metadata = pod.object.metadata || {}; + pod.object.metadata.annotations = { + foo: "bar", + test: "test1", + test2: "test2", + }; + + expect(shouldSkipRequest(binding, pod, [])).toMatch( + /Ignoring Admission Callback: Binding defines deletionTimestamp but Object does not carry it./, + ); +}); diff --git a/src/lib/filter/filter.ts b/src/lib/filter/filter.ts new file mode 100644 index 000000000..9604477d1 --- /dev/null +++ b/src/lib/filter/filter.ts @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2023-Present The Pepr Authors + +import { AdmissionRequest, Binding } from "../types"; +import { Operation } from "../enums"; +import { + carriesIgnoredNamespace, + carriedName, + definedEvent, + declaredOperation, + definedName, + definedGroup, + declaredGroup, + definedVersion, + declaredVersion, + definedKind, + declaredKind, + definedNamespaces, + carriedNamespace, + definedLabels, + carriedLabels, + definedAnnotations, + carriedAnnotations, + definedNamespaceRegexes, + definedNameRegex, + misboundDeleteWithDeletionTimestamp, + mismatchedDeletionTimestamp, + mismatchedAnnotations, + mismatchedLabels, + mismatchedName, + mismatchedNameRegex, + mismatchedNamespace, + mismatchedNamespaceRegex, + mismatchedEvent, + mismatchedGroup, + mismatchedVersion, + mismatchedKind, + unbindableNamespaces, + uncarryableNamespace, +} from "./adjudicators"; + +/** + * shouldSkipRequest determines if a request should be skipped based on the binding filters. + * + * @param binding the action binding + * @param req the incoming request + * @returns + */ +export function shouldSkipRequest( + binding: Binding, + req: AdmissionRequest, + capabilityNamespaces: string[], + ignoredNamespaces?: string[], +): string { + const prefix = "Ignoring Admission Callback:"; + const obj = req.operation === Operation.DELETE ? req.oldObject : req.object; + + // prettier-ignore + return ( + misboundDeleteWithDeletionTimestamp(binding) ? + `${prefix} Cannot use deletionTimestamp filter on a DELETE operation.` : + + mismatchedDeletionTimestamp(binding, obj) ? + `${prefix} Binding defines deletionTimestamp but Object does not carry it.` : + + mismatchedEvent(binding, req) ? + ( + `${prefix} Binding defines event '${definedEvent(binding)}' but ` + + `Request declares '${declaredOperation(req)}'.` + ) : + + mismatchedName(binding, obj) ? + `${prefix} Binding defines name '${definedName(binding)}' but Object carries '${carriedName(obj)}'.` : + + mismatchedGroup(binding, req) ? + ( + `${prefix} Binding defines group '${definedGroup(binding)}' but ` + + `Request declares '${declaredGroup(req)}'.` + ) : + + mismatchedVersion(binding, req) ? + ( + `${prefix} Binding defines version '${definedVersion(binding)}' but ` + + `Request declares '${declaredVersion(req)}'.` + ) : + + mismatchedKind(binding, req) ? + ( + `${prefix} Binding defines kind '${definedKind(binding)}' but ` + + `Request declares '${declaredKind(req)}'.` + ) : + + unbindableNamespaces(capabilityNamespaces, binding) ? + ( + `${prefix} Binding defines namespaces ${JSON.stringify(definedNamespaces(binding))} ` + + `but namespaces allowed by Capability are '${JSON.stringify(capabilityNamespaces)}'.` + ) : + + uncarryableNamespace(capabilityNamespaces, obj) ? + ( + `${prefix} Object carries namespace '${carriedNamespace(obj)}' ` + + `but namespaces allowed by Capability are '${JSON.stringify(capabilityNamespaces)}'.` + ) : + + mismatchedNamespace(binding, obj) ? + ( + `${prefix} Binding defines namespaces '${JSON.stringify(definedNamespaces(binding))}' ` + + `but Object carries '${carriedNamespace(obj)}'.` + ) : + + mismatchedLabels(binding, obj) ? + ( + `${prefix} Binding defines labels '${JSON.stringify(definedLabels(binding))}' ` + + `but Object carries '${JSON.stringify(carriedLabels(obj))}'.` + ) : + + mismatchedAnnotations(binding, obj) ? + ( + `${prefix} Binding defines annotations '${JSON.stringify(definedAnnotations(binding))}' ` + + `but Object carries '${JSON.stringify(carriedAnnotations(obj))}'.` + ) : + + mismatchedNamespaceRegex(binding, obj) ? + ( + `${prefix} Binding defines namespace regexes ` + + `'${JSON.stringify(definedNamespaceRegexes(binding))}' ` + + `but Object carries '${carriedNamespace(obj)}'.` + ) : + + mismatchedNameRegex(binding, obj) ? + ( + `${prefix} Binding defines name regex '${definedNameRegex(binding)}' ` + + `but Object carries '${carriedName(obj)}'.` + ) : + + carriesIgnoredNamespace(ignoredNamespaces, obj) ? + ( + `${prefix} Object carries namespace '${carriedNamespace(obj)}' ` + + `but ignored namespaces include '${JSON.stringify(ignoredNamespaces)}'.` + ) : + + "" + ); +} diff --git a/src/lib/filter/filterChain.ts b/src/lib/filter/filterChain.ts deleted file mode 100644 index 0272e9ab3..000000000 --- a/src/lib/filter/filterChain.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { FilterParams } from "../types"; - -interface Filter { - (data: FilterParams): string; -} - -export class FilterChain { - private filters: Filter[] = []; - - public addFilter(filter: Filter): FilterChain { - this.filters.push(filter); - return this; - } - public execute(data: FilterParams): string { - let result = ""; - for (const filter of this.filters) { - result += filter(data); - if (result !== "") { - break; - } - } - return result; - } -} diff --git a/src/lib/filter/filtersWithLogs.ts b/src/lib/filter/filtersWithLogs.ts deleted file mode 100644 index 138c80747..000000000 --- a/src/lib/filter/filtersWithLogs.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { KubernetesObject } from "kubernetes-fluent-client"; -import { - carriesIgnoredNamespace, - misboundDeleteWithDeletionTimestamp, - mismatchedAnnotations, - mismatchedDeletionTimestamp, - mismatchedEvent, - mismatchedGroup, - mismatchedKind, - mismatchedLabels, - mismatchedName, - mismatchedNameRegex, - mismatchedNamespace, - mismatchedNamespaceRegex, - mismatchedVersion, - uncarryableNamespace, -} from "./adjudicators"; -import { Operation } from "../enums"; -import { FilterInput, FilterParams } from "../types"; -import { commonLogMessage } from "./logMessages"; - -const getAdmissionRequest = (data: FilterParams): KubernetesObject | undefined => { - return data.request.operation === Operation.DELETE ? data.request.oldObject : data.request.object; -}; - -const createFilter = ( - filterInputSelector: (data: FilterParams) => FilterInput, // FilterInput is unvalidated - filterCriteriaSelector: (data: FilterParams) => FilterInput, // FilterCriteria adjudicates FilterInput - filterCheck: (filterInput: FilterInput, filterCriteria?: FilterInput) => boolean, // Adjudicates FilterInput based upon FilterCriteria - logMessage: (filterInput: FilterInput, filterCriteria?: FilterInput) => string, -) => { - return (data: FilterParams): string => { - const filterInput = filterInputSelector(data); - const filterCriteria = filterCriteriaSelector(data); - return filterCheck(filterInput, filterCriteria) ? logMessage(filterInput, filterCriteria) : ""; - }; -}; - -export const mismatchedNameFilter = createFilter( - data => data.binding, - data => getAdmissionRequest(data), - (binding, kubernetesObject) => mismatchedName(binding, kubernetesObject), - (binding, kubernetesObject) => commonLogMessage("name", binding, kubernetesObject), -); - -export const mismatchedNameRegexFilter = createFilter( - data => data.binding, - data => getAdmissionRequest(data), - (binding, kubernetesObject) => mismatchedNameRegex(binding, kubernetesObject), - (binding, kubernetesObject) => commonLogMessage("name regex", binding, kubernetesObject), -); - -export const mismatchedNamespaceRegexFilter = createFilter( - data => data.binding, - data => getAdmissionRequest(data), - (binding, kubernetesObject) => mismatchedNamespaceRegex(binding, kubernetesObject), - (binding, kubernetesObject) => commonLogMessage("namespace regexes", binding, kubernetesObject), -); - -export const mismatchedNamespaceFilter = createFilter( - data => data.binding, - data => getAdmissionRequest(data), - (binding, kubernetesObject) => mismatchedNamespace(binding, kubernetesObject), - (binding, kubernetesObject) => commonLogMessage("namespaces", binding, kubernetesObject), -); - -export const mismatchedAnnotationsFilter = createFilter( - data => data.binding, - data => getAdmissionRequest(data), - (binding, kubernetesObject) => mismatchedAnnotations(binding, kubernetesObject), - (binding, kubernetesObject) => commonLogMessage("annotations", binding, kubernetesObject), -); - -export const mismatchedLabelsFilter = createFilter( - data => data.binding, - data => getAdmissionRequest(data), - (binding, kubernetesObject) => mismatchedLabels(binding, kubernetesObject), - (binding, kubernetesObject) => commonLogMessage("labels", binding, kubernetesObject), -); - -export const mismatchedKindFilter = createFilter( - data => data.binding, - data => data.request, - (binding, request) => mismatchedKind(binding, request), - (binding, request) => commonLogMessage("kind", binding, request), -); - -export const mismatchedVersionFilter = createFilter( - data => data.binding, - data => data.request, - (binding, request) => mismatchedVersion(binding, request), - (binding, request) => commonLogMessage("version", binding, request), -); - -export const carriesIgnoredNamespaceFilter = createFilter( - data => data.ignoredNamespaces, - data => getAdmissionRequest(data), - (ignoreArray, kubernetesObject) => carriesIgnoredNamespace(ignoreArray, kubernetesObject), - (ignoreArray, kubernetesObject) => commonLogMessage("ignored namespaces", kubernetesObject, ignoreArray), -); - -export const unbindableNamespacesFilter = createFilter( - data => data.binding, - data => getAdmissionRequest(data), - (binding, request) => uncarryableNamespace(binding, request), - // eslint-disable-next-line @typescript-eslint/no-unused-vars - (binding, request) => commonLogMessage("namespaces", binding), -); - -export const mismatchedGroupFilter = createFilter( - data => data.binding, - data => getAdmissionRequest(data), - (binding, kubernetesObject) => mismatchedGroup(binding, kubernetesObject), - (binding, kubernetesObject) => commonLogMessage("group", binding, kubernetesObject), -); - -export const mismatchedDeletionTimestampFilter = createFilter( - data => data.binding, - data => getAdmissionRequest(data), - (binding, kubernetesObject) => mismatchedDeletionTimestamp(binding, kubernetesObject), - (binding, kubernetesObject) => commonLogMessage("deletionTimestamp", binding, kubernetesObject), -); - -export const misboundDeleteWithDeletionTimestampFilter = createFilter( - data => data.binding, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - data => undefined, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - (binding, unused) => misboundDeleteWithDeletionTimestamp(binding), - // eslint-disable-next-line @typescript-eslint/no-unused-vars - (binding, unused) => commonLogMessage("deletionTimestamp", undefined, undefined), -); -export const mismatchedEventFilter = createFilter( - data => data.binding, - data => data.request, - (binding, request) => mismatchedEvent(binding, request), - (binding, request) => commonLogMessage("event", binding, request), -); - -export const uncarryableNamespaceFilter = createFilter( - data => data.capabilityNamespaces, - data => getAdmissionRequest(data), - (capabilityNamespaces, kubernetesObject) => uncarryableNamespace(capabilityNamespaces, kubernetesObject), - (capabilityNamespaces, kubernetesObject) => - commonLogMessage("namespace array", kubernetesObject, capabilityNamespaces), -); diff --git a/src/lib/filter/logMessages.test.ts b/src/lib/filter/logMessages.test.ts deleted file mode 100644 index 47e1e2f24..000000000 --- a/src/lib/filter/logMessages.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { expect, it } from "@jest/globals"; -import { commonLogMessage } from "./logMessages"; - -it("should display the default log message when the subject is unsupported", () => { - const result = commonLogMessage("not-supported", ["some input"], ["some criteria"]); - expect(result).toMatch( - /Ignoring Admission Callback: An undefined logging condition occurred. Filter input was '.+' and Filter criteria was '.+'/, - ); -}); diff --git a/src/lib/filter/logMessages.ts b/src/lib/filter/logMessages.ts deleted file mode 100644 index d2f2096a8..000000000 --- a/src/lib/filter/logMessages.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { FilterInput } from "../types"; -import { - carriedKind, - carriedName, - carriedNamespace, - carriedVersion, - definedAnnotations, - definedGroup, - definedKind, - definedLabels, - definedName, - definedNameRegex, - definedNamespaces, - definedVersion, -} from "./adjudicators"; - -const prefix = "Ignoring Admission Callback:"; - -const bindingKubernetesObjectCases = [ - "annotations", - "deletionTimestamp", - "labels", - "name regex", - "name", - "namespace array", - "namespace regexes", - "namespaces", -]; -const bindingAdmissionRequestCases = ["event", "group", "kind", "version"]; - -export const commonLogMessage = (subject: string, filterInput: FilterInput, filterCriteria?: FilterInput): string => { - if (bindingKubernetesObjectCases.includes(subject)) { - return getBindingKubernetesObjectMessage(subject, filterInput, filterCriteria); - } else if (bindingAdmissionRequestCases.includes(subject)) { - return getBindingAdmissionRequestMessage(subject, filterInput, filterCriteria); - } else if (subject === "ignored namespaces") { - return `${prefix} Object carries namespace '${carriedNamespace(filterInput)}' but ${subject} include '${JSON.stringify(filterCriteria)}'.`; - } else { - return getUndefinedLoggingConditionMessage(subject, filterInput, filterCriteria); - } -}; - -const getBindingAdmissionRequestMessage = (subject: string, filterInput: FilterInput, filterCriteria?: FilterInput) => { - switch (subject) { - case "group": - return `${prefix} Binding defines ${subject} '${definedGroup(filterInput)}' but Request declares '${carriedName(filterCriteria)}'.`; - case "event": - return `${prefix} Binding defines ${subject} '${definedKind(filterInput)}' but Request does not declare it.`; - case "version": - return `${prefix} Binding defines ${subject} '${definedVersion(filterInput)}' but Request declares '${carriedVersion(filterCriteria)}'.`; - case "kind": - return `${prefix} Binding defines ${subject} '${definedKind(filterInput)}' but Request declares '${carriedKind(filterCriteria)}'.`; - default: - return getUndefinedLoggingConditionMessage(subject, filterInput, filterCriteria); - } -}; - -const getBindingKubernetesObjectMessage = (subject: string, filterInput: FilterInput, filterCriteria?: FilterInput) => { - switch (subject) { - case "namespaces": - return `${prefix} Binding defines ${subject} '${definedNamespaces(filterInput)}' but Object carries '${carriedNamespace(filterCriteria)}'.`; - case "annotations": - return `${prefix} Binding defines ${subject} '${definedAnnotations(filterInput)}' but Object carries '${carriedName(filterCriteria)}'.`; - case "labels": - return `${prefix} Binding defines ${subject} '${definedLabels(filterInput)}' but Object carries '${carriedName(filterCriteria)}'.`; - case "name": - return `${prefix} Binding defines ${subject} '${definedName(filterInput)}' but Object carries '${carriedName(filterCriteria)}'.`; - case "namespace array": - return `${prefix} Object carries namespace '${carriedNamespace(filterInput)}' but namespaces allowed by Capability are '${JSON.stringify(filterCriteria)}'.`; - case "name regex": - return `${prefix} Binding defines ${subject} '${definedNameRegex(filterInput)}' but Object carries '${carriedName(filterCriteria)}'.`; - case "namespace regexes": - return `${prefix} Binding defines ${subject} '${definedNameRegex(filterInput)}' but Object carries '${carriedName(filterCriteria)}'.`; - case "deletionTimestamp": - return getDeletionTimestampLogMessage(filterInput, filterCriteria); - default: - return getUndefinedLoggingConditionMessage(subject, filterInput, filterCriteria); - } -}; - -const getUndefinedLoggingConditionMessage = ( - subject: string, - filterInput: FilterInput, - filterCriteria: FilterInput, -) => { - return `${prefix} An undefined logging condition occurred. Filter input was '${JSON.stringify(filterInput)}' and Filter criteria was '${JSON.stringify(filterCriteria)}'`; -}; - -const getDeletionTimestampLogMessage = (filterInput: FilterInput, filterCriteria: FilterInput) => { - if (filterInput === undefined && filterCriteria === undefined) { - return `${prefix} Cannot use deletionTimestamp filter on a DELETE operation.`; - } - return `${prefix} Binding defines deletionTimestamp but Object does not carry it.`; -}; diff --git a/src/lib/filter/shouldSkipRequest.test.ts b/src/lib/filter/shouldSkipRequest.test.ts index 429d21f39..59c818424 100644 --- a/src/lib/filter/shouldSkipRequest.test.ts +++ b/src/lib/filter/shouldSkipRequest.test.ts @@ -5,7 +5,7 @@ import { expect, describe, it } from "@jest/globals"; import { kind, modelToGroupVersionKind } from "kubernetes-fluent-client"; import * as fc from "fast-check"; import { CreatePod, DeletePod } from "../../fixtures/loader"; -import { shouldSkipRequest } from "./shouldSkipRequest"; +import { shouldSkipRequest } from "./filter"; import { AdmissionRequest, Binding } from "../types"; import { Event } from "../enums"; @@ -271,7 +271,7 @@ it("should reject when kind does not match", () => { const pod = CreatePod(); expect(shouldSkipRequest(binding, pod, [])).toMatch( - /Ignoring Admission Callback: Binding defines kind '.+' but Request declares 'not set'./, + /Ignoring Admission Callback: Binding defines kind '.+' but Request declares 'Pod'./, ); }); @@ -290,7 +290,7 @@ it("should reject when group does not match", () => { const pod = CreatePod(); expect(shouldSkipRequest(binding, pod, [])).toMatch( - /Ignoring Admission Callback: Binding defines group '.+' but Request declares '.+'./, + /Ignoring Admission Callback: Binding defines group '.+' but Request declares ''./, ); }); diff --git a/src/lib/filter/shouldSkipRequest.ts b/src/lib/filter/shouldSkipRequest.ts deleted file mode 100644 index 63f0cf0ef..000000000 --- a/src/lib/filter/shouldSkipRequest.ts +++ /dev/null @@ -1,63 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2023-Present The Pepr Authors - -import { AdmissionRequest, Binding } from "../types"; -import { - carriesIgnoredNamespaceFilter, - misboundDeleteWithDeletionTimestampFilter, - mismatchedAnnotationsFilter, - mismatchedDeletionTimestampFilter, - mismatchedEventFilter, - mismatchedGroupFilter, - mismatchedKindFilter, - mismatchedLabelsFilter, - mismatchedNameFilter, - mismatchedNameRegexFilter, - mismatchedNamespaceFilter, - mismatchedNamespaceRegexFilter, - mismatchedVersionFilter, - unbindableNamespacesFilter, - uncarryableNamespaceFilter, -} from "./filtersWithLogs"; -import { FilterChain } from "./filterChain"; - -/** - * shouldSkipRequest determines if a request should be skipped based on the binding filters. - * - * @param binding the action binding - * @param req the incoming request - * @returns - */ -export const shouldSkipRequest = ( - binding: Binding, - req: AdmissionRequest, - capabilityNamespaces: string[], - ignoredNamespaces?: string[], -): string => { - const filterChain = new FilterChain(); - - filterChain - .addFilter(misboundDeleteWithDeletionTimestampFilter) - .addFilter(mismatchedDeletionTimestampFilter) - .addFilter(mismatchedEventFilter) - .addFilter(mismatchedNameFilter) - .addFilter(mismatchedGroupFilter) - .addFilter(mismatchedVersionFilter) - .addFilter(mismatchedKindFilter) - .addFilter(unbindableNamespacesFilter) - .addFilter(uncarryableNamespaceFilter) - .addFilter(mismatchedNamespaceFilter) - .addFilter(mismatchedLabelsFilter) - .addFilter(mismatchedAnnotationsFilter) - .addFilter(mismatchedNamespaceRegexFilter) - .addFilter(mismatchedNameRegexFilter) - .addFilter(carriesIgnoredNamespaceFilter); - - const admissionFilterMessage = filterChain.execute({ - binding, - request: req, - capabilityNamespaces, - ignoredNamespaces, - }); - return admissionFilterMessage; -}; diff --git a/src/lib/mutate-processor.ts b/src/lib/mutate-processor.ts index 5441ff64d..2d4abd348 100644 --- a/src/lib/mutate-processor.ts +++ b/src/lib/mutate-processor.ts @@ -6,7 +6,7 @@ import { kind } from "kubernetes-fluent-client"; import { Capability } from "./capability"; import { Errors } from "./errors"; -import { shouldSkipRequest } from "./filter/shouldSkipRequest"; +import { shouldSkipRequest } from "./filter/filter"; import { MutateResponse } from "./k8s"; import { AdmissionRequest } from "./types"; import Log from "./logger"; diff --git a/src/lib/types.ts b/src/lib/types.ts index a73ce69c9..6a00c07be 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -350,16 +350,6 @@ export interface GroupVersionResource { readonly version: string; readonly resource: string; } - -export type FilterParams = { - binding: Binding; - request: AdmissionRequest; - capabilityNamespaces: string[]; - ignoredNamespaces?: string[]; -}; - -export type FilterInput = Binding | KubernetesObject | AdmissionRequest | string[] | undefined; - // DeepPartial utility type for deep optional properties export type DeepPartial = { [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; diff --git a/src/lib/validate-processor.ts b/src/lib/validate-processor.ts index 43571a330..611fa6411 100644 --- a/src/lib/validate-processor.ts +++ b/src/lib/validate-processor.ts @@ -4,7 +4,7 @@ import { kind } from "kubernetes-fluent-client"; import { Capability } from "./capability"; -import { shouldSkipRequest } from "./filter/shouldSkipRequest"; +import { shouldSkipRequest } from "./filter/filter"; import { ValidateResponse } from "./k8s"; import { AdmissionRequest } from "./types"; import Log from "./logger";