From 15b48d3616a8b8507eb8e60d7bab989bcd12ce0b Mon Sep 17 00:00:00 2001 From: f1ames Date: Mon, 2 Oct 2023 11:41:55 +0200 Subject: [PATCH] feat: add namespace filtering --- README.md | 29 ++++++--- admission-webhook/src/index.ts | 2 +- admission-webhook/src/utils/policy-manager.ts | 63 +++++++++++++++++-- .../src/utils/validation-server.ts | 32 ++++++++-- .../src/utils/validator-manager.ts | 37 +++++++---- examples/policy-binding-sample.yaml | 2 +- examples/policy-binding-scoped-sample.yaml | 11 ++++ examples/policy-sample-2.yaml | 8 +++ k8s/manifests/monokle-policy-binding-crd.yaml | 10 +++ 9 files changed, 161 insertions(+), 33 deletions(-) create mode 100644 examples/policy-binding-scoped-sample.yaml create mode 100644 examples/policy-sample-2.yaml diff --git a/README.md b/README.md index b4aa14a..f5dde2a 100644 --- a/README.md +++ b/README.md @@ -60,9 +60,7 @@ For getting info about CRDs: ```bash kubectl get crd kubectl describe crd policies.monokle.com - -kubectl get monoklepolicy -n webhook-demo -kubectl describe monoklepolicy policy-sample -n webhook-demo +kubectl describe crd policybindings.monokle.com ``` #### Testing @@ -70,16 +68,33 @@ kubectl describe monoklepolicy policy-sample -n webhook-demo First you need to create policy resource, for example: ```bash -kubectl -n webhook-demo apply -f examples/policy-sample.yaml -kubectl -n webhook-demo apply -f examples/policy-binding-sample.yaml +kubectl apply -f examples/policy-sample.yaml +kubectl apply -f examples/policy-sample-2.yaml +``` + +Then it needs to be bind to be used for validation. Either without scope (globally to all, but ignored namespaces) or with `matchResource` field: + +```bash +kubectl apply -f examples/policy-binding-sample.yaml +kubectl apply -f examples/policy-binding-scoped-sample.yaml ``` -> Admission controller will still work without policy resource but then it will be like running validation with all plugins disabled. +You can inspect deployed policies with: + +```bash +kubectl get policy +kubectl describe policy + +kubectl get policybinding +kubectl describe policybinding +``` Then you can try to create sample resource and see webhook response: ```bash -kubectl -n webhook-demo create -f examples/pod-warning.yaml +kubectl apply -f examples/pod-valid.yaml +kubectl apply -f examples/pod-warning.yaml +kubectl apply -f examples/pod-errors.yaml ``` #### Iterating diff --git a/admission-webhook/src/index.ts b/admission-webhook/src/index.ts index 66934d3..7f8f730 100644 --- a/admission-webhook/src/index.ts +++ b/admission-webhook/src/index.ts @@ -32,7 +32,7 @@ const logger = pino({ ); const policyManager = new PolicyManager(policyInformer, bindingsInformer, IGNORED_NAMESPACES, logger); - const validatorManager = new ValidatorManager(policyManager); + const validatorManager = new ValidatorManager(policyManager, logger); await policyManager.start(); diff --git a/admission-webhook/src/utils/policy-manager.ts b/admission-webhook/src/utils/policy-manager.ts index 4f43cc4..90e9506 100644 --- a/admission-webhook/src/utils/policy-manager.ts +++ b/admission-webhook/src/utils/policy-manager.ts @@ -1,7 +1,9 @@ +import {EventEmitter} from "events"; import pino from 'pino'; import {KubernetesObject} from '@kubernetes/client-node'; import {Config} from '@monokle/validation'; import {InformerWrapper} from './get-informer'; +import {AdmissionRequestObject} from "./validation-server"; export type MonoklePolicy = KubernetesObject & { spec: Config @@ -10,6 +12,11 @@ export type MonoklePolicy = KubernetesObject & { export type MonoklePolicyBindingConfiguration = { policyName: string validationActions: ['Warn'] + matchResources?: { + namespaceSelector?: { + matchLabels?: Record + } + } } export type MonoklePolicyBinding = KubernetesObject & { @@ -21,9 +28,9 @@ export type MonokleApplicablePolicy = { binding: MonoklePolicyBindingConfiguration } -export class PolicyManager { +export class PolicyManager extends EventEmitter{ private _policies = new Map(); // Map - private _bindings = new Map(); // Map // @TODO use policyName as key instead of bindingName? + private _bindings = new Map(); // Map constructor( private readonly _policyInformer: InformerWrapper, @@ -31,6 +38,8 @@ export class PolicyManager { private readonly _ignoreNamespaces: string[], private readonly _logger: ReturnType, ) { + super(); + this._policyInformer.informer.on('add', this.onPolicy.bind(this)); this._policyInformer.informer.on('update', this.onPolicy.bind(this)); this._policyInformer.informer.on('delete', this.onPolicyRemoval.bind(this)); @@ -45,11 +54,17 @@ export class PolicyManager { await this._bindingInformer.start(); } - getMatchingPolicies(): MonokleApplicablePolicy[] { // @TODO pass resource data so it can be matched according to matchResources definition (when it's implemented) + getMatchingPolicies(resource: AdmissionRequestObject, namespace: string): MonokleApplicablePolicy[] { + this._logger.debug({policies: this._policies.size, bindings: this._bindings.size}); + if (this._bindings.size === 0) { return []; } + if (this._ignoreNamespaces.includes(namespace)) { + return []; + } + return Array.from(this._bindings.values()) .map((binding) => { const policy = this._policies.get(binding.spec.policyName); @@ -59,6 +74,10 @@ export class PolicyManager { return null; } + if (binding.spec.matchResources && !this.isResourceMatching(binding, resource)) { + return null; + } + return { policy: policy.spec, binding: binding.spec @@ -71,23 +90,55 @@ export class PolicyManager { this._logger.debug({msg: 'Policy updated', policy}); this._policies.set(policy.metadata!.name!, policy); + + this.emit('policyUpdated', policy); } private onPolicyRemoval(policy: MonoklePolicy) { - this._logger.debug({msg: 'Policy updated', policy}); + this._logger.debug({msg: 'Policy removed', policy}); this._policies.delete(policy.metadata!.name!); + + this.emit('policyRemoved', policy); } private onBinding(binding: MonoklePolicyBinding) { this._logger.debug({msg: 'Binding updated', binding}); this._bindings.set(binding.metadata!.name!, binding); + + this.emit('bindingUpdated', binding); } private onBindingRemoval(binding: MonoklePolicyBinding) { - this._logger.debug({msg: 'Binding updated', binding}); + this._logger.debug({msg: 'Binding removed', binding}); this._bindings.delete(binding.metadata!.name!); + + this.emit('bindingRemoved', binding); } -} \ No newline at end of file + + private isResourceMatching(binding: MonoklePolicyBinding, resource: AdmissionRequestObject): boolean { + const namespaceMatchLabels = binding.spec.matchResources?.namespaceSelector?.matchLabels; + + this._logger.trace({ + msg: 'Checking if resource matches binding', + namespaceMatchLabels, + resourceMetadata: resource.metadata.labels + }); + + if (!namespaceMatchLabels) { + return true; + } + + for (const key of Object.keys(namespaceMatchLabels)) { + if (resource.metadata.labels?.[key] !== namespaceMatchLabels[key]) { + if (!(key === 'namespace' && resource.metadata.namespace === namespaceMatchLabels[key])) { + return false; + } + } + } + + return true; + } +} diff --git a/admission-webhook/src/utils/validation-server.ts b/admission-webhook/src/utils/validation-server.ts index a8e885d..b9bf668 100644 --- a/admission-webhook/src/utils/validation-server.ts +++ b/admission-webhook/src/utils/validation-server.ts @@ -3,7 +3,7 @@ import pino from 'pino'; import path from "path"; import {readFileSync} from "fs"; import {Resource} from "@monokle/validation"; -import {V1ValidatingWebhookConfiguration, V1ObjectMeta} from "@kubernetes/client-node"; +import {V1ObjectMeta} from "@kubernetes/client-node"; import {ValidatorManager} from "./validator-manager"; export type ValidationServerOptions = { @@ -11,9 +11,22 @@ export type ValidationServerOptions = { host: string; }; -export type AdmissionRequest = V1ValidatingWebhookConfiguration & { - request?: V1ObjectMeta & { - object?: Resource +export type AdmissionRequestObject = { + apiVersion: string + kind: string + metadata: V1ObjectMeta + spec: any + status: any +}; + +export type AdmissionRequest = { + apiVersion: string + kind: string + request: { + name: string + namespace: string + uid: string + object: AdmissionRequestObject } }; @@ -44,7 +57,6 @@ export class ValidationServer { this._shouldValidate = false; this._server = fastify({ - // @TODO do not require certs when running locally (for testing outside K8s cluster) https: { key: readFileSync(path.join('/run/secrets/tls', 'tls.key')), cert: readFileSync(path.join('/run/secrets/tls', 'tls.crt')) @@ -110,11 +122,19 @@ export class ValidationServer { if (!namespace) { this._logger.error({msg: 'No namespace found', metadata: body.request}); + return response; + } + const resource = body.request?.object; + if (!resource) { + this._logger.error({msg: 'No resource found', metadata: body.request}); return response; } - const validators = this._validators.getMatchingValidators(); + const validators = this._validators.getMatchingValidators(resource, namespace); + + this._logger.debug({msg: 'Matching validators', count: validators.length}); + if (validators.length === 0) { return response; } diff --git a/admission-webhook/src/utils/validator-manager.ts b/admission-webhook/src/utils/validator-manager.ts index ae9c190..aca87fa 100644 --- a/admission-webhook/src/utils/validator-manager.ts +++ b/admission-webhook/src/utils/validator-manager.ts @@ -1,5 +1,7 @@ +import pino from 'pino'; import {AnnotationSuppressor, Config, DisabledFixer, FingerprintSuppressor, MonokleValidator, RemotePluginLoader, ResourceParser, SchemaLoader} from "@monokle/validation"; -import {MonokleApplicablePolicy, PolicyManager} from "./policy-manager"; +import {MonokleApplicablePolicy, MonoklePolicy, PolicyManager} from "./policy-manager"; +import {AdmissionRequestObject} from './validation-server'; export type MonokleApplicableValidator = { validator: MonokleValidator, @@ -11,14 +13,19 @@ export class ValidatorManager { constructor( private readonly _policyManager: PolicyManager, + private readonly _logger: ReturnType, ) { - // @TODO implement this._policyManager.on(policyUpdated, this._reloadValidator) - // We should preload configuration here instead of in getMatchingValidators since - // it would affect performance of the admission webhook response time + this._policyManager.on('policyUpdated', async (policy: MonoklePolicy) => { + await this.setupValidator(policy.metadata!.name!, policy.spec); + }); + + this._policyManager.on('policyRemoved', async (policy: MonoklePolicy) => { + await this._validators.delete(policy.metadata!.name!); + }); } - getMatchingValidators(): MonokleApplicableValidator[] { - const matchingPolicies = this._policyManager.getMatchingPolicies(); + getMatchingValidators(resource: AdmissionRequestObject, namespace: string): MonokleApplicableValidator[] { + const matchingPolicies = this._policyManager.getMatchingPolicies(resource, namespace); if (matchingPolicies.length === 0) { return []; @@ -26,19 +33,22 @@ export class ValidatorManager { return matchingPolicies.map((policy) => { if (!this._validators.has(policy.binding.policyName)) { - this.setupValidator(policy.binding.policyName, policy.policy); + // This should not happen and means there is a bug in other place in the code. Raise warning and skip. + // Do not create validator instance here to keep this function sync and to keep processing time low. + this._logger.warn({msg: 'ValidatorManager: Validator not found', policyName: policy.binding.policyName}); + return null; } return { validator: this._validators.get(policy.binding.policyName)!, policy } - }); + }).filter((validator) => validator !== null) as MonokleApplicableValidator[]; } private async setupValidator(policyName: string, policy: Config) { if (this._validators.has(policyName)) { - this._validators.get(policyName)!.preload(policy); + await this._validators.get(policyName)!.preload(policy); } else { const validator = new MonokleValidator( { @@ -47,11 +57,14 @@ export class ValidatorManager { schemaLoader: new SchemaLoader(), suppressors: [new AnnotationSuppressor(), new FingerprintSuppressor()], fixer: new DisabledFixer(), - }, - policy + } ); + // Run separately (instead of passing config to constructor) to make sure that validator + // is ready when 'setupValidator' function call fulfills. + await validator.preload(policy); + this._validators.set(policyName, validator); } } -} \ No newline at end of file +} diff --git a/examples/policy-binding-sample.yaml b/examples/policy-binding-sample.yaml index 0049bed..681a397 100644 --- a/examples/policy-binding-sample.yaml +++ b/examples/policy-binding-sample.yaml @@ -1,7 +1,7 @@ apiVersion: monokle.com/v1alpha1 kind: MonoklePolicyBinding metadata: - name: policy-sample-binding + name: policy-binding-sample spec: policyName: "policy-sample" validationActions: [Warn] \ No newline at end of file diff --git a/examples/policy-binding-scoped-sample.yaml b/examples/policy-binding-scoped-sample.yaml new file mode 100644 index 0000000..f5591fd --- /dev/null +++ b/examples/policy-binding-scoped-sample.yaml @@ -0,0 +1,11 @@ +apiVersion: monokle.com/v1alpha1 +kind: MonoklePolicyBinding +metadata: + name: policy-binding-scoped-sample +spec: + policyName: "policy-sample-2" + validationActions: [Warn] + matchResources: + namespaceSelector: + matchLabels: + namespace: default \ No newline at end of file diff --git a/examples/policy-sample-2.yaml b/examples/policy-sample-2.yaml new file mode 100644 index 0000000..2c15f5f --- /dev/null +++ b/examples/policy-sample-2.yaml @@ -0,0 +1,8 @@ +apiVersion: monokle.com/v1alpha1 +kind: MonoklePolicy +metadata: + name: policy-sample-2 +spec: + plugins: + open-policy-agent: true + pod-security-standards: true \ No newline at end of file diff --git a/k8s/manifests/monokle-policy-binding-crd.yaml b/k8s/manifests/monokle-policy-binding-crd.yaml index 5d536f1..d7567d0 100644 --- a/k8s/manifests/monokle-policy-binding-crd.yaml +++ b/k8s/manifests/monokle-policy-binding-crd.yaml @@ -22,6 +22,16 @@ spec: items: type: string enum: [Warn] + matchResources: + type: object + properties: + namespaceSelector: + type: object + properties: + matchLabels: + type: object + additionalProperties: + type: string scope: Cluster names: plural: policybindings