diff --git a/Makefile b/Makefile index abd01142c..136af5a6c 100644 --- a/Makefile +++ b/Makefile @@ -48,8 +48,13 @@ all: manager # Run tests .PHONY: test -test: test-clean generate manifests test-clean - @GO111MODULE=on go test -race -v $(shell go list ./... | grep -v "e2e") -coverprofile coverage.out +test: gotestsum test-clean generate manifests test-clean + @GO111MODULE=on $(GOTEST) \ + --format pkgname-and-test-fails \ + --packages="$(shell go list ./... | grep -v "e2e")" \ + -- \ + -race \ + -coverprofile coverage.out .PHONY: test-clean test-clean: ## Clean tests cache @@ -680,6 +685,13 @@ syft: ## Download syft locally if necessary. test -s $(SYFT) && $(SYFT) --version | grep -q $(SYFT_VERSION) || \ $(call go-install-tool,$(SYFT),github.com/$(SYFT_LOOKUP)/cmd/syft@v$(SYFT_VERSION)) +GOTEST := $(LOCALBIN)/gotestsum +GOTEST_VERSION := 1.13.0 +GOTEST_LOOKUP := gotestyourself/gotestsum +gotestsum: + test -s $(GOTEST) && $(GOTEST) --version | grep -q $(GOTEST_VERSION) || \ + $(call go-install-tool,$(GOTEST),gotest.tools/gotestsum@v$(GOTEST_VERSION)) + HARPOON := $(LOCALBIN)/harpoon HARPOON_VERSION := v0.10.2 HARPOON_LOOKUP := alegrey91/harpoon diff --git a/charts/capsule/README.md b/charts/capsule/README.md index 018e745f7..065d9ca06 100644 --- a/charts/capsule/README.md +++ b/charts/capsule/README.md @@ -105,6 +105,10 @@ The following Values have changed key or Value: | Key | Type | Default | Description | |-----|------|---------|-------------| +| manager.apiPriorityAndFairness.enabled | bool | `false` | Change to `true` if you want to insulate the API calls made by Capsule admission controller activities. This will help ensure Capsule stability in busy clusters. Ref: https://kubernetes.io/docs/concepts/cluster-administration/flow-control/ | +| manager.apiPriorityAndFairness.flowApiVersion | string | `"flowcontrol.apiserver.k8s.io/v1"` | Declare ApiVersion used for Flow | +| manager.apiPriorityAndFairness.matchingPrecedence | int | `900` | Only the first matching FlowSchema for a given request matters. If multiple FlowSchemas match a single inbound request, it will be assigned based on the one with the highest matchingPrecedence. Ref: https://kubernetes.io/docs/concepts/cluster-administration/flow-control/#flowschema | +| manager.apiPriorityAndFairness.priorityLevelConfigurationSpec | object | See [values.yaml](values.yaml) | Priority level configuration. The block is directly forwarded into the priorityLevelConfiguration, so you can use whatever specification you want. ref: https://kubernetes.io/docs/concepts/cluster-administration/flow-control/#prioritylevelconfiguration | | manager.daemonsetStrategy | object | `{"type":"RollingUpdate"}` | [Daemonset Strategy](https://kubernetes.io/docs/tasks/manage-daemon/update-daemon-set/#creating-a-daemonset-with-rollingupdate-update-strategy) | | manager.deploymentStrategy | object | `{"type":"RollingUpdate"}` | [Deployment Strategy](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#strategy) | | manager.env | list | `[]` | Additional Environment Variables | @@ -274,6 +278,7 @@ The following Values have changed key or Value: | webhooks.hooks.managed.objectSelector | object | `{"matchExpressions":[{"key":"projectcapsule.dev/managed-by","operator":"In","values":["controller"]}]}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) | | webhooks.hooks.managed.opts | object | `{}` | Capsule Hook Options | | webhooks.hooks.managed.rules | list | `[{"apiGroups":["*"],"apiVersions":["*"],"operations":["CREATE","UPDATE","DELETE"],"resources":["*"],"scope":"Namespaced"}]` | [Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules) | +| webhooks.hooks.metadata | object | `{"enabled":true,"failurePolicy":"Ignore","matchConditions":[{"expression":"!has(request.subResource) || request.subResource == \"\"","name":"ignore-subresources"},{"expression":"request.resource.resource != \"events\"","name":"ignore-events"}],"matchPolicy":"Equivalent","namespaceSelector":{"matchExpressions":[{"key":"capsule.clastix.io/tenant","operator":"Exists"}]},"objectSelector":{},"opts":{},"reinvocationPolicy":"Never","rules":[{"apiGroups":["*"],"apiVersions":["*"],"operations":["CREATE","UPDATE"],"resources":["*"],"scope":"Namespaced"}]}` | Additional Metadata webhook | | webhooks.hooks.metadata.enabled | bool | `true` | Enable the Hook | | webhooks.hooks.metadata.failurePolicy | string | `"Ignore"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) | | webhooks.hooks.metadata.matchConditions | list | `[{"expression":"!has(request.subResource) || request.subResource == \"\"","name":"ignore-subresources"},{"expression":"request.resource.resource != \"events\"","name":"ignore-events"}]` | [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | @@ -347,6 +352,13 @@ The following Values have changed key or Value: | webhooks.hooks.resourcepools.pools.objectSelector | object | `{}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) | | webhooks.hooks.resourcepools.pools.opts | object | `{}` | Capsule Hook Options | | webhooks.hooks.resourcepools.pools.reinvocationPolicy | string | `"Never"` | [ReinvocationPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy) | +| webhooks.hooks.rulestatus | object | `{"enabled":true,"failurePolicy":"Fail","matchConditions":[],"matchPolicy":"Equivalent","namespaceSelector":{},"objectSelector":{}}` | Webhook for Rule Status ([Read More](https://projectcapsule.dev/docs/resource-management/customquotas/#admission)) | +| webhooks.hooks.rulestatus.enabled | bool | `true` | Enable the Hook | +| webhooks.hooks.rulestatus.failurePolicy | string | `"Fail"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) | +| webhooks.hooks.rulestatus.matchConditions | list | `[]` | [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | +| webhooks.hooks.rulestatus.matchPolicy | string | `"Equivalent"` | [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | +| webhooks.hooks.rulestatus.namespaceSelector | object | `{}` | [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) | +| webhooks.hooks.rulestatus.objectSelector | object | `{}` | [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) | | webhooks.hooks.serviceaccounts.enabled | bool | `true` | Enable the Hook | | webhooks.hooks.serviceaccounts.failurePolicy | string | `"Fail"` | [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) | | webhooks.hooks.serviceaccounts.matchConditions | list | `[]` | [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) | diff --git a/charts/capsule/ci/ha-values.yaml b/charts/capsule/ci/ha-values.yaml index abc7300c7..c04d20cfd 100644 --- a/charts/capsule/ci/ha-values.yaml +++ b/charts/capsule/ci/ha-values.yaml @@ -2,3 +2,5 @@ replicaCount: 2 manager: extraArgs: - "--enable-leader-election=true" + apiPriorityAndFairness: + enabled: true diff --git a/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml b/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml index 0045207cc..d2219ac49 100644 --- a/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml +++ b/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml @@ -68,6 +68,99 @@ spec: - deny - audit type: string + services: + description: Enforcement for Services. + properties: + externalNames: + description: ExternalNames defines additional constraints + for Services of type ExternalName. + properties: + hostnames: + description: |- + Hostnames restricts spec.externalName. + Empty means no additional hostname restriction once ExternalName is allowed by types. + items: + description: |- + At least one of Exact or Exp must be set. + Both may be set together. + properties: + exact: + description: Exact matches one of the provided + values exactly. + items: + type: string + minItems: 1 + type: array + exp: + description: Exp matches regular expression. + minLength: 1 + type: string + negate: + default: false + description: Negate regular Expression + type: boolean + type: object + x-kubernetes-validations: + - message: at least one of exact or exp must be set + rule: has(self.exact) || has(self.exp) + type: array + type: object + loadBalancers: + description: LoadBalancers defines additional constraints + for Services of type LoadBalancer. + properties: + cidrs: + description: |- + CIDRs restricts spec.loadBalancerIP and spec.loadBalancerSourceRanges. + Empty means no additional CIDR restriction once LoadBalancer is allowed by types. + items: + type: string + type: array + type: object + nodePorts: + description: NodePorts defines additional constraints for + nodePort values. + properties: + ports: + description: |- + Ports restricts explicitly requested nodePort values. + Empty means no additional port restriction once NodePort is allowed by types. + items: + properties: + from: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + to: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + required: + - from + - to + type: object + type: array + type: object + types: + description: |- + Types defines the Service types matched by this rule. + + Supported values: + - ClusterIP + - NodePort + - LoadBalancer + - ExternalName + items: + enum: + - ClusterIP + - NodePort + - LoadBalancer + - ExternalName + type: string + type: array + type: object workloads: description: Enforcement for Workloads (Pods) properties: @@ -247,6 +340,100 @@ spec: - deny - audit type: string + services: + description: Enforcement for Services. + properties: + externalNames: + description: ExternalNames defines additional constraints + for Services of type ExternalName. + properties: + hostnames: + description: |- + Hostnames restricts spec.externalName. + Empty means no additional hostname restriction once ExternalName is allowed by types. + items: + description: |- + At least one of Exact or Exp must be set. + Both may be set together. + properties: + exact: + description: Exact matches one of the provided + values exactly. + items: + type: string + minItems: 1 + type: array + exp: + description: Exp matches regular expression. + minLength: 1 + type: string + negate: + default: false + description: Negate regular Expression + type: boolean + type: object + x-kubernetes-validations: + - message: at least one of exact or exp must be + set + rule: has(self.exact) || has(self.exp) + type: array + type: object + loadBalancers: + description: LoadBalancers defines additional constraints + for Services of type LoadBalancer. + properties: + cidrs: + description: |- + CIDRs restricts spec.loadBalancerIP and spec.loadBalancerSourceRanges. + Empty means no additional CIDR restriction once LoadBalancer is allowed by types. + items: + type: string + type: array + type: object + nodePorts: + description: NodePorts defines additional constraints + for nodePort values. + properties: + ports: + description: |- + Ports restricts explicitly requested nodePort values. + Empty means no additional port restriction once NodePort is allowed by types. + items: + properties: + from: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + to: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + required: + - from + - to + type: object + type: array + type: object + types: + description: |- + Types defines the Service types matched by this rule. + + Supported values: + - ClusterIP + - NodePort + - LoadBalancer + - ExternalName + items: + enum: + - ClusterIP + - NodePort + - LoadBalancer + - ExternalName + type: string + type: array + type: object workloads: description: Enforcement for Workloads (Pods) properties: @@ -362,6 +549,100 @@ spec: - deny - audit type: string + services: + description: Enforcement for Services. + properties: + externalNames: + description: ExternalNames defines additional constraints + for Services of type ExternalName. + properties: + hostnames: + description: |- + Hostnames restricts spec.externalName. + Empty means no additional hostname restriction once ExternalName is allowed by types. + items: + description: |- + At least one of Exact or Exp must be set. + Both may be set together. + properties: + exact: + description: Exact matches one of the provided + values exactly. + items: + type: string + minItems: 1 + type: array + exp: + description: Exp matches regular expression. + minLength: 1 + type: string + negate: + default: false + description: Negate regular Expression + type: boolean + type: object + x-kubernetes-validations: + - message: at least one of exact or exp must be + set + rule: has(self.exact) || has(self.exp) + type: array + type: object + loadBalancers: + description: LoadBalancers defines additional constraints + for Services of type LoadBalancer. + properties: + cidrs: + description: |- + CIDRs restricts spec.loadBalancerIP and spec.loadBalancerSourceRanges. + Empty means no additional CIDR restriction once LoadBalancer is allowed by types. + items: + type: string + type: array + type: object + nodePorts: + description: NodePorts defines additional constraints + for nodePort values. + properties: + ports: + description: |- + Ports restricts explicitly requested nodePort values. + Empty means no additional port restriction once NodePort is allowed by types. + items: + properties: + from: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + to: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + required: + - from + - to + type: object + type: array + type: object + types: + description: |- + Types defines the Service types matched by this rule. + + Supported values: + - ClusterIP + - NodePort + - LoadBalancer + - ExternalName + items: + enum: + - ClusterIP + - NodePort + - LoadBalancer + - ExternalName + type: string + type: array + type: object workloads: description: Enforcement for Workloads (Pods) properties: diff --git a/charts/capsule/crds/capsule.clastix.io_tenants.yaml b/charts/capsule/crds/capsule.clastix.io_tenants.yaml index 41737437c..ce26495d8 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenants.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenants.yaml @@ -2515,6 +2515,100 @@ spec: - deny - audit type: string + services: + description: Enforcement for Services. + properties: + externalNames: + description: ExternalNames defines additional constraints + for Services of type ExternalName. + properties: + hostnames: + description: |- + Hostnames restricts spec.externalName. + Empty means no additional hostname restriction once ExternalName is allowed by types. + items: + description: |- + At least one of Exact or Exp must be set. + Both may be set together. + properties: + exact: + description: Exact matches one of the provided + values exactly. + items: + type: string + minItems: 1 + type: array + exp: + description: Exp matches regular expression. + minLength: 1 + type: string + negate: + default: false + description: Negate regular Expression + type: boolean + type: object + x-kubernetes-validations: + - message: at least one of exact or exp must be + set + rule: has(self.exact) || has(self.exp) + type: array + type: object + loadBalancers: + description: LoadBalancers defines additional constraints + for Services of type LoadBalancer. + properties: + cidrs: + description: |- + CIDRs restricts spec.loadBalancerIP and spec.loadBalancerSourceRanges. + Empty means no additional CIDR restriction once LoadBalancer is allowed by types. + items: + type: string + type: array + type: object + nodePorts: + description: NodePorts defines additional constraints + for nodePort values. + properties: + ports: + description: |- + Ports restricts explicitly requested nodePort values. + Empty means no additional port restriction once NodePort is allowed by types. + items: + properties: + from: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + to: + format: int32 + maximum: 65535 + minimum: 1 + type: integer + required: + - from + - to + type: object + type: array + type: object + types: + description: |- + Types defines the Service types matched by this rule. + + Supported values: + - ClusterIP + - NodePort + - LoadBalancer + - ExternalName + items: + enum: + - ClusterIP + - NodePort + - LoadBalancer + - ExternalName + type: string + type: array + type: object workloads: description: Enforcement for Workloads (Pods) properties: diff --git a/charts/capsule/templates/configuration.yaml b/charts/capsule/templates/configuration.yaml index da5597bbf..7f05d3af7 100644 --- a/charts/capsule/templates/configuration.yaml +++ b/charts/capsule/templates/configuration.yaml @@ -108,6 +108,48 @@ spec: timeoutSeconds: {{ $.Values.webhooks.validatingWebhooksTimeoutSeconds }} {{- end }} {{- end }} + {{- with .Values.webhooks.hooks.rulestatus }} + {{- if .enabled }} + {{- $any = true }} + - name: rulestatus.validating.projectcapsule.dev + {{- with .opts }} + opts: + {{- toYaml . | nindent 10 }} + {{- end }} + admissionReviewVersions: + - v1 + - v1beta1 + path: "/rulestatus/validating" + failurePolicy: {{ .failurePolicy }} + matchPolicy: {{ .matchPolicy }} + {{- with .namespaceSelector }} + namespaceSelector: + {{- toYaml . | nindent 10 }} + {{- end }} + {{- with .objectSelector }} + objectSelector: + {{- toYaml . | nindent 10 }} + {{- end }} + {{- with .matchConditions }} + matchConditions: + {{- toYaml . | nindent 10 }} + {{- end }} + rules: + - apiGroups: + - capsule.clastix.io + apiVersions: + - v1beta2 + operations: + - CREATE + - UPDATE + - DELETE + resources: + - rulestatuses + scope: 'Namespaced' + sideEffects: None + timeoutSeconds: {{ $.Values.webhooks.validatingWebhooksTimeoutSeconds }} + {{- end }} + {{- end }} {{- with .Values.webhooks.hooks.customresources }} {{- if .enabled }} {{- $any = true }} diff --git a/charts/capsule/templates/flowschema.yaml b/charts/capsule/templates/flowschema.yaml new file mode 100644 index 000000000..4ff5b160a --- /dev/null +++ b/charts/capsule/templates/flowschema.yaml @@ -0,0 +1,180 @@ +{{- if .Values.manager.apiPriorityAndFairness.enabled }} +--- +apiVersion: {{ .Values.manager.apiPriorityAndFairness.flowApiVersion }} +kind: FlowSchema +metadata: + name: {{ include "capsule.fullname" $ }} + labels: + {{- include "capsule.labels" $ | nindent 4 }} +spec: + matchingPrecedence: {{ .Values.manager.apiPriorityAndFairness.matchingPrecedence }} + priorityLevelConfiguration: + name: {{ include "capsule.fullname" $ }} + distinguisherMethod: + type: ByNamespace + rules: + - subjects: + - kind: ServiceAccount + serviceAccount: + namespace: {{ $.Release.Namespace }} + name: {{ include "capsule.serviceAccountName" $ }} + resourceRules: + - apiGroups: + - "" + resources: + - namespaces + - namespaces/status + clusterScope: true + namespaces: [] + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: + - "" + resources: + - pods + - services + - serviceaccounts + - configmaps + - secrets + - resourcequotas + - resourcequotas/status + - limitranges + - persistentvolumeclaims + - events + namespaces: + - '*' + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: + - rbac.authorization.k8s.io + resources: + - clusterroles + - clusterrolebindings + clusterScope: true + namespaces: [] + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: + - rbac.authorization.k8s.io + resources: + - roles + - rolebindings + namespaces: + - '*' + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: + - capsule.clastix.io + namespaces: + - '*' + resources: + - rulestatus + - rulestatus/status + - customquotas + - customquotas/status + - tenantresources + - tenantresources/status + - resourcepoolclaims + - resourcepoolclaims/status + - quantityledgers + - quantityledgers/status + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: + - capsule.clastix.io + clusterScope: true + namespaces: [] + resources: + - tenants + - tenants/status + - tenantowners + - tenantowners/status + - globalcustomquotas + - globalcustomquotas/status + - globaltenantresources + - globaltenantresources/status + - resourcepools + - resourcepools/status + - capsuleconfigurations + - capsuleconfigurations/status + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: + - networking.k8s.io + resources: + - networkpolicies + namespaces: + - '*' + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: + - admissionregistration.k8s.io + clusterScope: true + resources: + - mutatingwebhookconfigurations + - validatingwebhookconfigurations + namespaces: [] + verbs: + - get + - list + - watch + - update + - patch + - create + - apiGroups: + - coordination.k8s.io + namespaces: + - {{ $.Release.Namespace }} + resources: + - leases + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +{{- end }} diff --git a/charts/capsule/templates/prioritylevelconfiguration.yaml b/charts/capsule/templates/prioritylevelconfiguration.yaml new file mode 100644 index 000000000..33234ccb1 --- /dev/null +++ b/charts/capsule/templates/prioritylevelconfiguration.yaml @@ -0,0 +1,13 @@ +{{- if .Values.manager.apiPriorityAndFairness.enabled }} +--- +apiVersion: {{ .Values.manager.apiPriorityAndFairness.flowApiVersion }} +kind: PriorityLevelConfiguration +metadata: + name: {{ include "capsule.fullname" $ }} + labels: + {{- include "capsule.labels" $ | nindent 4 }} +{{- with .Values.manager.apiPriorityAndFairness.priorityLevelConfigurationSpec }} +spec: + {{- tpl (toYaml .) $ | nindent 2 }} +{{- end }} +{{- end }} diff --git a/charts/capsule/values.schema.json b/charts/capsule/values.schema.json index a7c798806..4aec85e95 100644 --- a/charts/capsule/values.schema.json +++ b/charts/capsule/values.schema.json @@ -274,6 +274,56 @@ "manager": { "type": "object", "properties": { + "apiPriorityAndFairness": { + "type": "object", + "properties": { + "enabled": { + "description": "Change to `true` if you want to insulate the API calls made by Capsule admission controller activities. This will help ensure Capsule stability in busy clusters. Ref: https://kubernetes.io/docs/concepts/cluster-administration/flow-control/", + "type": "boolean" + }, + "flowApiVersion": { + "description": "Declare ApiVersion used for Flow", + "type": "string" + }, + "matchingPrecedence": { + "description": "Only the first matching FlowSchema for a given request matters. If multiple FlowSchemas match a single inbound request, it will be assigned based on the one with the highest matchingPrecedence. Ref: https://kubernetes.io/docs/concepts/cluster-administration/flow-control/#flowschema", + "type": "integer" + }, + "priorityLevelConfigurationSpec": { + "description": "Priority level configuration. The block is directly forwarded into the priorityLevelConfiguration, so you can use whatever specification you want. ref: https://kubernetes.io/docs/concepts/cluster-administration/flow-control/#prioritylevelconfiguration", + "type": "object", + "properties": { + "limited": { + "type": "object", + "properties": { + "limitResponse": { + "type": "object", + "properties": { + "queuing": { + "type": "object", + "properties": { + "queueLengthLimit": { + "type": "integer" + } + } + }, + "type": { + "type": "string" + } + } + }, + "nominalConcurrencyShares": { + "type": "integer" + } + } + }, + "type": { + "type": "string" + } + } + } + } + }, "daemonsetStrategy": { "description": "[Daemonset Strategy](https://kubernetes.io/docs/tasks/manage-daemon/update-daemon-set/#creating-a-daemonset-with-rollingupdate-update-strategy)", "type": "object", @@ -1566,6 +1616,7 @@ } }, "metadata": { + "description": "Additional Metadata webhook", "type": "object", "properties": { "enabled": { @@ -2068,6 +2119,36 @@ } } }, + "rulestatus": { + "description": "Webhook for Rule Status ([Read More](https://projectcapsule.dev/docs/resource-management/customquotas/#admission))", + "type": "object", + "properties": { + "enabled": { + "description": "Enable the Hook", + "type": "boolean" + }, + "failurePolicy": { + "description": "[FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy)", + "type": "string" + }, + "matchConditions": { + "description": "[MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy)", + "type": "array" + }, + "matchPolicy": { + "description": "[MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy)", + "type": "string" + }, + "namespaceSelector": { + "description": "[NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector)", + "type": "object" + }, + "objectSelector": { + "description": "[ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector)", + "type": "object" + } + } + }, "serviceaccounts": { "type": "object", "properties": { diff --git a/charts/capsule/values.yaml b/charts/capsule/values.yaml index fb47320e8..dc4375138 100644 --- a/charts/capsule/values.yaml +++ b/charts/capsule/values.yaml @@ -113,6 +113,31 @@ rbac: # Manager Options manager: + apiPriorityAndFairness: + # -- Change to `true` if you want to insulate the API calls made by Capsule admission controller activities. + # This will help ensure Capsule stability in busy clusters. + # Ref: https://kubernetes.io/docs/concepts/cluster-administration/flow-control/ + enabled: false + + # -- Only the first matching FlowSchema for a given request matters. If multiple FlowSchemas match a single inbound request, it will be assigned based on the one with the highest matchingPrecedence. + # Ref: https://kubernetes.io/docs/concepts/cluster-administration/flow-control/#flowschema + matchingPrecedence: 900 + + # -- Declare ApiVersion used for Flow + flowApiVersion: "flowcontrol.apiserver.k8s.io/v1" + + # -- Priority level configuration. + # The block is directly forwarded into the priorityLevelConfiguration, so you can use whatever specification you want. + # ref: https://kubernetes.io/docs/concepts/cluster-administration/flow-control/#prioritylevelconfiguration + # @default -- See [values.yaml](values.yaml) + priorityLevelConfigurationSpec: + type: Limited + limited: + nominalConcurrencyShares: 10 + limitResponse: + queuing: + queueLengthLimit: 50 + type: Queue # Manager RBAC rbac: @@ -584,6 +609,25 @@ webhooks: # -- [Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules) rules: [] + # -- Webhook for Rule Status ([Read More](https://projectcapsule.dev/docs/resource-management/customquotas/#admission)) + rulestatus: + # -- Enable the Hook + enabled: true + # -- [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) + failurePolicy: Fail + # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) + matchPolicy: Equivalent + # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) + objectSelector: {} + # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) + namespaceSelector: {} + # matchExpressions: + # - key: capsule.clastix.io/tenant + # operator: Exists + # -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) + matchConditions: [] + + # -- Additional Metadata webhook metadata: # -- Enable the Hook enabled: true diff --git a/cmd/controller/main.go b/cmd/controller/main.go index 47884a224..2f9184216 100644 --- a/cmd/controller/main.go +++ b/cmd/controller/main.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" goRuntime "runtime" + "time" flag "github.com/spf13/pflag" _ "go.uber.org/automaxprocs" @@ -75,6 +76,7 @@ import ( "github.com/projectcapsule/capsule/internal/webhook/resourcepool" "github.com/projectcapsule/capsule/internal/webhook/route" podrules "github.com/projectcapsule/capsule/internal/webhook/rules/pods/validation" + servicerules "github.com/projectcapsule/capsule/internal/webhook/rules/services/validation" "github.com/projectcapsule/capsule/internal/webhook/service" "github.com/projectcapsule/capsule/internal/webhook/serviceaccounts" tenantmutation "github.com/projectcapsule/capsule/internal/webhook/tenant/mutation" @@ -127,6 +129,8 @@ func main() { clientConnectionBurst int32 webhookPort int + + cacheSyncTimeout time.Duration ) var goFlagSet goflag.FlagSet @@ -148,11 +152,17 @@ func main() { "Enabling this will ensure there is only one active controller manager.", ) flag.IntVar( - &controllerConfig.MaxConcurrentReconciles, + &controllerConfig.Runtime.MaxConcurrentReconciles, "workers", 1, "MaxConcurrentReconciles is the maximum number of concurrent Reconciles which can be run.", ) + flag.DurationVar( + &cacheSyncTimeout, + "cache-sync-timeout", + 0, + "The timeout used when waiting for controller cache synchronization. If unset or 0, the controller-runtime default is used.", + ) flag.StringVar( &metricsAddr, "metrics-addr", @@ -272,6 +282,10 @@ func main() { os.Exit(1) } + if cacheSyncTimeout > 0 { + controllerConfig.Runtime.CacheSyncTimeout = cacheSyncTimeout + } + if len(controllerConfig.ConfigurationName) == 0 { setupLog.Error(fmt.Errorf("missing CapsuleConfiguration resource name"), "unable to start manager") os.Exit(1) @@ -583,6 +597,7 @@ func main() { ), route.Service( service.Handler( + servicerules.ServiceRules(regexCache), service.Validating(), ), ), @@ -669,6 +684,7 @@ func main() { cfgvalidation.OwnerHandler(), ), ), + route.RulesValidating(cfg), ) nodeWebhookSupported, _ := utils.NodeWebhookSupported(kubeVersion) diff --git a/e2e/namespace_metadata_controller_test.go b/e2e/namespace_metadata_controller_test.go index 28e076ed4..7b26975eb 100644 --- a/e2e/namespace_metadata_controller_test.go +++ b/e2e/namespace_metadata_controller_test.go @@ -21,7 +21,7 @@ import ( var _ = Describe("creating a Namespace for a Tenant with additional metadata", Ordered, Label("namespace", "metadata"), func() { tnt := &capsulev1beta2.Tenant{ ObjectMeta: metav1.ObjectMeta{ - Name: "e2e-tenant-metadata", + Name: "e2e-tenant-ns-metadata", Labels: map[string]string{ "env": "e2e", }, diff --git a/e2e/rules_enforce_services_test.go b/e2e/rules_enforce_services_test.go new file mode 100644 index 000000000..2833f97db --- /dev/null +++ b/e2e/rules_enforce_services_test.go @@ -0,0 +1,1208 @@ +// Copyright 2020-2026 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "context" + "fmt" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/client-go/kubernetes" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + capsuleapi "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/api/rbac" + "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +var _ = Describe("enforcing service namespace rules", Ordered, Label("tenant", "rules", "enforce", "services"), func() { + const ownerName = "e2e-rules-services" + + var ( + tnt *capsulev1beta2.Tenant + tenantRules []*rules.NamespaceRuleBodyTenant + ) + + externalNameByExpression := func(expression string) capsuleapi.ExpressionMatch { + return capsuleapi.ExpressionMatch{ + ExpressionRegex: capsuleapi.ExpressionRegex{ + Expression: expression, + }, + } + } + + externalNameByNegatedExpression := func(expression string) capsuleapi.ExpressionMatch { + return capsuleapi.ExpressionMatch{ + ExpressionRegex: capsuleapi.ExpressionRegex{ + Expression: expression, + Negate: true, + }, + } + } + + externalNameByExact := func(exact ...string) capsuleapi.ExpressionMatch { + return capsuleapi.ExpressionMatch{ + Exact: exact, + } + } + + externalNameByMatch := func(exact []string, expression string) capsuleapi.ExpressionMatch { + return capsuleapi.ExpressionMatch{ + ExpressionRegex: capsuleapi.ExpressionRegex{ + Expression: expression, + }, + Exact: exact, + } + } + + serviceTypeRule := func(action rules.ActionType, serviceTypes ...rules.ServiceType) *rules.NamespaceRuleBodyTenant { + return &rules.NamespaceRuleBodyTenant{ + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: action, + Services: rules.NamespaceRuleEnforceServicesBody{ + Types: serviceTypes, + }, + }, + }, + } + } + + loadBalancerCIDRRule := func(action rules.ActionType, cidrs ...string) *rules.NamespaceRuleBodyTenant { + return &rules.NamespaceRuleBodyTenant{ + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: action, + Services: rules.NamespaceRuleEnforceServicesBody{ + LoadBalancers: &rules.ServiceLoadBalancerRule{ + CIDRs: cidrs, + }, + }, + }, + }, + } + } + + externalNameRule := func(action rules.ActionType, hostnames ...capsuleapi.ExpressionMatch) *rules.NamespaceRuleBodyTenant { + return &rules.NamespaceRuleBodyTenant{ + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: action, + Services: rules.NamespaceRuleEnforceServicesBody{ + ExternalNames: &rules.ServiceExternalNameRule{ + Hostnames: hostnames, + }, + }, + }, + }, + } + } + + nodePortRule := func(action rules.ActionType, ports ...rules.ServiceNodePortRange) *rules.NamespaceRuleBodyTenant { + return &rules.NamespaceRuleBodyTenant{ + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: action, + Services: rules.NamespaceRuleEnforceServicesBody{ + NodePorts: &rules.ServiceNodePortRule{ + Ports: ports, + }, + }, + }, + }, + } + } + + selectedRule := func( + selector map[string]string, + rule *rules.NamespaceRuleBodyTenant, + ) *rules.NamespaceRuleBodyTenant { + rule.NamespaceSelector = &metav1.LabelSelector{ + MatchLabels: selector, + } + + return rule + } + + baseTenantRules := func() []*rules.NamespaceRuleBodyTenant { + return []*rules.NamespaceRuleBodyTenant{ + serviceTypeRule( + rules.ActionTypeAllow, + rules.ServiceTypeClusterIP, + rules.ServiceTypeNodePort, + rules.ServiceTypeLoadBalancer, + rules.ServiceTypeExternalName, + ), + loadBalancerCIDRRule( + rules.ActionTypeAllow, + "10.0.0.2/32", + "10.0.1.0/24", + ), + externalNameRule( + rules.ActionTypeAllow, + externalNameByExact("internal.git.com"), + externalNameByExpression(".*\\.example\\.com"), + externalNameByMatch( + []string{"combined.internal.git.com"}, + "combined\\..*\\.example\\.com", + ), + ), + nodePortRule( + rules.ActionTypeAllow, + rules.ServiceNodePortRange{ + From: 30000, + To: 30100, + }, + rules.ServiceNodePortRange{ + From: 30500, + To: 30500, + }, + ), + nodePortRule( + rules.ActionTypeDeny, + rules.ServiceNodePortRange{ + From: 30090, + To: 30090, + }, + ), + loadBalancerCIDRRule( + rules.ActionTypeDeny, + "10.0.66.0/24", + ), + externalNameRule( + rules.ActionTypeAudit, + externalNameByExpression("audit\\..*"), + ), + selectedRule( + map[string]string{ + "environment": "prod", + }, + loadBalancerCIDRRule( + rules.ActionTypeAllow, + "10.0.171.0/24", + ), + ), + selectedRule( + map[string]string{ + "external-policy": "restricted", + }, + externalNameRule( + rules.ActionTypeDeny, + externalNameByExact("blocked.example.com"), + ), + ), + selectedRule( + map[string]string{ + "negate": "true", + }, + externalNameRule( + rules.ActionTypeDeny, + externalNameByNegatedExpression("trusted\\..*"), + ), + ), + selectedRule( + map[string]string{ + "negate": "true", + }, + externalNameRule( + rules.ActionTypeAllow, + externalNameByExpression("trusted\\..*"), + ), + ), + } + } + + newTenant := func() *capsulev1beta2.Tenant { + return &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-rule-services", + Labels: map[string]string{ + "env": "e2e", + }, + }, + Spec: capsulev1beta2.TenantSpec{ + Owners: rbac.OwnerListSpec{ + { + CoreOwnerSpec: rbac.CoreOwnerSpec{ + UserSpec: rbac.UserSpec{ + Name: ownerName, + Kind: "User", + }, + }, + }, + }, + ServiceOptions: &capsuleapi.ServiceOptions{ + AllowedServices: &capsuleapi.AllowedServices{ + ExternalName: ptr.To(true), + LoadBalancer: ptr.To(true), + NodePort: ptr.To(true), + }, + }, + Rules: tenantRules, + }, + } + } + + type expectedServiceStatusRule struct { + action rules.ActionType + types []rules.ServiceType + loadBalancerCIDRs []string + nodePortRanges []rules.ServiceNodePortRange + externalExpressions []string + externalExact [][]string + externalNegated []bool + } + + expectNamespaceStatusRules := func(nsName string, want []expectedServiceStatusRule) { + Eventually(func(g Gomega) { + nsStatus := &capsulev1beta2.RuleStatus{} + + g.Expect(k8sClient.Get( + context.Background(), + client.ObjectKey{ + Name: meta.NameForManagedRuleStatus(), + Namespace: nsName, + }, + nsStatus, + )).To(Succeed()) + + g.Expect(nsStatus.Status.Rules).To(HaveLen(len(want))) + + for i, expected := range want { + got := nsStatus.Status.Rules[i] + + g.Expect(got).NotTo(BeNil()) + g.Expect(got.Enforce).NotTo(BeNil()) + g.Expect(got.Enforce.Action).To(Equal(expected.action)) + + if len(expected.types) == 0 { + g.Expect(got.Enforce.Services.Types).To(BeEmpty()) + } else { + g.Expect(got.Enforce.Services.Types).To(Equal(expected.types)) + } + + if len(expected.loadBalancerCIDRs) == 0 { + if got.Enforce.Services.LoadBalancers != nil { + g.Expect(got.Enforce.Services.LoadBalancers.CIDRs).To(BeEmpty()) + } + } else { + g.Expect(got.Enforce.Services.LoadBalancers).NotTo(BeNil()) + g.Expect(got.Enforce.Services.LoadBalancers.CIDRs).To(Equal(expected.loadBalancerCIDRs)) + } + + if len(expected.nodePortRanges) == 0 { + if got.Enforce.Services.NodePorts != nil { + g.Expect(got.Enforce.Services.NodePorts.Ports).To(BeEmpty()) + } + } else { + g.Expect(got.Enforce.Services.NodePorts).NotTo(BeNil()) + g.Expect(got.Enforce.Services.NodePorts.Ports).To(Equal(expected.nodePortRanges)) + } + + wantHostnames := len(expected.externalExpressions) + if len(expected.externalExact) > wantHostnames { + wantHostnames = len(expected.externalExact) + } + + if wantHostnames == 0 { + if got.Enforce.Services.ExternalNames != nil { + g.Expect(got.Enforce.Services.ExternalNames.Hostnames).To(BeEmpty()) + } + + continue + } + + g.Expect(got.Enforce.Services.ExternalNames).NotTo(BeNil()) + g.Expect(got.Enforce.Services.ExternalNames.Hostnames).To(HaveLen(wantHostnames)) + + for j := 0; j < wantHostnames; j++ { + match := got.Enforce.Services.ExternalNames.Hostnames[j] + + if len(expected.externalExpressions) > j { + g.Expect(match.Expression).To(Equal(expected.externalExpressions[j])) + } else { + g.Expect(match.Expression).To(BeEmpty()) + } + + if len(expected.externalExact) > j { + g.Expect(match.Exact).To(Equal(expected.externalExact[j])) + } else { + g.Expect(match.Exact).To(BeEmpty()) + } + + if len(expected.externalNegated) > j { + g.Expect(match.Negate).To(Equal(expected.externalNegated[j])) + } else { + g.Expect(match.Negate).To(BeFalse()) + } + } + } + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + baseStatusRules := func() []expectedServiceStatusRule { + return []expectedServiceStatusRule{ + { + action: rules.ActionTypeAllow, + types: []rules.ServiceType{ + rules.ServiceTypeClusterIP, + rules.ServiceTypeNodePort, + rules.ServiceTypeLoadBalancer, + rules.ServiceTypeExternalName, + }, + }, + { + action: rules.ActionTypeAllow, + loadBalancerCIDRs: []string{ + "10.0.0.2/32", + "10.0.1.0/24", + }, + }, + { + action: rules.ActionTypeAllow, + externalExpressions: []string{ + "", + ".*\\.example\\.com", + "combined\\..*\\.example\\.com", + }, + externalExact: [][]string{ + { + "internal.git.com", + }, + nil, + { + "combined.internal.git.com", + }, + }, + }, + { + action: rules.ActionTypeAllow, + nodePortRanges: []rules.ServiceNodePortRange{ + { + From: 30000, + To: 30100, + }, + { + From: 30500, + To: 30500, + }, + }, + }, + { + action: rules.ActionTypeDeny, + nodePortRanges: []rules.ServiceNodePortRange{ + { + From: 30090, + To: 30090, + }, + }, + }, + { + action: rules.ActionTypeDeny, + loadBalancerCIDRs: []string{ + "10.0.66.0/24", + }, + }, + { + action: rules.ActionTypeAudit, + externalExpressions: []string{ + "audit\\..*", + }, + }, + } + } + + updateTenantRules := func(next []*rules.NamespaceRuleBodyTenant) { + UpdateTenantEventually(tnt, func(current *capsulev1beta2.Tenant) { + current.Spec.Rules = next + }) + + tnt.Spec.Rules = next + } + + createNamespace := func(labels map[string]string) *corev1.Namespace { + if labels == nil { + labels = map[string]string{} + } + + labels[meta.TenantLabel] = tnt.GetName() + + ns := NewNamespace("", labels) + + NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) + NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) + + return ns + } + + createServiceAndExpectDenied := func(cs kubernetes.Interface, nsName string, svc *corev1.Service, substrings ...string) { + base := svc.DeepCopy() + baseName := base.Name + if baseName == "" { + baseName = "svc" + } + + Eventually(func() error { + candidate := base.DeepCopy() + candidate.Name = fmt.Sprintf("%s-%d", baseName, time.Now().UnixNano()%1e6) + + _, err := cs.CoreV1().Services(nsName).Create(context.Background(), candidate, metav1.CreateOptions{}) + if err == nil { + _ = cs.CoreV1().Services(nsName).Delete(context.Background(), candidate.Name, metav1.DeleteOptions{}) + + return fmt.Errorf("expected service create to be denied, but it succeeded") + } + + if apierrors.IsAlreadyExists(err) { + return fmt.Errorf("unexpected AlreadyExists: %v", err) + } + + msg := err.Error() + for _, substring := range substrings { + if !strings.Contains(msg, substring) { + return fmt.Errorf("expected error to contain %q, got: %s", substring, msg) + } + } + + return nil + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + createServiceAndExpectAllowed := func(cs kubernetes.Interface, nsName string, svc *corev1.Service) { + EventuallyCreation(func() error { + _, err := cs.CoreV1().Services(nsName).Create(context.Background(), svc, metav1.CreateOptions{}) + + return err + }).Should(Succeed()) + } + + updateServiceAndExpectDenied := func( + cs kubernetes.Interface, + nsName string, + svcName string, + mutate func(*corev1.Service), + substrings ...string, + ) { + Eventually(func() error { + svc, err := cs.CoreV1().Services(nsName).Get(context.Background(), svcName, metav1.GetOptions{}) + if err != nil { + return err + } + + mutate(svc) + + _, err = cs.CoreV1().Services(nsName).Update(context.Background(), svc, metav1.UpdateOptions{}) + if err == nil { + return fmt.Errorf("expected service update to be denied, but it succeeded") + } + + msg := err.Error() + for _, substring := range substrings { + if !strings.Contains(msg, substring) { + return fmt.Errorf("expected error to contain %q, got: %s", substring, msg) + } + } + + return nil + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + expectAuditEvent := func( + cs kubernetes.Interface, + namespace string, + serviceName string, + substrings ...string, + ) { + Eventually(func() error { + evt, err := cs.CoreV1().Events(namespace).List(context.Background(), metav1.ListOptions{}) + if err != nil { + return err + } + + for _, e := range evt.Items { + if e.Reason != events.ReasonNamespaceRuleAudit { + continue + } + + if e.InvolvedObject.Kind != "Service" { + continue + } + + eventServiceName := e.InvolvedObject.Name + if eventServiceName != serviceName && !strings.HasPrefix(eventServiceName, serviceName+"-") { + continue + } + + message := e.Message + + matched := true + for _, substring := range substrings { + if !strings.Contains(message, substring) { + matched = false + + break + } + } + + if matched { + return nil + } + } + + return fmt.Errorf( + "expected audit event for service %q containing %q", + serviceName, + substrings, + ) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + servicePort := func() corev1.ServicePort { + return corev1.ServicePort{ + Name: "http", + Protocol: corev1.ProtocolTCP, + Port: 8080, + TargetPort: intstr.FromInt(8080), + } + } + + clusterIPService := func(name string) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{ + servicePort(), + }, + }, + } + } + + nodePortService := func(name string, nodePort int32) *corev1.Service { + port := servicePort() + port.NodePort = nodePort + + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{ + port, + }, + }, + } + } + + nodePortServiceWithoutExplicitNodePort := func(name string) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: []corev1.ServicePort{ + servicePort(), + }, + }, + } + } + + createServiceAndExpectDeniedOneOf := func( + cs kubernetes.Interface, + nsName string, + svc *corev1.Service, + alternatives ...[]string, + ) { + base := svc.DeepCopy() + baseName := base.Name + if baseName == "" { + baseName = "svc" + } + + Eventually(func() error { + candidate := base.DeepCopy() + candidate.Name = fmt.Sprintf("%s-%d", baseName, time.Now().UnixNano()%1e6) + + _, err := cs.CoreV1().Services(nsName).Create(context.Background(), candidate, metav1.CreateOptions{}) + if err == nil { + _ = cs.CoreV1().Services(nsName).Delete(context.Background(), candidate.Name, metav1.DeleteOptions{}) + + return fmt.Errorf("expected service create to be denied, but it succeeded") + } + + if apierrors.IsAlreadyExists(err) { + return fmt.Errorf("unexpected AlreadyExists: %v", err) + } + + msg := err.Error() + + for _, alternative := range alternatives { + matches := true + + for _, substring := range alternative { + if !strings.Contains(msg, substring) { + matches = false + + break + } + } + + if matches { + return nil + } + } + + return fmt.Errorf( + "expected error to match one of %v, got: %s", + alternatives, + msg, + ) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + loadBalancerService := func( + name string, + loadBalancerIP string, + sourceRanges []string, + allocateLoadBalancerNodePorts *bool, + ) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + LoadBalancerIP: loadBalancerIP, + LoadBalancerSourceRanges: sourceRanges, + AllocateLoadBalancerNodePorts: allocateLoadBalancerNodePorts, + Ports: []corev1.ServicePort{ + servicePort(), + }, + }, + } + } + + externalNameService := func(name string, externalName string) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeExternalName, + ExternalName: externalName, + Ports: []corev1.ServicePort{ + servicePort(), + }, + }, + } + } + + BeforeEach(func() { + tenantRules = baseTenantRules() + }) + + JustBeforeEach(func() { + tnt = newTenant() + + EventuallyCreation(func() error { + tnt.ResourceVersion = "" + + return k8sClient.Create(context.TODO(), tnt) + }).Should(Succeed()) + + TenantReady(tnt, metav1.ConditionTrue, defaultTimeoutInterval) + }) + + JustAfterEach(func() { + EventuallyDeletion(tnt) + }) + + It("requires or validates LoadBalancer nodePorts when allocation is enabled and nodePort ranges are configured", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDeniedOneOf( + cs, + ns.Name, + loadBalancerService("lb-auto-node-port-denied", "10.0.0.2", nil, nil), + []string{ + "requires explicit spec.ports[*].nodePort", + "nodePort ranges are enforced by namespace rule", + }, + []string{ + "nodePort", + "not allowed by namespace rule", + "Allowed ranges", + "30000-30100", + "30500", + }, + ) + }) + + It("stores matching tenant service rules as independent status rule blocks", func() { + ns := createNamespace(nil) + + expectNamespaceStatusRules(ns.GetName(), baseStatusRules()) + }) + + It("stores namespace-selector matched service rules as additional independent status rule blocks", func() { + ns := createNamespace(map[string]string{ + "environment": "prod", + }) + + want := baseStatusRules() + want = append(want, expectedServiceStatusRule{ + action: rules.ActionTypeAllow, + loadBalancerCIDRs: []string{ + "10.0.171.0/24", + }, + }) + + expectNamespaceStatusRules(ns.GetName(), want) + }) + + It("stores namespace-selector matched negated service rules as independent status rule blocks", func() { + ns := createNamespace(map[string]string{ + "negate": "true", + }) + + want := baseStatusRules() + want = append(want, + expectedServiceStatusRule{ + action: rules.ActionTypeDeny, + externalExpressions: []string{ + "trusted\\..*", + }, + externalNegated: []bool{ + true, + }, + }, + expectedServiceStatusRule{ + action: rules.ActionTypeAllow, + externalExpressions: []string{ + "trusted\\..*", + }, + }, + ) + + expectNamespaceStatusRules(ns.GetName(), want) + }) + + It("allows a listed ClusterIP service type", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + serviceTypeRule( + rules.ActionTypeAllow, + rules.ServiceTypeClusterIP, + ), + }) + + ns := createNamespace(nil) + + expectNamespaceStatusRules(ns.Name, []expectedServiceStatusRule{ + { + action: rules.ActionTypeAllow, + types: []rules.ServiceType{ + rules.ServiceTypeClusterIP, + }, + }, + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectAllowed(cs, ns.Name, clusterIPService("cluster-ip-allowed")) + }) + + It("denies a service type missing from services.types and reports allowed service types", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + serviceTypeRule( + rules.ActionTypeAllow, + rules.ServiceTypeClusterIP, + ), + }) + + ns := createNamespace(nil) + + expectNamespaceStatusRules(ns.Name, []expectedServiceStatusRule{ + { + action: rules.ActionTypeAllow, + types: []rules.ServiceType{ + rules.ServiceTypeClusterIP, + }, + }, + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDenied(cs, ns.Name, externalNameService("external-name-type-denied", "internal.git.com"), + "service type", + "ExternalName", + "not allowed", + "Allowed service types", + "ClusterIP", + ) + }) + + It("allows exact, regex, and combined ExternalName hostname matchers", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectAllowed(cs, ns.Name, externalNameService("external-exact-allowed", "internal.git.com")) + createServiceAndExpectAllowed(cs, ns.Name, externalNameService("external-regex-allowed", "api.example.com")) + createServiceAndExpectAllowed(cs, ns.Name, externalNameService("external-combined-exact-allowed", "combined.internal.git.com")) + createServiceAndExpectAllowed(cs, ns.Name, externalNameService("external-combined-regex-allowed", "combined.api.example.com")) + }) + + It("denies non-matching ExternalName hostnames and reports allowed hostname rules", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDenied(cs, ns.Name, externalNameService("external-denied", "api.bad.com"), + "externalName hostname", + "api.bad.com", + "spec.externalName", + "not allowed", + "Allowed hostnames", + "exact: internal.git.com", + "exp: .*\\.example\\.com", + ) + }) + + It("audits a matching ExternalName but still denies when no allow rule matches", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + serviceTypeRule( + rules.ActionTypeAllow, + rules.ServiceTypeExternalName, + ), + externalNameRule( + rules.ActionTypeAudit, + externalNameByExpression("audit\\..*"), + ), + externalNameRule( + rules.ActionTypeAllow, + externalNameByExpression("allowed\\..*"), + ), + }) + + ns := createNamespace(nil) + + expectNamespaceStatusRules(ns.Name, []expectedServiceStatusRule{ + { + action: rules.ActionTypeAllow, + types: []rules.ServiceType{ + rules.ServiceTypeExternalName, + }, + }, + { + action: rules.ActionTypeAudit, + externalExpressions: []string{ + "audit\\..*", + }, + }, + { + action: rules.ActionTypeAllow, + externalExpressions: []string{ + "allowed\\..*", + }, + }, + }) + + svc := externalNameService("external-audit-denied", "audit.internal") + + createServiceAndExpectDenied(clusterAdminClient(), ns.Name, svc, + "externalName hostname", + "audit.internal", + "not allowed", + "Allowed hostnames", + "allowed\\..*", + ) + + expectAuditEvent(clusterAdminClient(), ns.Name, svc.Name, + "matched audit", + "audit\\..*", + ) + }) + + It("denies a later selected ExternalName deny rule after an earlier allow rule matched", func() { + ns := createNamespace(map[string]string{ + "external-policy": "restricted", + }) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDenied(cs, ns.Name, externalNameService("external-selected-denied", "blocked.example.com"), + "externalName hostname", + "blocked.example.com", + "denied", + "exact: blocked.example.com", + ) + }) + + It("applies namespace-selector matched negated ExternalName rules after base rules", func() { + ns := createNamespace(map[string]string{ + "negate": "true", + }) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDenied(cs, ns.Name, externalNameService("external-negated-denied", "api.example.com"), + "externalName hostname", + "api.example.com", + "denied", + "exp: trusted\\..*", + ) + + createServiceAndExpectAllowed(cs, ns.Name, externalNameService("external-negated-allowed", "trusted.api")) + }) + + It("allows LoadBalancer IPs and source ranges contained in configured CIDRs", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectAllowed(cs, ns.Name, loadBalancerService("lb-ip-allowed", "10.0.0.2", nil, ptr.To(false))) + createServiceAndExpectAllowed(cs, ns.Name, loadBalancerService("lb-ip-range-allowed", "10.0.1.44", nil, ptr.To(false))) + createServiceAndExpectAllowed(cs, ns.Name, loadBalancerService("lb-source-range-allowed", "", []string{"10.0.1.0/25"}, ptr.To(false))) + }) + + It("denies LoadBalancer IPs outside configured CIDRs and reports allowed CIDRs", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDenied(cs, ns.Name, loadBalancerService("lb-ip-denied", "10.0.171.239", nil, ptr.To(false)), + "loadBalancer CIDR", + "10.0.171.239", + "spec.loadBalancerIP", + "not allowed", + "Allowed CIDRs", + "10.0.0.2/32", + "10.0.1.0/24", + ) + }) + + It("denies LoadBalancer source ranges not fully contained in configured CIDRs", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDenied(cs, ns.Name, loadBalancerService("lb-source-range-denied", "", []string{"10.0.1.0/23"}, ptr.To(false)), + "loadBalancer CIDR", + "10.0.1.0/23", + "spec.loadBalancerSourceRanges[0]", + "Allowed CIDRs", + ) + }) + + It("requires LoadBalancer IP or source ranges when CIDR constraints are configured", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDenied(cs, ns.Name, loadBalancerService("lb-required-value-denied", "", nil, ptr.To(false)), + "requires spec.loadBalancerIP or spec.loadBalancerSourceRanges", + "loadBalancer CIDR constraints are enforced by namespace rule", + ) + }) + + It("denies a later LoadBalancer CIDR deny rule after an earlier allow rule matched", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDenied(cs, ns.Name, loadBalancerService("lb-later-deny", "10.0.66.10", nil, ptr.To(false)), + "loadBalancer CIDR", + "10.0.66.10", + "denied", + "10.0.66.0/24", + ) + }) + + It("allows a later selected LoadBalancer allow rule to override an earlier allow miss", func() { + ns := createNamespace(map[string]string{ + "environment": "prod", + }) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectAllowed(cs, ns.Name, loadBalancerService("lb-prod-selected-allowed", "10.0.171.239", nil, ptr.To(false))) + }) + + It("allows explicit nodePorts inside configured ranges", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectAllowed(cs, ns.Name, nodePortService("node-port-range-allowed", 30080)) + createServiceAndExpectAllowed(cs, ns.Name, nodePortService("node-port-single-allowed", 30500)) + }) + + It("denies explicit nodePorts outside configured ranges and reports allowed ranges", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDenied(cs, ns.Name, nodePortService("node-port-range-denied", 32080), + "nodePort", + "32080", + "not allowed", + "Allowed ranges", + "30000-30100", + "30500", + ) + }) + + It("requires explicit nodePorts when nodePort ranges are configured", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDeniedOneOf( + cs, + ns.Name, + nodePortServiceWithoutExplicitNodePort("node-port-required-denied"), + []string{ + "requires explicit spec.ports[*].nodePort", + "nodePort ranges are enforced by namespace rule", + }, + []string{ + "nodePort", + "not allowed by namespace rule", + "Allowed ranges", + "30000-30100", + "30500", + }, + ) + }) + It("denies a later nodePort deny rule after an earlier allow rule matched", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDenied( + cs, + ns.Name, + nodePortService("node-port-later-deny", 30090), + "nodePort", + "30090", + "denied", + "30090", + ) + }) + + It("requires or validates LoadBalancer nodePorts when allocation is enabled and nodePort ranges are configured", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectDeniedOneOf( + cs, + ns.Name, + loadBalancerService("lb-auto-node-port-denied", "10.0.0.2", nil, nil), + []string{ + "requires explicit spec.ports[*].nodePort", + "nodePort ranges are enforced by namespace rule", + }, + []string{ + "nodePort", + "not allowed by namespace rule", + "Allowed ranges", + "30000-30100", + "30500", + }, + ) + }) + + It("does not require LoadBalancer nodePorts when allocation is explicitly disabled", func() { + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectAllowed( + cs, + ns.Name, + loadBalancerService("lb-node-port-allocation-disabled", "10.0.0.2", nil, ptr.To(false)), + ) + }) + + It("denies an update when the new ExternalName no longer matches the allowed hostname rules", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + serviceTypeRule( + rules.ActionTypeAllow, + rules.ServiceTypeExternalName, + ), + externalNameRule( + rules.ActionTypeAllow, + externalNameByExact("internal.git.com"), + ), + }) + + ns := createNamespace(nil) + + expectNamespaceStatusRules(ns.Name, []expectedServiceStatusRule{ + { + action: rules.ActionTypeAllow, + types: []rules.ServiceType{ + rules.ServiceTypeExternalName, + }, + }, + { + action: rules.ActionTypeAllow, + externalExact: [][]string{ + { + "internal.git.com", + }, + }, + }, + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + svc := externalNameService("external-update-denied", "internal.git.com") + createServiceAndExpectAllowed(cs, ns.Name, svc) + + updateServiceAndExpectDenied( + cs, + ns.Name, + svc.Name, + func(svc *corev1.Service) { + svc.Spec.ExternalName = "api.bad.com" + }, + "externalName hostname", + "api.bad.com", + "not allowed", + "Allowed hostnames", + ) + }) + + It("allows service creation when no matching service-specific constraint exists for the allowed type", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + serviceTypeRule( + rules.ActionTypeAllow, + rules.ServiceTypeClusterIP, + rules.ServiceTypeLoadBalancer, + ), + }) + + ns := createNamespace(nil) + + expectNamespaceStatusRules(ns.Name, []expectedServiceStatusRule{ + { + action: rules.ActionTypeAllow, + types: []rules.ServiceType{ + rules.ServiceTypeClusterIP, + rules.ServiceTypeLoadBalancer, + }, + }, + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectAllowed( + cs, + ns.Name, + loadBalancerService("lb-no-cidr-rule-allowed", "", nil, ptr.To(false)), + ) + }) +}) diff --git a/hack/distro/capsule/example-setup/tenants.yaml b/hack/distro/capsule/example-setup/tenants.yaml index a5205ce8c..3a7e0b2d9 100644 --- a/hack/distro/capsule/example-setup/tenants.yaml +++ b/hack/distro/capsule/example-setup/tenants.yaml @@ -91,10 +91,20 @@ spec: rules: - enforce: action: "allow" - workloads: - schedulers: - - exact: - - "{{ .tenant.metadata.name }}-scheduler" + services: + types: ["ClusterIP", "NodePort", "LoadBalancer", "ExternalName"] + loadBalancers: + cidrs: + - 10.0.0.2/32 + externalNames: + hostnames: + - exp: ".*\\.example\\.com" + exact: + - "internal.git.com" + nodePorts: + ports: + - from: 30000 + to: 32767 --- apiVersion: capsule.clastix.io/v1beta2 kind: Tenant diff --git a/internal/controllers/admission/mutating.go b/internal/controllers/admission/mutating.go index d1f6302fb..9d8ff24a7 100644 --- a/internal/controllers/admission/mutating.go +++ b/internal/controllers/admission/mutating.go @@ -17,7 +17,6 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" @@ -69,7 +68,7 @@ func (r *mutatingReconciler) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils predicates.NamesMatchingPredicate{Names: []string{ctrlConfig.ConfigurationName}}, ), ). - WithOptions(controller.Options{MaxConcurrentReconciles: ctrlConfig.MaxConcurrentReconciles}). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(r) } diff --git a/internal/controllers/admission/validating.go b/internal/controllers/admission/validating.go index 22070e01a..ef47bf5be 100644 --- a/internal/controllers/admission/validating.go +++ b/internal/controllers/admission/validating.go @@ -17,7 +17,6 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" @@ -69,7 +68,7 @@ func (r *validatingReconciler) SetupWithManager(mgr ctrl.Manager, ctrlConfig uti predicates.NamesMatchingPredicate{Names: []string{ctrlConfig.ConfigurationName}}, ), ). - WithOptions(controller.Options{MaxConcurrentReconciles: ctrlConfig.MaxConcurrentReconciles}). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(r) } diff --git a/internal/controllers/customquotas/custom_quota_controller.go b/internal/controllers/customquotas/custom_quota_controller.go index 328e07249..201e552e8 100644 --- a/internal/controllers/customquotas/custom_quota_controller.go +++ b/internal/controllers/customquotas/custom_quota_controller.go @@ -20,7 +20,6 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/predicate" @@ -49,7 +48,7 @@ type customQuotaClaimController struct { targetsCache *cache.CompiledTargetsCache[string] } -func (r *customQuotaClaimController) SetupWithManager(mgr ctrl.Manager, cfg cutils.ControllerOptions) error { +func (r *customQuotaClaimController) SetupWithManager(mgr ctrl.Manager, ctrlConfig cutils.ControllerOptions) error { r.mapper = mgr.GetRESTMapper() r.reader = mgr.GetAPIReader() @@ -71,7 +70,7 @@ func (r *customQuotaClaimController) SetupWithManager(mgr ctrl.Manager, cfg cuti &capsulev1beta2.CustomQuota{}, ), ). - WithOptions(controller.Options{MaxConcurrentReconciles: cfg.MaxConcurrentReconciles}). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(r) } diff --git a/internal/controllers/customquotas/global_custom_quota_controller.go b/internal/controllers/customquotas/global_custom_quota_controller.go index 8bba8928d..55a31aa3c 100644 --- a/internal/controllers/customquotas/global_custom_quota_controller.go +++ b/internal/controllers/customquotas/global_custom_quota_controller.go @@ -23,7 +23,6 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" @@ -55,7 +54,7 @@ type clusterCustomQuotaClaimController struct { targetsCache *cache.CompiledTargetsCache[string] } -func (r *clusterCustomQuotaClaimController) SetupWithManager(mgr ctrl.Manager, cfg cutils.ControllerOptions) error { +func (r *clusterCustomQuotaClaimController) SetupWithManager(mgr ctrl.Manager, ctrlConfig cutils.ControllerOptions) error { r.mapper = mgr.GetRESTMapper() r.reader = mgr.GetAPIReader() @@ -104,7 +103,7 @@ func (r *clusterCustomQuotaClaimController) SetupWithManager(mgr ctrl.Manager, c }, }), ). - WithOptions(controller.Options{MaxConcurrentReconciles: cfg.MaxConcurrentReconciles}). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(r) } diff --git a/internal/controllers/pv/controller.go b/internal/controllers/pv/controller.go index 25e1fb450..c3e33084a 100644 --- a/internal/controllers/pv/controller.go +++ b/internal/controllers/pv/controller.go @@ -12,7 +12,6 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" log2 "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -28,7 +27,7 @@ type Controller struct { label string } -func (c *Controller) SetupWithManager(mgr ctrl.Manager, cfg utils.ControllerOptions) error { +func (c *Controller) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) error { label, err := capsuleutils.GetTypeLabel(&capsulev1beta2.Tenant{}) if err != nil { return err @@ -54,7 +53,7 @@ func (c *Controller) SetupWithManager(mgr ctrl.Manager, cfg utils.ControllerOpti return !ok }))). - WithOptions(controller.Options{MaxConcurrentReconciles: cfg.MaxConcurrentReconciles}). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(c) } diff --git a/internal/controllers/resourcepools/claim_controller.go b/internal/controllers/resourcepools/claim_controller.go index 7f0cb451a..c536e17af 100644 --- a/internal/controllers/resourcepools/claim_controller.go +++ b/internal/controllers/resourcepools/claim_controller.go @@ -18,7 +18,6 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -40,7 +39,7 @@ type resourceClaimController struct { recorder events.EventRecorder } -func (r *resourceClaimController) SetupWithManager(mgr ctrl.Manager, cfg utils.ControllerOptions) error { +func (r *resourceClaimController) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) error { r.reader = mgr.GetAPIReader() return ctrl.NewControllerManagedBy(mgr). @@ -56,7 +55,7 @@ func (r *resourceClaimController) SetupWithManager(mgr ctrl.Manager, cfg utils.C handler.EnqueueRequestsFromMapFunc(r.claimsWithoutPoolFromNamespaces), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), ). - WithOptions(controller.Options{MaxConcurrentReconciles: cfg.MaxConcurrentReconciles}). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(r) } @@ -64,7 +63,7 @@ func (r resourceClaimController) Reconcile(ctx context.Context, request ctrl.Req log := r.log.WithValues("Request.Name", request.Name) instance := &capsulev1beta2.ResourcePoolClaim{} - if err = r.reader.Get(ctx, request.NamespacedName, instance); err != nil { + if err = r.Get(ctx, request.NamespacedName, instance); err != nil { if apierrors.IsNotFound(err) { log.V(5).Info("Request object not found, could have been deleted after reconcile request") diff --git a/internal/controllers/resourcepools/pool_controller.go b/internal/controllers/resourcepools/pool_controller.go index 562094764..5ddc76869 100644 --- a/internal/controllers/resourcepools/pool_controller.go +++ b/internal/controllers/resourcepools/pool_controller.go @@ -22,7 +22,6 @@ import ( "sigs.k8s.io/cluster-api/util/patch" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -47,7 +46,7 @@ type resourcePoolController struct { recorder events.EventRecorder } -func (r *resourcePoolController) SetupWithManager(mgr ctrl.Manager, cfg ctrlutils.ControllerOptions) error { +func (r *resourcePoolController) SetupWithManager(mgr ctrl.Manager, ctrlConfig ctrlutils.ControllerOptions) error { r.reader = mgr.GetAPIReader() return ctrl.NewControllerManagedBy(mgr). @@ -61,7 +60,7 @@ func (r *resourcePoolController) SetupWithManager(mgr ctrl.Manager, cfg ctrlutil handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, _ client.Object) []reconcile.Request { // Fetch all GlobalResourceQuota objects grqList := &capsulev1beta2.ResourcePoolList{} - if err := r.reader.List(ctx, grqList); err != nil { + if err := r.Client.List(ctx, grqList); err != nil { r.log.Error(err, "Failed to list ResourcePools objects") return nil @@ -78,7 +77,7 @@ func (r *resourcePoolController) SetupWithManager(mgr ctrl.Manager, cfg ctrlutil return requests }), ). - WithOptions(controller.Options{MaxConcurrentReconciles: cfg.MaxConcurrentReconciles}). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(r) } diff --git a/internal/controllers/resources/global.go b/internal/controllers/resources/global.go index d5e631256..dd407c69c 100644 --- a/internal/controllers/resources/global.go +++ b/internal/controllers/resources/global.go @@ -20,7 +20,6 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/handler" ctrllog "sigs.k8s.io/controller-runtime/pkg/log" @@ -53,7 +52,7 @@ type globalResourceController struct { impersonation *cache.ImpersonationCache } -func (r *globalResourceController) SetupWithManager(mgr ctrl.Manager, cfg utils.ControllerOptions) error { +func (r *globalResourceController) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) error { r.client = mgr.GetClient() r.reader = mgr.GetAPIReader() @@ -83,7 +82,7 @@ func (r *globalResourceController) SetupWithManager(mgr ctrl.Manager, cfg utils. handler.EnqueueRequestsFromMapFunc(r.enqueueAllResources), builder.WithPredicates( predicates.CapsuleConfigSpecImpersonationChangedPredicate{}, - predicates.NamesMatchingPredicate{Names: []string{cfg.ConfigurationName}}, + predicates.NamesMatchingPredicate{Names: []string{ctrlConfig.ConfigurationName}}, ), ). Watches( @@ -94,7 +93,7 @@ func (r *globalResourceController) SetupWithManager(mgr ctrl.Manager, cfg utils. &capsulev1beta2.Tenant{}, handler.EnqueueRequestsFromMapFunc(r.enqueueRequestFromTenant), ). - WithOptions(controller.Options{MaxConcurrentReconciles: cfg.MaxConcurrentReconciles}). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(r) } diff --git a/internal/controllers/resources/namespaced.go b/internal/controllers/resources/namespaced.go index b9ea12609..5db871b85 100644 --- a/internal/controllers/resources/namespaced.go +++ b/internal/controllers/resources/namespaced.go @@ -21,7 +21,6 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" @@ -56,7 +55,7 @@ type namespacedResourceController struct { impersonation *cache.ImpersonationCache } -func (r *namespacedResourceController) SetupWithManager(mgr ctrl.Manager, cfg cutils.ControllerOptions) error { +func (r *namespacedResourceController) SetupWithManager(mgr ctrl.Manager, ctrlConfig cutils.ControllerOptions) error { r.client = mgr.GetClient() r.reader = mgr.GetAPIReader() @@ -90,7 +89,7 @@ func (r *namespacedResourceController) SetupWithManager(mgr ctrl.Manager, cfg cu handler.EnqueueRequestsFromMapFunc(r.enqueueAllResources), builder.WithPredicates( predicates.CapsuleConfigSpecImpersonationChangedPredicate{}, - predicates.NamesMatchingPredicate{Names: []string{cfg.ConfigurationName}}, + predicates.NamesMatchingPredicate{Names: []string{ctrlConfig.ConfigurationName}}, ), ). Watches( @@ -131,7 +130,7 @@ func (r *namespacedResourceController) SetupWithManager(mgr ctrl.Manager, cfg cu }, ), ). - WithOptions(controller.Options{MaxConcurrentReconciles: cfg.MaxConcurrentReconciles}). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(r) } diff --git a/internal/controllers/rulestatus/manager.go b/internal/controllers/rulestatus/manager.go index bbe9166b0..99a8a35cb 100644 --- a/internal/controllers/rulestatus/manager.go +++ b/internal/controllers/rulestatus/manager.go @@ -19,14 +19,12 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/internal/controllers/utils" "github.com/projectcapsule/capsule/internal/metrics" - "github.com/projectcapsule/capsule/pkg/api" caperrors "github.com/projectcapsule/capsule/pkg/api/errors" meta "github.com/projectcapsule/capsule/pkg/api/meta" "github.com/projectcapsule/capsule/pkg/api/rules" @@ -60,7 +58,7 @@ func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.Controller ), ), ). - WithOptions(controller.Options{MaxConcurrentReconciles: ctrlConfig.MaxConcurrentReconciles}) + WithOptions(ctrlConfig.Runtime.ToControllerOptions()) return ctrlBuilder.Complete(r) } @@ -145,39 +143,13 @@ func (r Manager) reconcile(ctx context.Context, instance *capsulev1beta2.RuleSta ruleStatus := make([]*rules.NamespaceRuleBodyNamespace, 0, len(instance.Spec)) for _, rule := range instance.Spec { - if rule == nil { + if rule == nil || rule.Enforce == nil { continue } - if rule.Enforce == nil { - continue - } - - normalized := &rules.NamespaceRuleBodyNamespace{ - Enforce: &rules.NamespaceRuleEnforceBody{ - Action: rule.Enforce.Action, - Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ - Targets: append( - []rules.WorkloadValidationTarget(nil), - rule.Enforce.Workloads.Targets..., - ), - Registries: append( - []rules.OCIRegistry(nil), - rule.Enforce.Workloads.Registries..., - ), - QoSClasses: append( - []corev1.PodQOSClass(nil), - rule.Enforce.Workloads.QoSClasses..., - ), - Schedulers: append( - []api.ExpressionMatch(nil), - rule.Enforce.Workloads.Schedulers..., - ), - }, - }, - } - - ruleStatus = append(ruleStatus, normalized) + ruleStatus = append(ruleStatus, &rules.NamespaceRuleBodyNamespace{ + Enforce: rule.Enforce.DeepCopy(), + }) } instance.Status.Rules = ruleStatus diff --git a/internal/controllers/tenant/manager.go b/internal/controllers/tenant/manager.go index 08bb25cab..b637412c2 100644 --- a/internal/controllers/tenant/manager.go +++ b/internal/controllers/tenant/manager.go @@ -30,7 +30,6 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/predicate" @@ -202,7 +201,7 @@ func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.Controller }, builder.WithPredicates(predicates.PromotedServiceaccountPredicate{}), ). - WithOptions(controller.Options{MaxConcurrentReconciles: ctrlConfig.MaxConcurrentReconciles}) + WithOptions(ctrlConfig.Runtime.ToControllerOptions()) // GatewayClass is Optional r.classes.gateway = gvk.HasGVK(mgr.GetRESTMapper(), schema.GroupVersionKind{ diff --git a/internal/controllers/tenantowner/manager.go b/internal/controllers/tenantowner/manager.go index 69736de4a..72898fabc 100644 --- a/internal/controllers/tenantowner/manager.go +++ b/internal/controllers/tenantowner/manager.go @@ -111,6 +111,7 @@ func (r *TenantOwnerManager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils }, }, ). + WithOptions(ctrlConfig.Runtime.ToControllerOptions()). Complete(r) } diff --git a/internal/controllers/utils/options.go b/internal/controllers/utils/options.go index a517ccd0a..9d10aceb8 100644 --- a/internal/controllers/utils/options.go +++ b/internal/controllers/utils/options.go @@ -3,7 +3,30 @@ package utils +import ( + "time" + + "sigs.k8s.io/controller-runtime/pkg/controller" +) + type ControllerOptions struct { - ConfigurationName string + ConfigurationName string + Runtime RuntimeControllerOptions +} + +type RuntimeControllerOptions struct { MaxConcurrentReconciles int + CacheSyncTimeout time.Duration +} + +func (o RuntimeControllerOptions) ToControllerOptions() controller.Options { + out := controller.Options{ + MaxConcurrentReconciles: o.MaxConcurrentReconciles, + } + + if o.CacheSyncTimeout > 0 { + out.CacheSyncTimeout = o.CacheSyncTimeout + } + + return out } diff --git a/internal/webhook/route/rules.go b/internal/webhook/route/rules.go new file mode 100644 index 000000000..d8c65acbe --- /dev/null +++ b/internal/webhook/route/rules.go @@ -0,0 +1,30 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package route + +import ( + "github.com/projectcapsule/capsule/internal/webhook/rules/status" + "github.com/projectcapsule/capsule/pkg/runtime/configuration" + "github.com/projectcapsule/capsule/pkg/runtime/handlers" +) + +type rulesValidating struct { + configuration configuration.Configuration +} + +func RulesValidating(configuration configuration.Configuration) handlers.Webhook { + return &rulesValidating{ + configuration: configuration, + } +} + +func (w *rulesValidating) GetHandlers() []handlers.Handler { + return []handlers.Handler{ + status.RuleStatusValidationHandler(w.configuration), + } +} + +func (rulesValidating) GetPath() string { + return "/rulestatus/validating" +} diff --git a/internal/webhook/rules/pods/validation/factory_test.go b/internal/webhook/rules/pods/validation/factory_test.go new file mode 100644 index 000000000..7b99d6f2d --- /dev/null +++ b/internal/webhook/rules/pods/validation/factory_test.go @@ -0,0 +1,12 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import "github.com/projectcapsule/capsule/internal/cache" + +func podRulesForTest() *podRules { + return &podRules{ + regexCache: cache.NewRegexCache(), + } +} diff --git a/internal/webhook/rules/pods/validation/qos.go b/internal/webhook/rules/pods/validation/qos.go index 962747d70..ef603a1d2 100644 --- a/internal/webhook/rules/pods/validation/qos.go +++ b/internal/webhook/rules/pods/validation/qos.go @@ -42,6 +42,10 @@ func (h *podRules) validateQoSClasses( Matched: string(match) == value.Value, }, nil }, + RuleDescription: func(rule corev1.PodQOSClass) string { + return string(rule) + }, + AllowedDescription: "Allowed QoS classes", }, ) } diff --git a/internal/webhook/rules/pods/validation/qos_test.go b/internal/webhook/rules/pods/validation/qos_test.go new file mode 100644 index 000000000..7ff015497 --- /dev/null +++ b/internal/webhook/rules/pods/validation/qos_test.go @@ -0,0 +1,466 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +func TestPodRulesValidateQoSClasses(t *testing.T) { + tests := []struct { + name string + pod *corev1.Pod + enforceBodies []*apirules.NamespaceRuleEnforceBody + wantBlocking bool + wantFinal bool + wantAudits int + wantErr string + wantMessage []string + }{ + { + name: "BestEffort pod without QoS rules returns empty evaluation", + pod: bestEffortPodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + wantBlocking: false, + wantFinal: false, + }, + { + name: "nil enforce body is ignored", + pod: bestEffortPodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nil, + qosEnforceForTest( + apirules.ActionTypeAllow, + corev1.PodQOSBestEffort, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `QoS class "BestEffort" at status.qosClass is allowed by namespace rule`, + `matched allowed rule BestEffort`, + }, + }, + { + name: "allow BestEffort QoS class", + pod: bestEffortPodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionTypeAllow, + corev1.PodQOSBestEffort, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `QoS class "BestEffort" at status.qosClass is allowed by namespace rule`, + `matched allowed rule BestEffort`, + }, + }, + { + name: "allow Burstable QoS class", + pod: burstablePodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionTypeAllow, + corev1.PodQOSBurstable, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `QoS class "Burstable" at status.qosClass is allowed by namespace rule`, + `matched allowed rule Burstable`, + }, + }, + { + name: "allow Guaranteed QoS class", + pod: guaranteedPodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionTypeAllow, + corev1.PodQOSGuaranteed, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `QoS class "Guaranteed" at status.qosClass is allowed by namespace rule`, + `matched allowed rule Guaranteed`, + }, + }, + { + name: "allow miss denies QoS class missing from allowed list", + pod: bestEffortPodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionTypeAllow, + corev1.PodQOSBurstable, + corev1.PodQOSGuaranteed, + ), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `QoS class "BestEffort" at status.qosClass is not allowed by namespace rule`, + `Allowed QoS classes`, + `Burstable`, + `Guaranteed`, + }, + }, + { + name: "deny matching QoS class", + pod: bestEffortPodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionTypeDeny, + corev1.PodQOSBestEffort, + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `QoS class "BestEffort" at status.qosClass is denied by namespace rule`, + `matched denied rule BestEffort`, + }, + }, + { + name: "default action is deny", + pod: burstablePodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + "", + corev1.PodQOSBurstable, + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `QoS class "Burstable" at status.qosClass is denied by namespace rule`, + `matched denied rule Burstable`, + }, + }, + { + name: "later deny overrides earlier allow", + pod: bestEffortPodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionTypeAllow, + corev1.PodQOSBestEffort, + ), + qosEnforceForTest( + apirules.ActionTypeDeny, + corev1.PodQOSBestEffort, + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `QoS class "BestEffort" at status.qosClass is denied by namespace rule`, + `matched denied rule BestEffort`, + }, + }, + { + name: "later allow overrides earlier deny", + pod: bestEffortPodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionTypeDeny, + corev1.PodQOSBestEffort, + ), + qosEnforceForTest( + apirules.ActionTypeAllow, + corev1.PodQOSBestEffort, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `QoS class "BestEffort" at status.qosClass is allowed by namespace rule`, + `matched allowed rule BestEffort`, + }, + }, + { + name: "non matching later deny does not override earlier allow", + pod: guaranteedPodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionTypeAllow, + corev1.PodQOSGuaranteed, + ), + qosEnforceForTest( + apirules.ActionTypeDeny, + corev1.PodQOSBestEffort, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `QoS class "Guaranteed" at status.qosClass is allowed by namespace rule`, + `matched allowed rule Guaranteed`, + }, + }, + { + name: "audit match is observational", + pod: burstablePodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionTypeAudit, + corev1.PodQOSBurstable, + ), + }, + wantBlocking: false, + wantFinal: false, + wantAudits: 1, + wantMessage: []string{ + `QoS class "Burstable" at status.qosClass matched audit namespace rule`, + `matched audit rule Burstable`, + }, + }, + { + name: "audit does not satisfy allow list", + pod: burstablePodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionTypeAudit, + corev1.PodQOSBurstable, + ), + qosEnforceForTest( + apirules.ActionTypeAllow, + corev1.PodQOSGuaranteed, + ), + }, + wantBlocking: true, + wantFinal: false, + wantAudits: 1, + wantMessage: []string{ + `QoS class "Burstable" at status.qosClass is not allowed by namespace rule`, + `Allowed QoS classes`, + `Guaranteed`, + }, + }, + { + name: "unsupported action returns error", + pod: bestEffortPodForQoSTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionType("invalid"), + corev1.PodQOSBestEffort, + ), + }, + wantErr: `QoS class: unsupported rule action "invalid"`, + }, + { + name: "uses existing status qosClass when present", + pod: func() *corev1.Pod { + pod := bestEffortPodForQoSTest() + pod.Status.QOSClass = corev1.PodQOSGuaranteed + + return pod + }(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionTypeAllow, + corev1.PodQOSGuaranteed, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `QoS class "Guaranteed" at status.qosClass is allowed by namespace rule`, + }, + }, + { + name: "empty pod still evaluates as BestEffort", + pod: &corev1.Pod{}, + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + qosEnforceForTest( + apirules.ActionTypeAllow, + corev1.PodQOSBestEffort, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `QoS class "BestEffort" at status.qosClass is allowed by namespace rule`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := podRulesForTest() + + evaluation, err := h.validateQoSClasses(tt.pod, tt.enforceBodies) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if evaluation == nil { + t.Fatalf("expected evaluation, got nil") + } + + if tt.wantBlocking && evaluation.Blocking == nil { + t.Fatalf("expected blocking decision, got nil") + } + + if !tt.wantBlocking && evaluation.Blocking != nil { + t.Fatalf("expected no blocking decision, got %#v", evaluation.Blocking) + } + + if tt.wantFinal && evaluation.Final == nil { + t.Fatalf("expected final decision, got nil") + } + + if !tt.wantFinal && evaluation.Final != nil { + t.Fatalf("expected no final decision, got %#v", evaluation.Final) + } + + if len(evaluation.Audits) != tt.wantAudits { + t.Fatalf("expected %d audit decisions, got %d", tt.wantAudits, len(evaluation.Audits)) + } + + if len(tt.wantMessage) > 0 { + msg := decisionMessageForQoSTest(evaluation) + + for _, expected := range tt.wantMessage { + if !strings.Contains(msg, expected) { + t.Fatalf("expected message %q to contain %q", msg, expected) + } + } + } + + if evaluation.Final != nil { + if evaluation.Final.EventReason != events.ReasonForbiddenPodQoSClass { + t.Fatalf("final event reason = %q, want %q", evaluation.Final.EventReason, events.ReasonForbiddenPodQoSClass) + } + } + + if evaluation.Blocking != nil { + if evaluation.Blocking.EventReason != events.ReasonForbiddenPodQoSClass { + t.Fatalf("blocking event reason = %q, want %q", evaluation.Blocking.EventReason, events.ReasonForbiddenPodQoSClass) + } + } + + for _, audit := range evaluation.Audits { + if audit.EventReason != events.ReasonForbiddenPodQoSClass { + t.Fatalf("audit event reason = %q, want %q", audit.EventReason, events.ReasonForbiddenPodQoSClass) + } + } + }) + } +} + +func qosEnforceForTest( + action apirules.ActionType, + classes ...corev1.PodQOSClass, +) *apirules.NamespaceRuleEnforceBody { + return &apirules.NamespaceRuleEnforceBody{ + Action: action, + Workloads: apirules.NamespaceRuleEnforceWorkloadsBody{ + QoSClasses: classes, + }, + } +} + +func bestEffortPodForQoSTest() *corev1.Pod { + return &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "shell", + Image: "busybox", + }, + }, + }, + } +} + +func burstablePodForQoSTest() *corev1.Pod { + return &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "shell", + Image: "busybox", + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + }, + }, + }, + }, + }, + } +} + +func guaranteedPodForQoSTest() *corev1.Pod { + cpu := resource.MustParse("100m") + memory := resource.MustParse("128Mi") + + return &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "shell", + Image: "busybox", + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: cpu, + corev1.ResourceMemory: memory, + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: cpu, + corev1.ResourceMemory: memory, + }, + }, + }, + }, + }, + } +} + +func decisionMessageForQoSTest(evaluation interface { +}) string { + e, ok := evaluation.(*ruleengine.Evaluation) + if !ok || e == nil { + return "" + } + + switch { + case e.Blocking != nil: + return e.Blocking.Message + case e.Final != nil: + return e.Final.Message + case len(e.Audits) > 0: + return e.Audits[0].Message + default: + return "" + } +} diff --git a/internal/webhook/rules/pods/validation/registry.go b/internal/webhook/rules/pods/validation/registry.go index 55134de91..54cd5e01c 100644 --- a/internal/webhook/rules/pods/validation/registry.go +++ b/internal/webhook/rules/pods/validation/registry.go @@ -11,6 +11,7 @@ import ( corev1 "k8s.io/api/core/v1" "github.com/projectcapsule/capsule/internal/cache" + rulesutils "github.com/projectcapsule/capsule/internal/webhook/rules" apirules "github.com/projectcapsule/capsule/pkg/api/rules" ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" "github.com/projectcapsule/capsule/pkg/runtime/events" @@ -153,11 +154,34 @@ func (h *podRules) evaluateRegistryReference( MatchedValue: matched, }, nil }, - Message: registryDecisionMessage, + Message: registryDecisionMessage, + RuleDescription: describeRegistryRuleSet, + AllowedDescription: "Allowed registries", }, ) } +func describeRegistryRuleSet(rule registryRuleSet) string { + if len(rule.Registries) == 0 { + return "" + } + + parts := make([]string, 0, len(rule.Registries)) + + for _, registry := range rule.Registries { + description := strings.TrimSpace( + rulesutils.DescribeExpressionMatch(registry.ExpressionMatch), + ) + if description == "" { + continue + } + + parts = append(parts, description) + } + + return strings.Join(parts, ", ") +} + func registryReferencesFromPod(pod *corev1.Pod) []registryReference { if pod == nil { return nil diff --git a/internal/webhook/rules/pods/validation/registry_test.go b/internal/webhook/rules/pods/validation/registry_test.go new file mode 100644 index 000000000..0c155165d --- /dev/null +++ b/internal/webhook/rules/pods/validation/registry_test.go @@ -0,0 +1,1157 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + + "github.com/projectcapsule/capsule/internal/cache" + "github.com/projectcapsule/capsule/pkg/api" + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +func TestPodRulesValidateRegistriesPreconditions(t *testing.T) { + t.Run("nil registry cache returns error", func(t *testing.T) { + h := &podRules{} + + evaluation, err := h.validateRegistries( + registryPodForTest("harbor/platform/app:1.0.0", corev1.PullAlways), + []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAllow, + nil, + registryExpressionForTest("harbor/.*"), + ), + }, + ) + + if err == nil { + t.Fatalf("expected error, got nil") + } + + if !strings.Contains(err.Error(), "registry rule set cache is nil") { + t.Fatalf("expected registry cache error, got %q", err.Error()) + } + + if evaluation != nil { + t.Fatalf("expected nil evaluation, got %#v", evaluation) + } + }) + + t.Run("nil pod returns nil evaluation when cache exists", func(t *testing.T) { + h := &podRules{ + registryCache: cache.NewRegistryRuleSetCache(cache.NewRegexCache()), + } + + evaluation, err := h.validateRegistries( + nil, + []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAllow, + nil, + registryExpressionForTest("harbor/.*"), + ), + }, + ) + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if evaluation != nil { + t.Fatalf("expected nil evaluation, got %#v", evaluation) + } + }) + + t.Run("empty enforce bodies returns nil evaluation when cache exists", func(t *testing.T) { + h := &podRules{ + registryCache: cache.NewRegistryRuleSetCache(cache.NewRegexCache()), + } + + evaluation, err := h.validateRegistries( + registryPodForTest("harbor/platform/app:1.0.0", corev1.PullAlways), + nil, + ) + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if evaluation != nil { + t.Fatalf("expected nil evaluation, got %#v", evaluation) + } + }) +} + +func TestPodRulesValidateRegistries(t *testing.T) { + tests := []struct { + name string + pod *corev1.Pod + enforceBodies []*apirules.NamespaceRuleEnforceBody + wantBlocking bool + wantFinal bool + wantAudits int + wantErr string + wantMessage []string + }{ + { + name: "empty container image reference is denied before registry evaluation", + pod: registryPodForTest("", corev1.PullAlways), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAllow, + nil, + registryExpressionForTest("harbor/.*"), + ), + }, + wantBlocking: true, + wantMessage: []string{ + "containers[0] has empty reference", + }, + }, + { + name: "blank container image reference is denied before registry evaluation", + pod: registryPodForTest(" ", corev1.PullAlways), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAllow, + nil, + registryExpressionForTest("harbor/.*"), + ), + }, + wantBlocking: true, + wantMessage: []string{ + "containers[0] has empty reference", + }, + }, + { + name: "allow matching registry", + pod: registryPodForTest("harbor/platform/app:1.0.0", corev1.PullAlways), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAllow, + nil, + registryExpressionForTest("harbor/platform/.*"), + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `containers[0] reference "harbor/platform/app:1.0.0" is allowed by registry rule`, + `exp=harbor/platform/.*`, + }, + }, + { + name: "allow exact registry", + pod: registryPodForTest("harbor/platform/app:1.0.0", corev1.PullAlways), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAllow, + nil, + registryExactForTest("harbor/platform/app:1.0.0"), + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `containers[0] reference "harbor/platform/app:1.0.0" is allowed by registry rule`, + `exact=harbor/platform/app:1.0.0`, + }, + }, + { + name: "allow miss denies registry and reports allowed registries", + pod: registryPodForTest("docker.io/library/nginx:latest", corev1.PullAlways), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAllow, + nil, + registryExpressionForTest("harbor/.*"), + registryExpressionForTest("registry.local/.*"), + ), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `registry "docker.io/library/nginx:latest" at containers[0] is not allowed by namespace rule`, + `Allowed registries`, + `exp: harbor/.*`, + `exp: registry.local/.*`, + }, + }, + { + name: "deny matching registry", + pod: registryPodForTest("harbor/customer/app:1.0.0", corev1.PullAlways), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeDeny, + nil, + registryExpressionForTest("harbor/customer/.*"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `containers[0] reference "harbor/customer/app:1.0.0" is denied by registry rule`, + `exp=harbor/customer/.*`, + }, + }, + { + name: "default action is deny", + pod: registryPodForTest("harbor/customer/app:1.0.0", corev1.PullAlways), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + "", + nil, + registryExpressionForTest("harbor/customer/.*"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `containers[0] reference "harbor/customer/app:1.0.0" is denied by registry rule`, + `exp=harbor/customer/.*`, + }, + }, + { + name: "later deny overrides earlier allow", + pod: registryPodForTest("harbor/customer/app:1.0.0", corev1.PullAlways), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAllow, + nil, + registryExpressionForTest("harbor/.*"), + ), + registryEnforceForTest( + apirules.ActionTypeDeny, + nil, + registryExpressionForTest("harbor/customer/.*"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `containers[0] reference "harbor/customer/app:1.0.0" is denied by registry rule`, + `exp=harbor/customer/.*`, + }, + }, + { + name: "later allow overrides earlier deny", + pod: registryPodForTest("harbor/customer/prod/app:1.0.0", corev1.PullAlways), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeDeny, + nil, + registryExpressionForTest("harbor/customer/.*"), + ), + registryEnforceForTest( + apirules.ActionTypeAllow, + nil, + registryExpressionForTest("harbor/customer/prod/.*"), + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `containers[0] reference "harbor/customer/prod/app:1.0.0" is allowed by registry rule`, + `exp=harbor/customer/prod/.*`, + }, + }, + { + name: "audit match is observational", + pod: registryPodForTest("audit/app:1.0.0", corev1.PullAlways), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAudit, + nil, + registryExpressionForTest("audit/.*"), + ), + }, + wantBlocking: false, + wantFinal: false, + wantAudits: 1, + wantMessage: []string{ + `containers[0] reference "audit/app:1.0.0" matched audit registry rule`, + `exp=audit/.*`, + }, + }, + { + name: "audit does not satisfy allow list", + pod: registryPodForTest("audit/app:1.0.0", corev1.PullAlways), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAudit, + nil, + registryExpressionForTest("audit/.*"), + ), + registryEnforceForTest( + apirules.ActionTypeAllow, + nil, + registryExpressionForTest("allowed/.*"), + ), + }, + wantBlocking: true, + wantFinal: false, + wantAudits: 1, + wantMessage: []string{ + `registry "audit/app:1.0.0" at containers[0] is not allowed by namespace rule`, + `Allowed registries`, + `exp: allowed/.*`, + }, + }, + { + name: "invalid registry regex returns error", + pod: registryPodForTest("harbor/app:1.0.0", corev1.PullAlways), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeDeny, + nil, + registryExpressionForTest("["), + ), + }, + wantErr: "registry: invalid rule", + }, + { + name: "pull policy missing is denied when matching allow rule requires policy", + pod: registryPodForTest("harbor/platform/app:1.0.0", ""), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAllow, + []corev1.PullPolicy{corev1.PullAlways}, + registryExpressionForTest("harbor/platform/.*"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `containers[0] reference "harbor/platform/app:1.0.0" must explicitly set pullPolicy`, + `allowed: Always`, + }, + }, + { + name: "pull policy mismatch is denied after allow", + pod: registryPodForTest("harbor/platform/app:1.0.0", corev1.PullNever), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAllow, + []corev1.PullPolicy{corev1.PullAlways, corev1.PullIfNotPresent}, + registryExpressionForTest("harbor/platform/.*"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `containers[0] reference "harbor/platform/app:1.0.0" uses pullPolicy=Never which is not allowed`, + `allowed: Always, IfNotPresent`, + }, + }, + { + name: "pull policy match is allowed after allow", + pod: registryPodForTest("harbor/platform/app:1.0.0", corev1.PullIfNotPresent), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAllow, + []corev1.PullPolicy{corev1.PullAlways, corev1.PullIfNotPresent}, + registryExpressionForTest("harbor/platform/.*"), + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `containers[0] reference "harbor/platform/app:1.0.0" is allowed by registry rule`, + }, + }, + { + name: "pull policy is not evaluated for final deny", + pod: registryPodForTest("harbor/platform/app:1.0.0", corev1.PullNever), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeDeny, + []corev1.PullPolicy{corev1.PullAlways}, + registryExpressionForTest("harbor/platform/.*"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `containers[0] reference "harbor/platform/app:1.0.0" is denied by registry rule`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := &podRules{ + registryCache: cache.NewRegistryRuleSetCache(cache.NewRegexCache()), + } + + evaluation, err := h.validateRegistries(tt.pod, tt.enforceBodies) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if evaluation == nil { + t.Fatalf("expected evaluation, got nil") + } + + if tt.wantBlocking && evaluation.Blocking == nil { + t.Fatalf("expected blocking decision, got nil") + } + + if !tt.wantBlocking && evaluation.Blocking != nil { + t.Fatalf("expected no blocking decision, got %#v", evaluation.Blocking) + } + + if tt.wantFinal && evaluation.Final == nil { + t.Fatalf("expected final decision, got nil") + } + + if !tt.wantFinal && evaluation.Final != nil { + t.Fatalf("expected no final decision, got %#v", evaluation.Final) + } + + if len(evaluation.Audits) != tt.wantAudits { + t.Fatalf("expected %d audit decisions, got %d", tt.wantAudits, len(evaluation.Audits)) + } + + if len(tt.wantMessage) > 0 { + msg := decisionMessageForRegistryTest(evaluation) + + for _, expected := range tt.wantMessage { + if !strings.Contains(msg, expected) { + t.Fatalf("expected message %q to contain %q", msg, expected) + } + } + } + + if evaluation.Final != nil { + if evaluation.Final.EventReason != events.ReasonForbiddenContainerRegistry { + t.Fatalf("final event reason = %q, want %q", evaluation.Final.EventReason, events.ReasonForbiddenContainerRegistry) + } + } + + if evaluation.Blocking != nil { + switch evaluation.Blocking.EventReason { + case events.ReasonForbiddenContainerRegistry, events.ReasonForbiddenPullPolicy: + default: + t.Fatalf( + "blocking event reason = %q, want %q or %q", + evaluation.Blocking.EventReason, + events.ReasonForbiddenContainerRegistry, + events.ReasonForbiddenPullPolicy, + ) + } + } + + for _, audit := range evaluation.Audits { + if audit.EventReason != events.ReasonForbiddenContainerRegistry { + t.Fatalf("audit event reason = %q, want %q", audit.EventReason, events.ReasonForbiddenContainerRegistry) + } + } + }) + } +} + +func TestPodRulesEvaluateRegistryReference(t *testing.T) { + h := &podRules{ + registryCache: cache.NewRegistryRuleSetCache(cache.NewRegexCache()), + } + + ref := registryReference{ + Target: apirules.ValidateContainers, + Reference: "harbor/platform/app:1.0.0", + PullPolicy: corev1.PullAlways, + Path: "containers[0]", + } + + evaluation, err := h.evaluateRegistryReference(ref, []*apirules.NamespaceRuleEnforceBody{ + registryEnforceForTest( + apirules.ActionTypeAllow, + nil, + registryExpressionForTest("harbor/platform/.*"), + ), + }) + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if evaluation == nil { + t.Fatalf("expected evaluation, got nil") + } + + if evaluation.Final == nil { + t.Fatalf("expected final decision, got nil") + } + + if evaluation.Final.Action != apirules.ActionTypeAllow { + t.Fatalf("final action = %q, want %q", evaluation.Final.Action, apirules.ActionTypeAllow) + } + + if evaluation.Final.MatchedValue == nil { + t.Fatalf("expected matched value") + } +} + +func TestDescribeRegistryRuleSet(t *testing.T) { + tests := []struct { + name string + rule registryRuleSet + want string + }{ + { + name: "empty rule set", + rule: registryRuleSet{}, + want: "", + }, + { + name: "exact", + rule: registryRuleSet{ + Registries: []apirules.OCIRegistry{ + registryExactForTest("harbor/platform/app:1.0.0"), + }, + }, + want: "exact: harbor/platform/app:1.0.0", + }, + { + name: "expression", + rule: registryRuleSet{ + Registries: []apirules.OCIRegistry{ + registryExpressionForTest("harbor/.*"), + }, + }, + want: "exp: harbor/.*", + }, + { + name: "exact and expression", + rule: registryRuleSet{ + Registries: []apirules.OCIRegistry{ + { + ExpressionMatch: api.ExpressionMatch{ + Exact: []string{ + "harbor/platform/app:1.0.0", + }, + ExpressionRegex: api.ExpressionRegex{ + Expression: "harbor/shared/.*", + }, + }, + }, + }, + }, + want: "exact: harbor/platform/app:1.0.0; exp: harbor/shared/.*", + }, + { + name: "multiple registries", + rule: registryRuleSet{ + Registries: []apirules.OCIRegistry{ + registryExpressionForTest("harbor/.*"), + registryExpressionForTest("registry.local/.*"), + }, + }, + want: "exp: harbor/.*, exp: registry.local/.*", + }, + { + name: "skips empty description", + rule: registryRuleSet{ + Registries: []apirules.OCIRegistry{ + {}, + registryExpressionForTest("harbor/.*"), + }, + }, + want: "exp: harbor/.*", + }, + { + name: "negated expression", + rule: registryRuleSet{ + Registries: []apirules.OCIRegistry{ + { + ExpressionMatch: api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: "trusted/.*", + Negate: true, + }, + }, + }, + }, + }, + want: "not exp: trusted/.*", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := describeRegistryRuleSet(tt.rule) + if got != tt.want { + t.Fatalf("describeRegistryRuleSet() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestRegistryReferencesFromPod(t *testing.T) { + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + { + Name: "init", + Image: "harbor/init/app:1.0.0", + ImagePullPolicy: corev1.PullAlways, + }, + }, + Containers: []corev1.Container{ + { + Name: "app", + Image: "harbor/app/app:1.0.0", + ImagePullPolicy: corev1.PullIfNotPresent, + }, + }, + EphemeralContainers: []corev1.EphemeralContainer{ + { + EphemeralContainerCommon: corev1.EphemeralContainerCommon{ + Name: "debug", + Image: "harbor/debug/app:1.0.0", + ImagePullPolicy: corev1.PullNever, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{}, + }, + }, + { + Name: "artifact", + VolumeSource: corev1.VolumeSource{ + Image: &corev1.ImageVolumeSource{ + Reference: "harbor/volume/artifact:1.0.0", + PullPolicy: corev1.PullAlways, + }, + }, + }, + }, + }, + } + + got := registryReferencesFromPod(pod) + + want := []registryReference{ + { + Target: apirules.ValidateInitContainers, + Reference: "harbor/init/app:1.0.0", + PullPolicy: corev1.PullAlways, + Path: "initContainers[0]", + }, + { + Target: apirules.ValidateContainers, + Reference: "harbor/app/app:1.0.0", + PullPolicy: corev1.PullIfNotPresent, + Path: "containers[0]", + }, + { + Target: apirules.ValidateEphemeralContainers, + Reference: "harbor/debug/app:1.0.0", + PullPolicy: corev1.PullNever, + Path: "ephemeralContainers[0]", + }, + { + Target: apirules.ValidateVolumes, + Reference: "harbor/volume/artifact:1.0.0", + PullPolicy: corev1.PullAlways, + Path: "volumes[1](artifact)", + }, + } + + if len(got) != len(want) { + t.Fatalf("expected %d refs, got %d: %#v", len(want), len(got), got) + } + + for i := range want { + if got[i] != want[i] { + t.Fatalf("ref[%d] = %#v, want %#v", i, got[i], want[i]) + } + } +} + +func TestRegistryReferencesFromPodNil(t *testing.T) { + if got := registryReferencesFromPod(nil); got != nil { + t.Fatalf("registryReferencesFromPod(nil) = %#v, want nil", got) + } +} + +func TestRegistryDecisionMessage(t *testing.T) { + matched := compiledRegistryRuleForTest( + registryExpressionForTest("harbor/platform/.*"), + nil, + ) + + tests := []struct { + name string + action apirules.ActionType + value ruleengine.Value + match any + want string + }{ + { + name: "audit", + action: apirules.ActionTypeAudit, + value: ruleengine.Value{ + Value: "harbor/platform/app:1.0.0", + Path: "containers[0]", + }, + match: matched, + want: `containers[0] reference "harbor/platform/app:1.0.0" matched audit registry rule "exp=harbor/platform/.*"`, + }, + { + name: "deny", + action: apirules.ActionTypeDeny, + value: ruleengine.Value{ + Value: "harbor/platform/app:1.0.0", + Path: "containers[0]", + }, + match: matched, + want: `containers[0] reference "harbor/platform/app:1.0.0" is denied by registry rule "exp=harbor/platform/.*"`, + }, + { + name: "allow", + action: apirules.ActionTypeAllow, + value: ruleengine.Value{ + Value: "harbor/platform/app:1.0.0", + Path: "containers[0]", + }, + match: matched, + want: `containers[0] reference "harbor/platform/app:1.0.0" is allowed by registry rule "exp=harbor/platform/.*"`, + }, + { + name: "unknown action", + action: apirules.ActionType("custom"), + value: ruleengine.Value{ + Value: "harbor/platform/app:1.0.0", + Path: "containers[0]", + }, + match: matched, + want: `containers[0] reference "harbor/platform/app:1.0.0" matched registry rule "exp=harbor/platform/.*" with action "custom"`, + }, + { + name: "nil matched value", + action: apirules.ActionTypeAllow, + value: ruleengine.Value{ + Value: "harbor/platform/app:1.0.0", + Path: "containers[0]", + }, + match: nil, + want: `containers[0] reference "harbor/platform/app:1.0.0" is allowed by registry rule ""`, + }, + { + name: "wrong matched value type", + action: apirules.ActionTypeAllow, + value: ruleengine.Value{ + Value: "harbor/platform/app:1.0.0", + Path: "containers[0]", + }, + match: "not-a-compiled-rule", + want: `containers[0] reference "harbor/platform/app:1.0.0" is allowed by registry rule ""`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := registryDecisionMessage(tt.action, tt.value, tt.match) + if got != tt.want { + t.Fatalf("registryDecisionMessage() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestRegistryRuleDescription(t *testing.T) { + tests := []struct { + name string + matched *cache.CompiledRule + want string + }{ + { + name: "nil", + matched: nil, + want: "", + }, + { + name: "unknown empty rule", + matched: &cache.CompiledRule{ + Match: api.ExpressionMatch{}, + }, + want: "", + }, + { + name: "exact values are sorted", + matched: compiledRegistryRuleForTest( + registryExactForTest("z.registry/app:1.0.0", "a.registry/app:1.0.0"), + nil, + ), + want: "exact=a.registry/app:1.0.0,z.registry/app:1.0.0", + }, + { + name: "expression", + matched: compiledRegistryRuleForTest( + registryExpressionForTest("harbor/platform/.*"), + nil, + ), + want: "exp=harbor/platform/.*", + }, + { + name: "negated expression", + matched: compiledRegistryRuleForTest( + apirules.OCIRegistry{ + ExpressionMatch: api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: "trusted/.*", + Negate: true, + }, + }, + }, + nil, + ), + want: "exp=trusted/.*,negate=true", + }, + { + name: "exact and expression", + matched: compiledRegistryRuleForTest( + apirules.OCIRegistry{ + ExpressionMatch: api.ExpressionMatch{ + Exact: []string{ + "harbor/platform/app:1.0.0", + }, + ExpressionRegex: api.ExpressionRegex{ + Expression: "harbor/shared/.*", + }, + }, + }, + nil, + ), + want: "exact=harbor/platform/app:1.0.0;exp=harbor/shared/.*", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := registryRuleDescription(tt.matched) + if got != tt.want { + t.Fatalf("registryRuleDescription() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestRegistryPullPolicyDecision(t *testing.T) { + ref := registryReference{ + Target: apirules.ValidateContainers, + Reference: "harbor/platform/app:1.0.0", + Path: "containers[0]", + } + + tests := []struct { + name string + ref registryReference + matched *cache.CompiledRule + wantNil bool + wantReason string + wantMessage []string + }{ + { + name: "nil matched rule", + ref: ref, + matched: nil, + wantNil: true, + }, + { + name: "no allowed policy", + ref: ref, + matched: compiledRegistryRuleForTest( + registryExpressionForTest("harbor/platform/.*"), + nil, + ), + wantNil: true, + }, + { + name: "empty allowed policy", + ref: ref, + matched: compiledRegistryRuleForTest( + registryExpressionForTest("harbor/platform/.*"), + []corev1.PullPolicy{}, + ), + wantNil: true, + }, + { + name: "missing pull policy is denied", + ref: ref, + matched: compiledRegistryRuleForTest( + registryExpressionForTest("harbor/platform/.*"), + []corev1.PullPolicy{corev1.PullAlways}, + ), + wantReason: events.ReasonForbiddenPullPolicy, + wantMessage: []string{ + `containers[0] reference "harbor/platform/app:1.0.0" must explicitly set pullPolicy`, + `allowed: Always`, + }, + }, + { + name: "disallowed pull policy is denied", + ref: registryReference{ + Target: ref.Target, + Reference: ref.Reference, + Path: ref.Path, + PullPolicy: corev1.PullNever, + }, + matched: compiledRegistryRuleForTest( + registryExpressionForTest("harbor/platform/.*"), + []corev1.PullPolicy{ + corev1.PullAlways, + corev1.PullIfNotPresent, + }, + ), + wantReason: events.ReasonForbiddenPullPolicy, + wantMessage: []string{ + `containers[0] reference "harbor/platform/app:1.0.0" uses pullPolicy=Never which is not allowed`, + `allowed: Always, IfNotPresent`, + }, + }, + { + name: "allowed pull policy succeeds", + ref: registryReference{ + Target: ref.Target, + Reference: ref.Reference, + Path: ref.Path, + PullPolicy: corev1.PullIfNotPresent, + }, + matched: compiledRegistryRuleForTest( + registryExpressionForTest("harbor/platform/.*"), + []corev1.PullPolicy{ + corev1.PullAlways, + corev1.PullIfNotPresent, + }, + ), + wantNil: true, + }, + { + name: "allowed policies are sorted in message", + ref: registryReference{ + Target: ref.Target, + Reference: ref.Reference, + Path: ref.Path, + PullPolicy: corev1.PullNever, + }, + matched: compiledRegistryRuleForTest( + registryExpressionForTest("harbor/platform/.*"), + []corev1.PullPolicy{ + corev1.PullIfNotPresent, + corev1.PullAlways, + }, + ), + wantReason: events.ReasonForbiddenPullPolicy, + wantMessage: []string{ + `allowed: Always, IfNotPresent`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := registryPullPolicyDecision(tt.ref, tt.matched) + + if tt.wantNil { + if got != nil { + t.Fatalf("expected nil decision, got %#v", got) + } + + return + } + + if got == nil { + t.Fatalf("expected decision, got nil") + } + + if got.EventReason != tt.wantReason { + t.Fatalf("event reason = %q, want %q", got.EventReason, tt.wantReason) + } + + if got.Action != apirules.ActionTypeDeny { + t.Fatalf("action = %q, want %q", got.Action, apirules.ActionTypeDeny) + } + + if got.SetName != "registry" { + t.Fatalf("set name = %q, want registry", got.SetName) + } + + if got.Value.Value != tt.ref.Reference { + t.Fatalf("decision value = %q, want %q", got.Value.Value, tt.ref.Reference) + } + + if got.Value.Path != tt.ref.Path { + t.Fatalf("decision path = %q, want %q", got.Value.Path, tt.ref.Path) + } + + for _, expected := range tt.wantMessage { + if !strings.Contains(got.Message, expected) { + t.Fatalf("expected message %q to contain %q", got.Message, expected) + } + } + }) + } +} + +func TestFormatAllowedPullPolicies(t *testing.T) { + tests := []struct { + name string + policies map[corev1.PullPolicy]struct{} + want string + }{ + { + name: "nil", + policies: nil, + want: "", + }, + { + name: "empty", + policies: map[corev1.PullPolicy]struct{}{}, + want: "", + }, + { + name: "single", + policies: map[corev1.PullPolicy]struct{}{ + corev1.PullAlways: {}, + }, + want: "Always", + }, + { + name: "sorted", + policies: map[corev1.PullPolicy]struct{}{ + corev1.PullNever: {}, + corev1.PullAlways: {}, + corev1.PullIfNotPresent: {}, + }, + want: "Always, IfNotPresent, Never", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatAllowedPullPolicies(tt.policies) + if got != tt.want { + t.Fatalf("formatAllowedPullPolicies() = %q, want %q", got, tt.want) + } + }) + } +} + +func registryEnforceForTest( + action apirules.ActionType, + policies []corev1.PullPolicy, + registries ...apirules.OCIRegistry, +) *apirules.NamespaceRuleEnforceBody { + out := &apirules.NamespaceRuleEnforceBody{ + Action: action, + Workloads: apirules.NamespaceRuleEnforceWorkloadsBody{ + Registries: registries, + }, + } + + if policies == nil { + return out + } + + for i := range out.Workloads.Registries { + out.Workloads.Registries[i].Policy = policies + } + + return out +} + +func registryExactForTest(values ...string) apirules.OCIRegistry { + return apirules.OCIRegistry{ + ExpressionMatch: api.ExpressionMatch{ + Exact: values, + }, + } +} + +func registryExpressionForTest(expression string) apirules.OCIRegistry { + return apirules.OCIRegistry{ + ExpressionMatch: api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: expression, + }, + }, + } +} + +func registryPodForTest(reference string, pullPolicy corev1.PullPolicy) *corev1.Pod { + return &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: reference, + ImagePullPolicy: pullPolicy, + }, + }, + }, + } +} + +func compiledRegistryRuleForTest( + registry apirules.OCIRegistry, + policies []corev1.PullPolicy, +) *cache.CompiledRule { + out := &cache.CompiledRule{ + Match: registry.ExpressionMatch, + } + + if len(policies) == 0 { + return out + } + + out.AllowedPolicy = make(map[corev1.PullPolicy]struct{}, len(policies)) + for _, policy := range policies { + out.AllowedPolicy[policy] = struct{}{} + } + + return out +} + +func decisionMessageForRegistryTest(evaluation interface { +}) string { + e, ok := evaluation.(*ruleengine.Evaluation) + if !ok || e == nil { + return "" + } + + switch { + case e.Blocking != nil: + return e.Blocking.Message + case e.Final != nil: + return e.Final.Message + case len(e.Audits) > 0: + return e.Audits[0].Message + default: + return "" + } +} diff --git a/internal/webhook/rules/pods/validation/schedulers.go b/internal/webhook/rules/pods/validation/schedulers.go index 73a8f85dc..3628ae746 100644 --- a/internal/webhook/rules/pods/validation/schedulers.go +++ b/internal/webhook/rules/pods/validation/schedulers.go @@ -8,6 +8,7 @@ import ( corev1 "k8s.io/api/core/v1" + rulesutils "github.com/projectcapsule/capsule/internal/webhook/rules" "github.com/projectcapsule/capsule/pkg/api" apirules "github.com/projectcapsule/capsule/pkg/api/rules" ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" @@ -49,6 +50,8 @@ func (h *podRules) validateSchedulers( Matched: matched, }, nil }, + RuleDescription: rulesutils.DescribeExpressionMatch, + AllowedDescription: "Allowed schedulers", }, ) } diff --git a/internal/webhook/rules/pods/validation/schedulers_test.go b/internal/webhook/rules/pods/validation/schedulers_test.go new file mode 100644 index 000000000..b8bb69f03 --- /dev/null +++ b/internal/webhook/rules/pods/validation/schedulers_test.go @@ -0,0 +1,521 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + + "github.com/projectcapsule/capsule/pkg/api" + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +func TestPodRulesValidateSchedulers(t *testing.T) { + tests := []struct { + name string + pod *corev1.Pod + enforceBodies []*apirules.NamespaceRuleEnforceBody + wantBlocking bool + wantFinal bool + wantAudits int + wantErr string + wantMessage []string + }{ + { + name: "pod without schedulerName returns empty evaluation", + pod: schedulerPodForTest(""), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeDeny, + schedulerExpressionForTest(".*"), + ), + }, + wantBlocking: false, + wantFinal: false, + }, + { + name: "blank schedulerName is trimmed and skipped", + pod: schedulerPodForTest(" "), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeDeny, + schedulerExpressionForTest(".*"), + ), + }, + wantBlocking: false, + wantFinal: false, + }, + { + name: "no scheduler rules returns empty evaluation", + pod: schedulerPodForTest("tenant-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + wantBlocking: false, + wantFinal: false, + }, + { + name: "nil enforce body is ignored", + pod: schedulerPodForTest("tenant-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nil, + schedulerEnforceForTest( + apirules.ActionTypeAllow, + schedulerExactForTest("tenant-scheduler"), + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `scheduler "tenant-scheduler" at spec.schedulerName is allowed by namespace rule`, + `matched allowed rule exact: tenant-scheduler`, + }, + }, + { + name: "allow exact scheduler", + pod: schedulerPodForTest("tenant-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeAllow, + schedulerExactForTest("tenant-scheduler"), + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `scheduler "tenant-scheduler" at spec.schedulerName is allowed by namespace rule`, + `matched allowed rule exact: tenant-scheduler`, + }, + }, + { + name: "allow regex scheduler", + pod: schedulerPodForTest("tenant-batch"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeAllow, + schedulerExpressionForTest("tenant-[a-z0-9-]+"), + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `scheduler "tenant-batch" at spec.schedulerName is allowed by namespace rule`, + `matched allowed rule exp: tenant-[a-z0-9-]+`, + }, + }, + { + name: "allow exact and regex in same matcher", + pod: schedulerPodForTest("tenant-special"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeAllow, + api.ExpressionMatch{ + Exact: []string{ + "default-scheduler", + }, + ExpressionRegex: api.ExpressionRegex{ + Expression: "tenant-[a-z0-9-]+", + }, + }, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `scheduler "tenant-special" at spec.schedulerName is allowed by namespace rule`, + `matched allowed rule exact: default-scheduler; exp: tenant-[a-z0-9-]+`, + }, + }, + { + name: "allow miss denies scheduler missing from allowed list", + pod: schedulerPodForTest("other-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeAllow, + schedulerExactForTest("tenant-scheduler"), + schedulerExpressionForTest("batch-[a-z0-9-]+"), + ), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `scheduler "other-scheduler" at spec.schedulerName is not allowed by namespace rule`, + `Allowed schedulers`, + `exact: tenant-scheduler`, + `exp: batch-[a-z0-9-]+`, + }, + }, + { + name: "deny matching exact scheduler", + pod: schedulerPodForTest("unsafe-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeDeny, + schedulerExactForTest("unsafe-scheduler"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `scheduler "unsafe-scheduler" at spec.schedulerName is denied by namespace rule`, + `matched denied rule exact: unsafe-scheduler`, + }, + }, + { + name: "default action is deny", + pod: schedulerPodForTest("unsafe-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + "", + schedulerExactForTest("unsafe-scheduler"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `scheduler "unsafe-scheduler" at spec.schedulerName is denied by namespace rule`, + `matched denied rule exact: unsafe-scheduler`, + }, + }, + { + name: "later deny overrides earlier allow", + pod: schedulerPodForTest("unsafe-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeAllow, + schedulerExpressionForTest(".*-scheduler"), + ), + schedulerEnforceForTest( + apirules.ActionTypeDeny, + schedulerExactForTest("unsafe-scheduler"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `scheduler "unsafe-scheduler" at spec.schedulerName is denied by namespace rule`, + `matched denied rule exact: unsafe-scheduler`, + }, + }, + { + name: "later allow overrides earlier deny", + pod: schedulerPodForTest("tenant-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeDeny, + schedulerExpressionForTest(".*-scheduler"), + ), + schedulerEnforceForTest( + apirules.ActionTypeAllow, + schedulerExactForTest("tenant-scheduler"), + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `scheduler "tenant-scheduler" at spec.schedulerName is allowed by namespace rule`, + `matched allowed rule exact: tenant-scheduler`, + }, + }, + { + name: "non matching later deny does not override earlier allow", + pod: schedulerPodForTest("tenant-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeAllow, + schedulerExactForTest("tenant-scheduler"), + ), + schedulerEnforceForTest( + apirules.ActionTypeDeny, + schedulerExactForTest("unsafe-scheduler"), + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `scheduler "tenant-scheduler" at spec.schedulerName is allowed by namespace rule`, + `matched allowed rule exact: tenant-scheduler`, + }, + }, + { + name: "audit match is observational", + pod: schedulerPodForTest("custom-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeAudit, + schedulerExactForTest("custom-scheduler"), + ), + }, + wantBlocking: false, + wantFinal: false, + wantAudits: 1, + wantMessage: []string{ + `scheduler "custom-scheduler" at spec.schedulerName matched audit namespace rule`, + `matched audit rule exact: custom-scheduler`, + }, + }, + { + name: "audit does not satisfy allow list", + pod: schedulerPodForTest("custom-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeAudit, + schedulerExactForTest("custom-scheduler"), + ), + schedulerEnforceForTest( + apirules.ActionTypeAllow, + schedulerExactForTest("tenant-scheduler"), + ), + }, + wantBlocking: true, + wantFinal: false, + wantAudits: 1, + wantMessage: []string{ + `scheduler "custom-scheduler" at spec.schedulerName is not allowed by namespace rule`, + `Allowed schedulers`, + `exact: tenant-scheduler`, + }, + }, + { + name: "negated exact deny matches every other scheduler", + pod: schedulerPodForTest("other-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeDeny, + schedulerNegatedExactForTest("tenant-scheduler"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `scheduler "other-scheduler" at spec.schedulerName is denied by namespace rule`, + `matched denied rule not exact: tenant-scheduler`, + }, + }, + { + name: "negated exact deny does not match excluded scheduler", + pod: schedulerPodForTest("tenant-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeDeny, + schedulerNegatedExactForTest("tenant-scheduler"), + ), + }, + wantBlocking: false, + wantFinal: false, + }, + { + name: "negated regex allow matches scheduler outside regex", + pod: schedulerPodForTest("external-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeAllow, + schedulerNegatedExpressionForTest("tenant-[a-z0-9-]+"), + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `scheduler "external-scheduler" at spec.schedulerName is allowed by namespace rule`, + `matched allowed rule not exp: tenant-[a-z0-9-]+`, + }, + }, + { + name: "invalid regex returns matcher error", + pod: schedulerPodForTest("tenant-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeDeny, + schedulerExpressionForTest("["), + ), + }, + wantErr: `scheduler: invalid rule`, + }, + { + name: "schedulerName is trimmed before evaluation", + pod: schedulerPodForTest(" tenant-scheduler "), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionTypeAllow, + schedulerExactForTest("tenant-scheduler"), + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `scheduler "tenant-scheduler" at spec.schedulerName is allowed by namespace rule`, + }, + }, + { + name: "unsupported action returns error", + pod: schedulerPodForTest("tenant-scheduler"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + schedulerEnforceForTest( + apirules.ActionType("invalid"), + schedulerExactForTest("tenant-scheduler"), + ), + }, + wantErr: `scheduler: unsupported rule action "invalid"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := podRulesForTest() + + evaluation, err := h.validateSchedulers(tt.pod, tt.enforceBodies) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if evaluation == nil { + t.Fatalf("expected evaluation, got nil") + } + + if tt.wantBlocking && evaluation.Blocking == nil { + t.Fatalf("expected blocking decision, got nil") + } + + if !tt.wantBlocking && evaluation.Blocking != nil { + t.Fatalf("expected no blocking decision, got %#v", evaluation.Blocking) + } + + if tt.wantFinal && evaluation.Final == nil { + t.Fatalf("expected final decision, got nil") + } + + if !tt.wantFinal && evaluation.Final != nil { + t.Fatalf("expected no final decision, got %#v", evaluation.Final) + } + + if len(evaluation.Audits) != tt.wantAudits { + t.Fatalf("expected %d audit decisions, got %d", tt.wantAudits, len(evaluation.Audits)) + } + + if len(tt.wantMessage) > 0 { + msg := decisionMessageForSchedulerTest(evaluation) + + for _, expected := range tt.wantMessage { + if !strings.Contains(msg, expected) { + t.Fatalf("expected message %q to contain %q", msg, expected) + } + } + } + + if evaluation.Final != nil { + if evaluation.Final.EventReason != events.ReasonForbiddenPodScheduler { + t.Fatalf("final event reason = %q, want %q", evaluation.Final.EventReason, events.ReasonForbiddenPodScheduler) + } + } + + if evaluation.Blocking != nil { + if evaluation.Blocking.EventReason != events.ReasonForbiddenPodScheduler { + t.Fatalf("blocking event reason = %q, want %q", evaluation.Blocking.EventReason, events.ReasonForbiddenPodScheduler) + } + } + + for _, audit := range evaluation.Audits { + if audit.EventReason != events.ReasonForbiddenPodScheduler { + t.Fatalf("audit event reason = %q, want %q", audit.EventReason, events.ReasonForbiddenPodScheduler) + } + } + }) + } +} + +func schedulerEnforceForTest( + action apirules.ActionType, + schedulers ...api.ExpressionMatch, +) *apirules.NamespaceRuleEnforceBody { + return &apirules.NamespaceRuleEnforceBody{ + Action: action, + Workloads: apirules.NamespaceRuleEnforceWorkloadsBody{ + Schedulers: schedulers, + }, + } +} + +func schedulerPodForTest(schedulerName string) *corev1.Pod { + return &corev1.Pod{ + Spec: corev1.PodSpec{ + SchedulerName: schedulerName, + Containers: []corev1.Container{ + { + Name: "shell", + Image: "busybox", + }, + }, + }, + } +} + +func schedulerExactForTest(values ...string) api.ExpressionMatch { + return api.ExpressionMatch{ + Exact: values, + } +} + +func schedulerExpressionForTest(expression string) api.ExpressionMatch { + return api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: expression, + }, + } +} + +func schedulerNegatedExactForTest(values ...string) api.ExpressionMatch { + return api.ExpressionMatch{ + Exact: values, + ExpressionRegex: api.ExpressionRegex{ + Negate: true, + }, + } +} + +func schedulerNegatedExpressionForTest(expression string) api.ExpressionMatch { + return api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: expression, + Negate: true, + }, + } +} + +func decisionMessageForSchedulerTest(evaluation interface { +}) string { + e, ok := evaluation.(*ruleengine.Evaluation) + if !ok || e == nil { + return "" + } + + switch { + case e.Blocking != nil: + return e.Blocking.Message + case e.Final != nil: + return e.Final.Message + case len(e.Audits) > 0: + return e.Audits[0].Message + default: + return "" + } +} diff --git a/internal/webhook/rules/services/validation/external_name.go b/internal/webhook/rules/services/validation/external_name.go new file mode 100644 index 000000000..d58e09a1c --- /dev/null +++ b/internal/webhook/rules/services/validation/external_name.go @@ -0,0 +1,86 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "fmt" + "strings" + + corev1 "k8s.io/api/core/v1" + + "github.com/projectcapsule/capsule/pkg/api" + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +func (h *serviceRules) validateExternalNames( + svc *corev1.Service, + enforceBodies []*apirules.NamespaceRuleEnforceBody, +) (*ruleengine.Evaluation, error) { + if svc == nil || svc.Spec.Type != corev1.ServiceTypeExternalName { + return nil, nil + } + + if strings.TrimSpace(svc.Spec.ExternalName) == "" { + return nil, nil + } + + return evaluateServiceRules[api.ExpressionMatch]( + svc, + enforceBodies, + serviceRuleSet[api.ExpressionMatch]{ + Name: "externalName hostname", + EventReason: events.ReasonForbiddenExternalName, + Values: func(svc *corev1.Service) []ruleengine.Value { + return []ruleengine.Value{ + { + Value: strings.TrimSpace(svc.Spec.ExternalName), + Path: "spec.externalName", + }, + } + }, + Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []api.ExpressionMatch { + if enforce == nil || enforce.Services.ExternalNames == nil { + return nil + } + + return enforce.Services.ExternalNames.Hostnames + }, + Matches: func(match api.ExpressionMatch, value ruleengine.Value) (ruleengine.Match, error) { + matched, err := match.MatchesWithExpressionMatcher(h.regexCache, value.Value) + if err != nil { + return ruleengine.Match{}, err + } + + out := ruleengine.Match{ + Matched: matched, + MatchedValue: describeExpressionMatch(match), + } + + if matched { + out.Detail = fmt.Sprintf("%q matched hostname rule %s", value.Value, describeExpressionMatch(match)) + } + + return out, nil + }, + RuleDescription: describeExpressionMatch, + AllowedDescription: "Allowed hostnames", + }, + ) +} + +func describeExpressionMatch(match api.ExpressionMatch) string { + parts := make([]string, 0, 2) + + if len(match.Exact) > 0 { + parts = append(parts, fmt.Sprintf("exact: %s", strings.Join(match.Exact, ", "))) + } + + if match.Expression != "" { + parts = append(parts, fmt.Sprintf("exp: %s", match.Expression)) + } + + return strings.Join(parts, "; ") +} diff --git a/internal/webhook/rules/services/validation/external_name_test.go b/internal/webhook/rules/services/validation/external_name_test.go new file mode 100644 index 000000000..85e0346a3 --- /dev/null +++ b/internal/webhook/rules/services/validation/external_name_test.go @@ -0,0 +1,531 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/projectcapsule/capsule/pkg/api" + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +func TestServiceRulesValidateExternalNames(t *testing.T) { + tests := []struct { + name string + svc *corev1.Service + enforceBodies []*apirules.NamespaceRuleEnforceBody + wantNil bool + wantBlocking bool + wantFinal bool + wantAudits int + wantErr string + wantMessage []string + }{ + { + name: "nil service returns nil evaluation", + svc: nil, + wantNil: true, + }, + { + name: "non ExternalName service returns nil evaluation", + svc: clusterIPServiceForExternalNameTest("cluster-ip"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest(apirules.ActionTypeDeny, expressionMatchForTest(".*")), + }, + wantNil: true, + }, + { + name: "empty externalName returns nil evaluation", + svc: externalNameServiceForTest("empty", " "), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest(apirules.ActionTypeDeny, expressionMatchForTest(".*")), + }, + wantNil: true, + }, + { + name: "no rules allows externalName without final decision", + svc: externalNameServiceForTest("external", "api.example.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + wantFinal: false, + wantBlocking: false, + }, + { + name: "allow exact hostname", + svc: externalNameServiceForTest("external", "internal.git.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest( + apirules.ActionTypeAllow, + exactMatchForTest("internal.git.com"), + ), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + `externalName hostname "internal.git.com" at spec.externalName is allowed by namespace rule`, + `"internal.git.com" matched hostname rule exact: internal.git.com`, + }, + }, + { + name: "allow regex hostname", + svc: externalNameServiceForTest("external", "api.example.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest( + apirules.ActionTypeAllow, + expressionMatchForTest(".*\\.example\\.com"), + ), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + `externalName hostname "api.example.com" at spec.externalName is allowed by namespace rule`, + `"api.example.com" matched hostname rule exp: .*\.example\.com`, + }, + }, + { + name: "allow exact and regex in same matcher", + svc: externalNameServiceForTest("external", "combined.internal.git.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest( + apirules.ActionTypeAllow, + api.ExpressionMatch{ + Exact: []string{ + "combined.internal.git.com", + }, + ExpressionRegex: api.ExpressionRegex{ + Expression: "combined\\..*\\.example\\.com", + }, + }, + ), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + `externalName hostname "combined.internal.git.com" at spec.externalName is allowed by namespace rule`, + `exact: combined.internal.git.com; exp: combined\..*\.example\.com`, + }, + }, + { + name: "allow miss denies and reports allowed hostnames", + svc: externalNameServiceForTest("external", "api.bad.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest( + apirules.ActionTypeAllow, + exactMatchForTest("internal.git.com"), + expressionMatchForTest(".*\\.example\\.com"), + ), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `externalName hostname "api.bad.com" at spec.externalName is not allowed by namespace rule`, + `Allowed hostnames`, + `exact: internal.git.com`, + `exp: .*\.example\.com`, + }, + }, + { + name: "deny matching exact hostname", + svc: externalNameServiceForTest("external", "blocked.example.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest( + apirules.ActionTypeDeny, + exactMatchForTest("blocked.example.com"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `externalName hostname "blocked.example.com" at spec.externalName is denied by namespace rule`, + `"blocked.example.com" matched hostname rule exact: blocked.example.com`, + }, + }, + { + name: "later deny overrides earlier allow", + svc: externalNameServiceForTest("external", "blocked.example.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest( + apirules.ActionTypeAllow, + expressionMatchForTest(".*\\.example\\.com"), + ), + externalNameEnforceForTest( + apirules.ActionTypeDeny, + exactMatchForTest("blocked.example.com"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `externalName hostname "blocked.example.com" at spec.externalName is denied by namespace rule`, + `"blocked.example.com" matched hostname rule exact: blocked.example.com`, + }, + }, + { + name: "later allow overrides earlier deny", + svc: externalNameServiceForTest("external", "trusted.example.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest( + apirules.ActionTypeDeny, + expressionMatchForTest(".*\\.example\\.com"), + ), + externalNameEnforceForTest( + apirules.ActionTypeAllow, + exactMatchForTest("trusted.example.com"), + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `externalName hostname "trusted.example.com" at spec.externalName is allowed by namespace rule`, + `"trusted.example.com" matched hostname rule exact: trusted.example.com`, + }, + }, + { + name: "audit match is observational", + svc: externalNameServiceForTest("external", "audit.internal"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest( + apirules.ActionTypeAudit, + expressionMatchForTest("audit\\..*"), + ), + }, + wantBlocking: false, + wantFinal: false, + wantAudits: 1, + wantMessage: []string{ + `externalName hostname "audit.internal" at spec.externalName matched audit namespace rule`, + `"audit.internal" matched hostname rule exp: audit\..*`, + }, + }, + { + name: "audit does not satisfy allow list", + svc: externalNameServiceForTest("external", "audit.internal"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest( + apirules.ActionTypeAudit, + expressionMatchForTest("audit\\..*"), + ), + externalNameEnforceForTest( + apirules.ActionTypeAllow, + expressionMatchForTest("allowed\\..*"), + ), + }, + wantBlocking: true, + wantFinal: false, + wantAudits: 1, + wantMessage: []string{ + `externalName hostname "audit.internal" at spec.externalName is not allowed by namespace rule`, + `Allowed hostnames`, + `exp: allowed\..*`, + }, + }, + { + name: "negated regex deny matches untrusted hostname", + svc: externalNameServiceForTest("external", "api.example.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest( + apirules.ActionTypeDeny, + negatedExpressionMatchForTest("trusted\\..*"), + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `externalName hostname "api.example.com" at spec.externalName is denied by namespace rule`, + `"api.example.com" matched hostname rule exp: trusted\..*`, + }, + }, + { + name: "negated regex deny does not match trusted hostname", + svc: externalNameServiceForTest("external", "trusted.api"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest( + apirules.ActionTypeDeny, + negatedExpressionMatchForTest("trusted\\..*"), + ), + }, + wantBlocking: false, + wantFinal: false, + }, + { + name: "invalid regex returns matcher error", + svc: externalNameServiceForTest("external", "api.example.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalNameEnforceForTest( + apirules.ActionTypeDeny, + expressionMatchForTest("["), + ), + }, + wantErr: `externalName hostname: invalid rule`, + }, + { + name: "nil enforce body is ignored", + svc: externalNameServiceForTest("external", "api.example.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nil, + externalNameEnforceForTest( + apirules.ActionTypeAllow, + expressionMatchForTest(".*\\.example\\.com"), + ), + }, + wantFinal: true, + wantBlocking: false, + }, + { + name: "enforce without externalName rules is ignored", + svc: externalNameServiceForTest("external", "api.example.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + wantFinal: false, + wantBlocking: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := serviceRulesForTest() + + evaluation, err := h.validateExternalNames(tt.svc, tt.enforceBodies) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if tt.wantNil { + if evaluation != nil { + t.Fatalf("expected nil evaluation, got %#v", evaluation) + } + + return + } + + if evaluation == nil { + t.Fatalf("expected evaluation, got nil") + } + + if tt.wantBlocking && evaluation.Blocking == nil { + t.Fatalf("expected blocking decision, got nil") + } + + if !tt.wantBlocking && evaluation.Blocking != nil { + t.Fatalf("expected no blocking decision, got %#v", evaluation.Blocking) + } + + if tt.wantFinal && evaluation.Final == nil { + t.Fatalf("expected final decision, got nil") + } + + if !tt.wantFinal && evaluation.Final != nil { + t.Fatalf("expected no final decision, got %#v", evaluation.Final) + } + + if len(evaluation.Audits) != tt.wantAudits { + t.Fatalf("expected %d audit decisions, got %d", tt.wantAudits, len(evaluation.Audits)) + } + + if len(tt.wantMessage) > 0 { + msg := decisionMessageForExternalNameTest(evaluation) + + for _, expected := range tt.wantMessage { + if !strings.Contains(msg, expected) { + t.Fatalf("expected message %q to contain %q", msg, expected) + } + } + } + + if evaluation.Final != nil { + if evaluation.Final.EventReason != events.ReasonForbiddenExternalName { + t.Fatalf("final event reason = %q, want %q", evaluation.Final.EventReason, events.ReasonForbiddenExternalName) + } + } + + if evaluation.Blocking != nil { + if evaluation.Blocking.EventReason != events.ReasonForbiddenExternalName { + t.Fatalf("blocking event reason = %q, want %q", evaluation.Blocking.EventReason, events.ReasonForbiddenExternalName) + } + } + + for _, audit := range evaluation.Audits { + if audit.EventReason != events.ReasonForbiddenExternalName { + t.Fatalf("audit event reason = %q, want %q", audit.EventReason, events.ReasonForbiddenExternalName) + } + } + }) + } +} + +func TestDescribeExpressionMatch(t *testing.T) { + tests := []struct { + name string + match api.ExpressionMatch + want string + }{ + { + name: "empty matcher", + match: api.ExpressionMatch{}, + want: "", + }, + { + name: "exact only", + match: api.ExpressionMatch{ + Exact: []string{ + "internal.git.com", + "api.example.com", + }, + }, + want: "exact: internal.git.com, api.example.com", + }, + { + name: "expression only", + match: api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: ".*\\.example\\.com", + }, + }, + want: "exp: .*\\.example\\.com", + }, + { + name: "exact and expression", + match: api.ExpressionMatch{ + Exact: []string{ + "internal.git.com", + }, + ExpressionRegex: api.ExpressionRegex{ + Expression: ".*\\.example\\.com", + }, + }, + want: "exact: internal.git.com; exp: .*\\.example\\.com", + }, + { + name: "negate is currently not included in description", + match: api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: "trusted\\..*", + Negate: true, + }, + }, + want: "exp: trusted\\..*", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := describeExpressionMatch(tt.match) + if got != tt.want { + t.Fatalf("describeExpressionMatch() = %q, want %q", got, tt.want) + } + }) + } +} + +func externalNameEnforceForTest( + action apirules.ActionType, + hostnames ...api.ExpressionMatch, +) *apirules.NamespaceRuleEnforceBody { + return &apirules.NamespaceRuleEnforceBody{ + Action: action, + Services: apirules.NamespaceRuleEnforceServicesBody{ + ExternalNames: &apirules.ServiceExternalNameRule{ + Hostnames: hostnames, + }, + }, + } +} + +func externalNameServiceForTest(name string, externalName string) *corev1.Service { + return &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeExternalName, + ExternalName: externalName, + Ports: []corev1.ServicePort{ + { + Name: "https", + Port: 443, + TargetPort: intstr.FromInt(443), + }, + }, + }, + } +} + +func clusterIPServiceForExternalNameTest(name string) *corev1.Service { + return &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 8080, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + } +} + +func exactMatchForTest(values ...string) api.ExpressionMatch { + return api.ExpressionMatch{ + Exact: values, + } +} + +func expressionMatchForTest(expression string) api.ExpressionMatch { + return api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: expression, + }, + } +} + +func negatedExpressionMatchForTest(expression string) api.ExpressionMatch { + return api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: expression, + Negate: true, + }, + } +} + +func decisionMessageForExternalNameTest(evaluation interface { +}) string { + e, ok := evaluation.(*ruleengine.Evaluation) + if !ok || e == nil { + return "" + } + + switch { + case e.Blocking != nil: + return e.Blocking.Message + case e.Final != nil: + return e.Final.Message + case len(e.Audits) > 0: + return e.Audits[0].Message + default: + return "" + } +} diff --git a/internal/webhook/rules/services/validation/factory.go b/internal/webhook/rules/services/validation/factory.go new file mode 100644 index 000000000..7df917e6d --- /dev/null +++ b/internal/webhook/rules/services/validation/factory.go @@ -0,0 +1,180 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "context" + "errors" + + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/internal/cache" + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/handlers" +) + +type serviceRuleSet[R any] = ruleengine.Set[R, *corev1.Service] + +func evaluateServiceRules[R any]( + svc *corev1.Service, + enforceBodies []*apirules.NamespaceRuleEnforceBody, + set serviceRuleSet[R], +) (*ruleengine.Evaluation, error) { + if svc == nil || len(enforceBodies) == 0 { + return nil, nil + } + + return ruleengine.EvaluateEnforce( + svc, + enforceBodies, + set, + ) +} + +type serviceRuleValidator func( + *corev1.Service, + []*apirules.NamespaceRuleEnforceBody, +) (*ruleengine.Evaluation, error) + +type serviceRules struct { + rules []serviceRuleValidator + regexCache *cache.RegexCache +} + +func ServiceRules( + regexCache *cache.RegexCache, +) handlers.TypedHandlerWithTenantWithRuleset[*corev1.Service] { + if regexCache == nil { + regexCache = cache.NewRegexCache() + } + + h := &serviceRules{ + regexCache: regexCache, + } + + h.rules = []serviceRuleValidator{ + h.validateServiceTypes, + h.validateLoadBalancers, + h.validateExternalNames, + h.validateNodePorts, + } + + return h +} + +func (h *serviceRules) OnCreate( + _ client.Client, + _ client.Reader, + svc *corev1.Service, + _ admission.Decoder, + recorder events.EventRecorder, + tnt *capsulev1beta2.Tenant, + bodies []*apirules.NamespaceRuleBodyNamespace, +) handlers.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + enforceBodies := ruleengine.EnforceBodiesFromNamespaceRules(bodies) + + if err := h.validateServiceRules(ctx, req, svc, tnt, recorder, enforceBodies); err != nil { + return ad.Deny(err.Error()) + } + + return nil + } +} + +func (h *serviceRules) OnUpdate( + _ client.Client, + _ client.Reader, + _ *corev1.Service, + svc *corev1.Service, + _ admission.Decoder, + recorder events.EventRecorder, + tnt *capsulev1beta2.Tenant, + bodies []*apirules.NamespaceRuleBodyNamespace, +) handlers.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + enforceBodies := ruleengine.EnforceBodiesFromNamespaceRules(bodies) + + if err := h.validateServiceRules(ctx, req, svc, tnt, recorder, enforceBodies); err != nil { + return ad.Deny(err.Error()) + } + + return nil + } +} + +func (h *serviceRules) OnDelete( + _ client.Client, + _ client.Reader, + _ *corev1.Service, + _ admission.Decoder, + _ events.EventRecorder, + _ *capsulev1beta2.Tenant, + _ []*apirules.NamespaceRuleBodyNamespace, +) handlers.Func { + return func(context.Context, admission.Request) *admission.Response { + return nil + } +} + +func (h *serviceRules) validateServiceRules( + ctx context.Context, + req admission.Request, + svc *corev1.Service, + tnt *capsulev1beta2.Tenant, + recorder events.EventRecorder, + enforceBodies []*apirules.NamespaceRuleEnforceBody, +) error { + for _, evaluate := range h.rules { + evaluation, err := evaluate(svc, enforceBodies) + if err != nil { + return err + } + + if evaluation == nil { + continue + } + + for _, audit := range evaluation.Audits { + recorder.LabeledEvent( + svc, + corev1.EventTypeNormal, + events.ReasonNamespaceRuleAudit, + events.ActionRuleAudit, + audit.Message, + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) + } + + if err := evaluation.BlockingError(); err != nil { + var decisionErr *ruleengine.DecisionError + if errors.As(err, &decisionErr) && decisionErr.Decision != nil { + recorder.LabeledEvent( + svc, + corev1.EventTypeWarning, + decisionErr.Decision.EventReason, + events.ActionValidationDenied, + decisionErr.Decision.Message, + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) + } + + return err + } + } + + return nil +} diff --git a/internal/webhook/rules/services/validation/factory_test.go b/internal/webhook/rules/services/validation/factory_test.go new file mode 100644 index 000000000..c752f6a78 --- /dev/null +++ b/internal/webhook/rules/services/validation/factory_test.go @@ -0,0 +1,12 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import "github.com/projectcapsule/capsule/internal/cache" + +func serviceRulesForTest() *serviceRules { + return &serviceRules{ + regexCache: cache.NewRegexCache(), + } +} diff --git a/internal/webhook/rules/services/validation/loadbalancer.go b/internal/webhook/rules/services/validation/loadbalancer.go new file mode 100644 index 000000000..34a298e54 --- /dev/null +++ b/internal/webhook/rules/services/validation/loadbalancer.go @@ -0,0 +1,204 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "fmt" + "net" + "strings" + + corev1 "k8s.io/api/core/v1" + + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +type loadBalancerCIDRRule struct { + CIDRs []string +} + +func (h *serviceRules) validateLoadBalancers( + svc *corev1.Service, + enforceBodies []*apirules.NamespaceRuleEnforceBody, +) (*ruleengine.Evaluation, error) { + if svc == nil || svc.Spec.Type != corev1.ServiceTypeLoadBalancer { + return nil, nil + } + + if requiresLoadBalancerCIDRs(enforceBodies) && len(loadBalancerCIDRValues(svc)) == 0 { + return &ruleengine.Evaluation{ + Blocking: &ruleengine.Decision{ + SetName: "loadBalancer CIDR", + EventReason: events.ReasonForbiddenLoadBalancerCIDR, + Action: apirules.ActionTypeDeny, + Value: ruleengine.Value{ + Value: string(corev1.ServiceTypeLoadBalancer), + Path: "spec.type", + }, + Message: "loadBalancer service requires spec.loadBalancerIP or spec.loadBalancerSourceRanges because loadBalancer CIDR constraints are enforced by namespace rule", + }, + }, nil + } + + values := loadBalancerCIDRValues(svc) + if len(values) == 0 { + return nil, nil + } + + return evaluateServiceRules[loadBalancerCIDRRule]( + svc, + enforceBodies, + serviceRuleSet[loadBalancerCIDRRule]{ + Name: "loadBalancer CIDR", + EventReason: events.ReasonForbiddenLoadBalancerCIDR, + Values: func(_ *corev1.Service) []ruleengine.Value { + return values + }, + Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []loadBalancerCIDRRule { + if enforce == nil || + enforce.Services.LoadBalancers == nil || + len(enforce.Services.LoadBalancers.CIDRs) == 0 { + return nil + } + + cidrs := make([]string, 0, len(enforce.Services.LoadBalancers.CIDRs)) + for _, cidr := range enforce.Services.LoadBalancers.CIDRs { + cidr = strings.TrimSpace(cidr) + if cidr == "" { + continue + } + + cidrs = append(cidrs, cidr) + } + + if len(cidrs) == 0 { + return nil + } + + return []loadBalancerCIDRRule{ + { + CIDRs: cidrs, + }, + } + }, + Matches: func(rule loadBalancerCIDRRule, value ruleengine.Value) (ruleengine.Match, error) { + if len(rule.CIDRs) == 0 { + return ruleengine.Match{}, nil + } + + for _, rawCIDR := range rule.CIDRs { + allowedCIDR, err := parseCIDR(rawCIDR) + if err != nil { + return ruleengine.Match{}, fmt.Errorf("invalid loadBalancer CIDR %q: %w", rawCIDR, err) + } + + if ip := net.ParseIP(value.Value); ip != nil { + if !cidrContainsIP(allowedCIDR, ip) { + continue + } + + return ruleengine.Match{ + Matched: true, + MatchedValue: rawCIDR, + Detail: fmt.Sprintf("%s is contained in %s", value.Value, rawCIDR), + }, nil + } + + _, requestedCIDR, err := net.ParseCIDR(value.Value) + if err != nil { + return ruleengine.Match{}, fmt.Errorf( + "%s contains invalid IP or CIDR %q: %w", + value.Path, + value.Value, + err, + ) + } + + if !cidrContainsCIDR(allowedCIDR, requestedCIDR) { + continue + } + + return ruleengine.Match{ + Matched: true, + MatchedValue: rawCIDR, + Detail: fmt.Sprintf("%s is contained in %s", value.Value, rawCIDR), + }, nil + } + + return ruleengine.Match{ + Matched: false, + }, nil + }, + RuleDescription: func(rule loadBalancerCIDRRule) string { + return strings.Join(rule.CIDRs, ", ") + }, + AllowedDescription: "Allowed CIDRs", + }, + ) +} + +func requiresLoadBalancerCIDRs( + enforceBodies []*apirules.NamespaceRuleEnforceBody, +) bool { + for _, enforce := range enforceBodies { + if enforce == nil || + enforce.Services.LoadBalancers == nil { + continue + } + + if len(enforce.Services.LoadBalancers.CIDRs) > 0 { + return true + } + } + + return false +} + +func parseCIDR(raw string) (*net.IPNet, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("CIDR is empty") + } + + if !strings.Contains(raw, "/") { + ip := net.ParseIP(raw) + if ip == nil { + return nil, fmt.Errorf("invalid CIDR %q", raw) + } + + if ip.To4() != nil { + raw += "/32" + } else { + raw += "/128" + } + } + + _, network, err := net.ParseCIDR(raw) + if err != nil { + return nil, err + } + + return network, nil +} + +func loadBalancerCIDRValues(svc *corev1.Service) []ruleengine.Value { + out := make([]ruleengine.Value, 0, 1+len(svc.Spec.LoadBalancerSourceRanges)) + + if svc.Spec.LoadBalancerIP != "" { + out = append(out, ruleengine.Value{ + Value: svc.Spec.LoadBalancerIP, + Path: "spec.loadBalancerIP", + }) + } + + for i, sourceRange := range svc.Spec.LoadBalancerSourceRanges { + out = append(out, ruleengine.Value{ + Value: sourceRange, + Path: fmt.Sprintf("spec.loadBalancerSourceRanges[%d]", i), + }) + } + + return out +} diff --git a/internal/webhook/rules/services/validation/loadbalancer_test.go b/internal/webhook/rules/services/validation/loadbalancer_test.go new file mode 100644 index 000000000..ba46cecbc --- /dev/null +++ b/internal/webhook/rules/services/validation/loadbalancer_test.go @@ -0,0 +1,903 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "net" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +func TestServiceRulesValidateLoadBalancers(t *testing.T) { + tests := []struct { + name string + svc *corev1.Service + enforceBodies []*apirules.NamespaceRuleEnforceBody + wantNil bool + wantBlocking bool + wantFinal bool + wantErr string + wantMessage []string + }{ + { + name: "nil service returns nil evaluation", + svc: nil, + wantNil: true, + }, + { + name: "non LoadBalancer service returns nil evaluation", + svc: clusterIPServiceForLoadBalancerTest("cluster-ip"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeDeny, "10.0.0.0/8"), + }, + wantNil: true, + }, + { + name: "LoadBalancer without values and without CIDR rules returns nil evaluation", + svc: loadBalancerServiceForTest("lb", "", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + wantNil: true, + }, + { + name: "requires loadBalancerIP or source ranges when CIDR rules are configured", + svc: loadBalancerServiceForTest("lb", "", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.0.2/32"), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + "loadBalancer service requires spec.loadBalancerIP or spec.loadBalancerSourceRanges", + "loadBalancer CIDR constraints are enforced by namespace rule", + }, + }, + { + name: "empty CIDR entry still triggers required value check", + svc: loadBalancerServiceForTest("lb", "", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, ""), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + "loadBalancer service requires spec.loadBalancerIP or spec.loadBalancerSourceRanges", + }, + }, + { + name: "allows exact IPv4 loadBalancerIP inside single IP CIDR", + svc: loadBalancerServiceForTest("lb", "10.0.0.2", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.0.2/32"), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + `loadBalancer CIDR "10.0.0.2" at spec.loadBalancerIP is allowed by namespace rule`, + "10.0.0.2 is contained in 10.0.0.2/32", + }, + }, + { + name: "allows IPv4 loadBalancerIP inside configured range", + svc: loadBalancerServiceForTest("lb", "10.0.1.44", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.1.0/24"), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + `loadBalancer CIDR "10.0.1.44" at spec.loadBalancerIP is allowed by namespace rule`, + "10.0.1.44 is contained in 10.0.1.0/24", + }, + }, + { + name: "allows plain IPv4 rule by normalizing to host CIDR", + svc: loadBalancerServiceForTest("lb", "10.0.0.2", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.0.2"), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + "10.0.0.2 is contained in 10.0.0.2", + }, + }, + { + name: "allows IPv6 loadBalancerIP inside configured range", + svc: loadBalancerServiceForTest("lb", "2001:db8::1", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "2001:db8::/32"), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + `loadBalancer CIDR "2001:db8::1" at spec.loadBalancerIP is allowed by namespace rule`, + "2001:db8::1 is contained in 2001:db8::/32", + }, + }, + { + name: "allows plain IPv6 rule by normalizing to host CIDR", + svc: loadBalancerServiceForTest("lb", "2001:db8::2", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "2001:db8::2"), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + "2001:db8::2 is contained in 2001:db8::2", + }, + }, + { + name: "allow miss denies loadBalancerIP outside configured CIDRs", + svc: loadBalancerServiceForTest("lb", "10.0.171.239", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.0.2/32", "10.0.1.0/24"), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `loadBalancer CIDR "10.0.171.239" at spec.loadBalancerIP is not allowed by namespace rule`, + "Allowed CIDRs", + "10.0.0.2/32", + "10.0.1.0/24", + }, + }, + { + name: "allows source range fully contained in configured CIDR", + svc: loadBalancerServiceForTest("lb", "", []string{"10.0.1.0/25"}), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.1.0/24"), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + `loadBalancer CIDR "10.0.1.0/25" at spec.loadBalancerSourceRanges[0] is allowed by namespace rule`, + "10.0.1.0/25 is contained in 10.0.1.0/24", + }, + }, + { + name: "allow miss denies source range not fully contained in configured CIDR", + svc: loadBalancerServiceForTest("lb", "", []string{"10.0.1.0/23"}), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.1.0/24"), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `loadBalancer CIDR "10.0.1.0/23" at spec.loadBalancerSourceRanges[0] is not allowed by namespace rule`, + "Allowed CIDRs", + "10.0.1.0/24", + }, + }, + { + name: "multiple values deny if any value misses allow list", + svc: loadBalancerServiceForTest("lb", "10.0.1.44", []string{"172.16.0.0/16"}), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.1.0/24"), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `loadBalancer CIDR "172.16.0.0/16" at spec.loadBalancerSourceRanges[0] is not allowed by namespace rule`, + "Allowed CIDRs", + "10.0.1.0/24", + }, + }, + { + name: "deny matching CIDR", + svc: loadBalancerServiceForTest("lb", "10.0.66.10", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeDeny, "10.0.66.0/24"), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `loadBalancer CIDR "10.0.66.10" at spec.loadBalancerIP is denied by namespace rule`, + "10.0.66.10 is contained in 10.0.66.0/24", + }, + }, + { + name: "later deny overrides earlier allow", + svc: loadBalancerServiceForTest("lb", "10.0.66.10", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.0.0/8"), + loadBalancerEnforceForTest(apirules.ActionTypeDeny, "10.0.66.0/24"), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `loadBalancer CIDR "10.0.66.10" at spec.loadBalancerIP is denied by namespace rule`, + "10.0.66.10 is contained in 10.0.66.0/24", + }, + }, + { + name: "later allow overrides earlier deny", + svc: loadBalancerServiceForTest("lb", "10.0.171.239", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeDeny, "10.0.0.0/8"), + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.171.0/24"), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `loadBalancer CIDR "10.0.171.239" at spec.loadBalancerIP is allowed by namespace rule`, + "10.0.171.239 is contained in 10.0.171.0/24", + }, + }, + { + name: "audit match is observational", + svc: loadBalancerServiceForTest("lb", "10.0.171.239", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAudit, "10.0.171.0/24"), + }, + wantBlocking: false, + wantFinal: false, + wantMessage: []string{ + `loadBalancer CIDR "10.0.171.239" at spec.loadBalancerIP matched audit namespace rule`, + "10.0.171.239 is contained in 10.0.171.0/24", + }, + }, + { + name: "audit does not satisfy allow list", + svc: loadBalancerServiceForTest("lb", "10.0.171.239", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAudit, "10.0.171.0/24"), + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.0.2/32"), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `loadBalancer CIDR "10.0.171.239" at spec.loadBalancerIP is not allowed by namespace rule`, + "Allowed CIDRs", + "10.0.0.2/32", + }, + }, + { + name: "invalid configured CIDR returns matcher error", + svc: loadBalancerServiceForTest("lb", "10.0.0.2", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeDeny, "10.0.0.0/33"), + }, + wantErr: `loadBalancer CIDR: invalid rule: invalid loadBalancer CIDR "10.0.0.0/33"`, + }, + { + name: "invalid requested source range returns matcher error", + svc: loadBalancerServiceForTest("lb", "", []string{"not-a-cidr"}), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.0.0/8"), + }, + wantErr: `loadBalancer CIDR: invalid rule: spec.loadBalancerSourceRanges[0] contains invalid IP or CIDR "not-a-cidr"`, + }, + { + name: "nil enforce body is ignored", + svc: loadBalancerServiceForTest("lb", "10.0.0.2", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nil, + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "10.0.0.2/32"), + }, + wantFinal: true, + wantBlocking: false, + }, + { + name: "enforce without loadBalancer rules is ignored", + svc: loadBalancerServiceForTest("lb", "10.0.0.2", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + wantFinal: false, + wantBlocking: false, + }, + { + name: "empty configured CIDRs are ignored during rule extraction when values exist", + svc: loadBalancerServiceForTest("lb", "10.0.0.2", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionTypeAllow, "", " "), + }, + wantFinal: false, + wantBlocking: false, + }, + { + name: "unsupported action returns error", + svc: loadBalancerServiceForTest("lb", "10.0.0.2", nil), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + loadBalancerEnforceForTest(apirules.ActionType("invalid"), "10.0.0.2/32"), + }, + wantErr: `loadBalancer CIDR: unsupported rule action "invalid"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := serviceRulesForTest() + + evaluation, err := h.validateLoadBalancers(tt.svc, tt.enforceBodies) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if tt.wantNil { + if evaluation != nil { + t.Fatalf("expected nil evaluation, got %#v", evaluation) + } + + return + } + + if evaluation == nil { + t.Fatalf("expected evaluation, got nil") + } + + if tt.wantBlocking && evaluation.Blocking == nil { + t.Fatalf("expected blocking decision, got nil") + } + + if !tt.wantBlocking && evaluation.Blocking != nil { + t.Fatalf("expected no blocking decision, got %#v", evaluation.Blocking) + } + + if tt.wantFinal && evaluation.Final == nil { + t.Fatalf("expected final decision, got nil") + } + + if !tt.wantFinal && evaluation.Final != nil { + t.Fatalf("expected no final decision, got %#v", evaluation.Final) + } + + if len(tt.wantMessage) > 0 { + msg := decisionMessageForLoadBalancerTest(evaluation) + + for _, expected := range tt.wantMessage { + if !strings.Contains(msg, expected) { + t.Fatalf("expected message %q to contain %q", msg, expected) + } + } + } + + if evaluation.Final != nil { + if evaluation.Final.EventReason != events.ReasonForbiddenLoadBalancerCIDR { + t.Fatalf("final event reason = %q, want %q", evaluation.Final.EventReason, events.ReasonForbiddenLoadBalancerCIDR) + } + } + + if evaluation.Blocking != nil { + if evaluation.Blocking.EventReason != events.ReasonForbiddenLoadBalancerCIDR { + t.Fatalf("blocking event reason = %q, want %q", evaluation.Blocking.EventReason, events.ReasonForbiddenLoadBalancerCIDR) + } + } + + for _, audit := range evaluation.Audits { + if audit.EventReason != events.ReasonForbiddenLoadBalancerCIDR { + t.Fatalf("audit event reason = %q, want %q", audit.EventReason, events.ReasonForbiddenLoadBalancerCIDR) + } + } + }) + } +} + +func TestRequiresLoadBalancerCIDRs(t *testing.T) { + tests := []struct { + name string + enforceBodies []*apirules.NamespaceRuleEnforceBody + want bool + }{ + { + name: "nil bodies", + enforceBodies: nil, + want: false, + }, + { + name: "nil enforce body", + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nil, + }, + want: false, + }, + { + name: "missing loadBalancer rules", + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + want: false, + }, + { + name: "empty cidr list", + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Services: apirules.NamespaceRuleEnforceServicesBody{ + LoadBalancers: &apirules.ServiceLoadBalancerRule{}, + }, + }, + }, + want: false, + }, + { + name: "blank cidr entry still counts as configured", + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Services: apirules.NamespaceRuleEnforceServicesBody{ + LoadBalancers: &apirules.ServiceLoadBalancerRule{ + CIDRs: []string{""}, + }, + }, + }, + }, + want: true, + }, + { + name: "cidr configured", + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Services: apirules.NamespaceRuleEnforceServicesBody{ + LoadBalancers: &apirules.ServiceLoadBalancerRule{ + CIDRs: []string{"10.0.0.0/8"}, + }, + }, + }, + }, + want: true, + }, + { + name: "later cidr configured", + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nil, + {}, + { + Services: apirules.NamespaceRuleEnforceServicesBody{ + LoadBalancers: &apirules.ServiceLoadBalancerRule{ + CIDRs: []string{"10.0.0.0/8"}, + }, + }, + }, + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := requiresLoadBalancerCIDRs(tt.enforceBodies) + if got != tt.want { + t.Fatalf("requiresLoadBalancerCIDRs() = %t, want %t", got, tt.want) + } + }) + } +} + +func TestParseCIDR(t *testing.T) { + tests := []struct { + name string + raw string + wantNetwork string + wantErr string + }{ + { + name: "IPv4 CIDR", + raw: "10.0.0.0/8", + wantNetwork: "10.0.0.0/8", + }, + { + name: "IPv4 host", + raw: "10.0.0.2", + wantNetwork: "10.0.0.2/32", + }, + { + name: "IPv4 host with whitespace", + raw: " 10.0.0.2 ", + wantNetwork: "10.0.0.2/32", + }, + { + name: "IPv6 CIDR", + raw: "2001:db8::/32", + wantNetwork: "2001:db8::/32", + }, + { + name: "IPv6 host", + raw: "2001:db8::2", + wantNetwork: "2001:db8::2/128", + }, + { + name: "empty", + raw: "", + wantErr: "CIDR is empty", + }, + { + name: "whitespace", + raw: " ", + wantErr: "CIDR is empty", + }, + { + name: "invalid IP without slash", + raw: "not-an-ip", + wantErr: `invalid CIDR "not-an-ip"`, + }, + { + name: "invalid CIDR", + raw: "10.0.0.0/33", + wantErr: "invalid CIDR address", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseCIDR(tt.raw) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if got == nil { + t.Fatalf("expected network, got nil") + } + + if got.String() != tt.wantNetwork { + t.Fatalf("network = %q, want %q", got.String(), tt.wantNetwork) + } + }) + } +} + +func TestLoadBalancerCIDRValues(t *testing.T) { + tests := []struct { + name string + svc *corev1.Service + want []struct { + value string + path string + } + }{ + { + name: "no values", + svc: loadBalancerServiceForTest("lb", "", nil), + want: nil, + }, + { + name: "loadBalancerIP only", + svc: loadBalancerServiceForTest("lb", "10.0.0.2", nil), + want: []struct { + value string + path string + }{ + { + value: "10.0.0.2", + path: "spec.loadBalancerIP", + }, + }, + }, + { + name: "source ranges only", + svc: loadBalancerServiceForTest("lb", "", []string{"10.0.1.0/25", "10.0.2.0/24"}), + want: []struct { + value string + path string + }{ + { + value: "10.0.1.0/25", + path: "spec.loadBalancerSourceRanges[0]", + }, + { + value: "10.0.2.0/24", + path: "spec.loadBalancerSourceRanges[1]", + }, + }, + }, + { + name: "loadBalancerIP and source ranges", + svc: loadBalancerServiceForTest("lb", "10.0.0.2", []string{"10.0.1.0/25"}), + want: []struct { + value string + path string + }{ + { + value: "10.0.0.2", + path: "spec.loadBalancerIP", + }, + { + value: "10.0.1.0/25", + path: "spec.loadBalancerSourceRanges[0]", + }, + }, + }, + { + name: "blank source range is preserved for matcher validation", + svc: loadBalancerServiceForTest("lb", "", []string{" "}), + want: []struct { + value string + path string + }{ + { + value: " ", + path: "spec.loadBalancerSourceRanges[0]", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := loadBalancerCIDRValues(tt.svc) + + if len(got) != len(tt.want) { + t.Fatalf("expected %d values, got %d: %#v", len(tt.want), len(got), got) + } + + for i, want := range tt.want { + if got[i].Value != want.value { + t.Fatalf("value[%d] = %q, want %q", i, got[i].Value, want.value) + } + + if got[i].Path != want.path { + t.Fatalf("path[%d] = %q, want %q", i, got[i].Path, want.path) + } + } + }) + } +} + +func TestCIDRContainsHelpers(t *testing.T) { + _, allowedIPv4, err := net.ParseCIDR("10.0.0.0/8") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, requestedInsideIPv4, err := net.ParseCIDR("10.0.1.0/24") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, requestedOutsideIPv4, err := net.ParseCIDR("10.1.0.0/7") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, allowedIPv6, err := net.ParseCIDR("2001:db8::/32") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, requestedInsideIPv6, err := net.ParseCIDR("2001:db8:1::/48") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, requestedOutsideIPv6, err := net.ParseCIDR("2001:db9::/32") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + tests := []struct { + name string + fn func() bool + want bool + }{ + { + name: "IPv4 contains IP", + fn: func() bool { + return cidrContainsIP(allowedIPv4, net.ParseIP("10.0.0.2")) + }, + want: true, + }, + { + name: "IPv4 does not contain IP", + fn: func() bool { + return cidrContainsIP(allowedIPv4, net.ParseIP("192.168.0.1")) + }, + want: false, + }, + { + name: "nil IP is not contained", + fn: func() bool { + return cidrContainsIP(allowedIPv4, nil) + }, + want: false, + }, + { + name: "nil CIDR does not contain IP", + fn: func() bool { + return cidrContainsIP(nil, net.ParseIP("10.0.0.2")) + }, + want: false, + }, + { + name: "IPv4 contains requested CIDR", + fn: func() bool { + return cidrContainsCIDR(allowedIPv4, requestedInsideIPv4) + }, + want: true, + }, + { + name: "IPv4 does not fully contain requested CIDR", + fn: func() bool { + return cidrContainsCIDR(allowedIPv4, requestedOutsideIPv4) + }, + want: false, + }, + { + name: "nil allowed CIDR does not contain CIDR", + fn: func() bool { + return cidrContainsCIDR(nil, requestedInsideIPv4) + }, + want: false, + }, + { + name: "nil requested CIDR is not contained", + fn: func() bool { + return cidrContainsCIDR(allowedIPv4, nil) + }, + want: false, + }, + { + name: "IPv6 contains IP", + fn: func() bool { + return cidrContainsIP(allowedIPv6, net.ParseIP("2001:db8::1")) + }, + want: true, + }, + { + name: "IPv6 does not contain IP", + fn: func() bool { + return cidrContainsIP(allowedIPv6, net.ParseIP("2001:db9::1")) + }, + want: false, + }, + { + name: "IPv6 contains requested CIDR", + fn: func() bool { + return cidrContainsCIDR(allowedIPv6, requestedInsideIPv6) + }, + want: true, + }, + { + name: "IPv6 does not contain requested CIDR", + fn: func() bool { + return cidrContainsCIDR(allowedIPv6, requestedOutsideIPv6) + }, + want: false, + }, + { + name: "IPv4 CIDR does not contain IPv6 IP", + fn: func() bool { + return cidrContainsIP(allowedIPv4, net.ParseIP("2001:db8::1")) + }, + want: false, + }, + { + name: "IPv4 CIDR does not contain IPv6 CIDR", + fn: func() bool { + return cidrContainsCIDR(allowedIPv4, requestedInsideIPv6) + }, + want: false, + }, + { + name: "IPv6 CIDR does not contain IPv4 IP", + fn: func() bool { + return cidrContainsIP(allowedIPv6, net.ParseIP("10.0.0.2")) + }, + want: false, + }, + { + name: "IPv6 CIDR does not contain IPv4 CIDR", + fn: func() bool { + return cidrContainsCIDR(allowedIPv6, requestedInsideIPv4) + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.fn() + if got != tt.want { + t.Fatalf("got %t, want %t", got, tt.want) + } + }) + } +} + +func loadBalancerEnforceForTest( + action apirules.ActionType, + cidrs ...string, +) *apirules.NamespaceRuleEnforceBody { + return &apirules.NamespaceRuleEnforceBody{ + Action: action, + Services: apirules.NamespaceRuleEnforceServicesBody{ + LoadBalancers: &apirules.ServiceLoadBalancerRule{ + CIDRs: cidrs, + }, + }, + } +} + +func loadBalancerServiceForTest( + name string, + loadBalancerIP string, + sourceRanges []string, +) *corev1.Service { + return &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + LoadBalancerIP: loadBalancerIP, + LoadBalancerSourceRanges: sourceRanges, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 80, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + } +} + +func clusterIPServiceForLoadBalancerTest(name string) *corev1.Service { + return &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 8080, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + } +} + +func decisionMessageForLoadBalancerTest(evaluation interface { +}) string { + e, ok := evaluation.(*ruleengine.Evaluation) + if !ok || e == nil { + return "" + } + + switch { + case e.Blocking != nil: + return e.Blocking.Message + case e.Final != nil: + return e.Final.Message + case len(e.Audits) > 0: + return e.Audits[0].Message + default: + return "" + } +} diff --git a/internal/webhook/rules/services/validation/node_port.go b/internal/webhook/rules/services/validation/node_port.go new file mode 100644 index 000000000..89d66bf80 --- /dev/null +++ b/internal/webhook/rules/services/validation/node_port.go @@ -0,0 +1,134 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +func (h *serviceRules) validateNodePorts( + svc *corev1.Service, + enforceBodies []*apirules.NamespaceRuleEnforceBody, +) (*ruleengine.Evaluation, error) { + if svc == nil || !serviceTypeIsNodePort(svc) { + return nil, nil + } + + if requiresNodePortRanges(enforceBodies) && len(nodePortValues(svc)) == 0 { + return &ruleengine.Evaluation{ + Blocking: &ruleengine.Decision{ + SetName: "nodePort", + EventReason: events.ReasonForbiddenNodePort, + Action: apirules.ActionTypeDeny, + Value: ruleengine.Value{ + Value: string(svc.Spec.Type), + Path: "spec.type", + }, + Message: "service requires explicit spec.ports[*].nodePort because nodePort ranges are enforced by namespace rule", + }, + }, nil + } + + values := nodePortValues(svc) + if len(values) == 0 { + return nil, nil + } + + return evaluateServiceRules[apirules.ServiceNodePortRange]( + svc, + enforceBodies, + serviceRuleSet[apirules.ServiceNodePortRange]{ + Name: "nodePort", + EventReason: events.ReasonForbiddenNodePort, + Values: func(_ *corev1.Service) []ruleengine.Value { + return values + }, + Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []apirules.ServiceNodePortRange { + if enforce == nil || enforce.Services.NodePorts == nil { + return nil + } + + return enforce.Services.NodePorts.Ports + }, + Matches: func(r apirules.ServiceNodePortRange, value ruleengine.Value) (ruleengine.Match, error) { + if r.From > r.To { + return ruleengine.Match{}, fmt.Errorf( + "invalid nodePort range: from %d must be lower than or equal to %d", + r.From, + r.To, + ) + } + + port, err := portFromValue(value.Value) + if err != nil { + return ruleengine.Match{}, err + } + + matched := port >= r.From && port <= r.To + + match := ruleengine.Match{ + Matched: matched, + MatchedValue: describeNodePortRange(r), + } + + if matched { + match.Detail = fmt.Sprintf("nodePort %d is within allowed range %s", port, describeNodePortRange(r)) + } + + return match, nil + }, + RuleDescription: describeNodePortRange, + AllowedDescription: "Allowed ranges", + }, + ) +} + +func describeNodePortRange(r apirules.ServiceNodePortRange) string { + if r.From == r.To { + return fmt.Sprintf("%d", r.From) + } + + return fmt.Sprintf("%d-%d", r.From, r.To) +} + +func nodePortValues(svc *corev1.Service) []ruleengine.Value { + out := make([]ruleengine.Value, 0, len(svc.Spec.Ports)) + + for i := range svc.Spec.Ports { + port := svc.Spec.Ports[i] + if port.NodePort == 0 { + continue + } + + out = append(out, ruleengine.Value{ + Value: fmt.Sprintf("%d", port.NodePort), + Path: fmt.Sprintf("spec.ports[%d].nodePort", i), + }) + } + + return out +} + +func requiresNodePortRanges( + enforceBodies []*apirules.NamespaceRuleEnforceBody, +) bool { + for _, enforce := range enforceBodies { + if enforce == nil || + enforce.Services.NodePorts == nil { + continue + } + + if len(enforce.Services.NodePorts.Ports) > 0 { + return true + } + } + + return false +} diff --git a/internal/webhook/rules/services/validation/node_port_test.go b/internal/webhook/rules/services/validation/node_port_test.go new file mode 100644 index 000000000..5fd7b703d --- /dev/null +++ b/internal/webhook/rules/services/validation/node_port_test.go @@ -0,0 +1,756 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +func TestServiceRulesValidateNodePorts(t *testing.T) { + tests := []struct { + name string + svc *corev1.Service + enforceBodies []*apirules.NamespaceRuleEnforceBody + wantNil bool + wantBlocking bool + wantFinal bool + wantErr string + wantMessage []string + }{ + { + name: "nil service returns nil evaluation", + svc: nil, + wantNil: true, + }, + { + name: "ClusterIP service returns nil evaluation", + svc: clusterIPServiceForNodePortTest("cluster-ip"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeDeny, nodePortRangeForTest(30000, 32767)), + }, + wantNil: true, + }, + { + name: "ExternalName service returns nil evaluation", + svc: externalNameServiceForNodePortTest("external", "api.example.com"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeDeny, nodePortRangeForTest(30000, 32767)), + }, + wantNil: true, + }, + { + name: "LoadBalancer with nodePort allocation disabled returns nil evaluation", + svc: loadBalancerServiceForNodePortTest("lb", false, 0), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeDeny, nodePortRangeForTest(30000, 32767)), + }, + wantNil: true, + }, + { + name: "NodePort without values and without nodePort rules returns nil evaluation", + svc: nodePortServiceForTest("node-port", 0), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + wantNil: true, + }, + { + name: "requires explicit nodePort when ranges are configured", + svc: nodePortServiceForTest("node-port", 0), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + "service requires explicit spec.ports[*].nodePort", + "nodePort ranges are enforced by namespace rule", + }, + }, + { + name: "requires explicit LoadBalancer nodePort when allocation is enabled and ranges are configured", + svc: loadBalancerServiceForNodePortTest("lb", true, 0), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + "service requires explicit spec.ports[*].nodePort", + "nodePort ranges are enforced by namespace rule", + }, + }, + { + name: "requires explicit LoadBalancer nodePort when allocation default is enabled and ranges are configured", + svc: loadBalancerServiceForNodePortTestWithDefaultAllocation("lb", 0), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + "service requires explicit spec.ports[*].nodePort", + "nodePort ranges are enforced by namespace rule", + }, + }, + { + name: "allows explicit nodePort inside range", + svc: nodePortServiceForTest("node-port", 30080), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + `nodePort "30080" at spec.ports[0].nodePort is allowed by namespace rule`, + "nodePort 30080 is within allowed range 30000-30100", + }, + }, + { + name: "allows explicit nodePort equal to single-port range", + svc: nodePortServiceForTest("node-port", 30500), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30500, 30500)), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + `nodePort "30500" at spec.ports[0].nodePort is allowed by namespace rule`, + "nodePort 30500 is within allowed range 30500", + }, + }, + { + name: "allow miss denies nodePort outside configured ranges", + svc: nodePortServiceForTest("node-port", 32080), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest( + apirules.ActionTypeAllow, + nodePortRangeForTest(30000, 30100), + nodePortRangeForTest(30500, 30500), + ), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `nodePort "32080" at spec.ports[0].nodePort is not allowed by namespace rule`, + "Allowed ranges", + "30000-30100", + "30500", + }, + }, + { + name: "deny matching nodePort", + svc: nodePortServiceForTest("node-port", 30090), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeDeny, nodePortRangeForTest(30090, 30090)), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `nodePort "30090" at spec.ports[0].nodePort is denied by namespace rule`, + "nodePort 30090 is within allowed range 30090", + }, + }, + { + name: "later deny overrides earlier allow", + svc: nodePortServiceForTest("node-port", 30090), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + nodePortEnforceForTest(apirules.ActionTypeDeny, nodePortRangeForTest(30090, 30090)), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `nodePort "30090" at spec.ports[0].nodePort is denied by namespace rule`, + "nodePort 30090 is within allowed range 30090", + }, + }, + { + name: "later allow overrides earlier deny", + svc: nodePortServiceForTest("node-port", 30090), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeDeny, nodePortRangeForTest(30000, 32767)), + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30090, 30090)), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `nodePort "30090" at spec.ports[0].nodePort is allowed by namespace rule`, + "nodePort 30090 is within allowed range 30090", + }, + }, + { + name: "audit match is observational", + svc: nodePortServiceForTest("node-port", 30090), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAudit, nodePortRangeForTest(30090, 30090)), + }, + wantBlocking: false, + wantFinal: false, + wantMessage: []string{ + `nodePort "30090" at spec.ports[0].nodePort matched audit namespace rule`, + "nodePort 30090 is within allowed range 30090", + }, + }, + { + name: "audit does not satisfy allow list", + svc: nodePortServiceForTest("node-port", 30090), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAudit, nodePortRangeForTest(30090, 30090)), + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30500, 30500)), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `nodePort "30090" at spec.ports[0].nodePort is not allowed by namespace rule`, + "Allowed ranges", + "30500", + }, + }, + { + name: "LoadBalancer with allocation enabled validates nodePort", + svc: loadBalancerServiceForNodePortTest("lb", true, 30080), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + `nodePort "30080" at spec.ports[0].nodePort is allowed by namespace rule`, + }, + }, + { + name: "LoadBalancer with default allocation validates nodePort", + svc: loadBalancerServiceForNodePortTestWithDefaultAllocation("lb", 30080), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + }, + wantFinal: true, + wantBlocking: false, + wantMessage: []string{ + `nodePort "30080" at spec.ports[0].nodePort is allowed by namespace rule`, + }, + }, + { + name: "LoadBalancer with allocation enabled denies nodePort outside range", + svc: loadBalancerServiceForNodePortTest("lb", true, 32080), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `nodePort "32080" at spec.ports[0].nodePort is not allowed by namespace rule`, + "Allowed ranges", + "30000-30100", + }, + }, + { + name: "multiple ports deny if one nodePort misses allow list", + svc: nodePortServiceWithPortsForTest( + "node-port", + []int32{30080, 32080}, + ), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `nodePort "32080" at spec.ports[1].nodePort is not allowed by namespace rule`, + "Allowed ranges", + "30000-30100", + }, + }, + { + name: "multiple ports with zero nodePort skips zero value", + svc: nodePortServiceWithPortsForTest( + "node-port", + []int32{0, 30080}, + ), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `nodePort "30080" at spec.ports[1].nodePort is allowed by namespace rule`, + }, + }, + { + name: "invalid configured range returns matcher error", + svc: nodePortServiceForTest("node-port", 30080), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeDeny, nodePortRangeForTest(30100, 30000)), + }, + wantErr: `nodePort: invalid rule: invalid nodePort range: from 30100 must be lower than or equal to 30000`, + }, + { + name: "unsupported action returns error", + svc: nodePortServiceForTest("node-port", 30080), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionType("invalid"), nodePortRangeForTest(30000, 30100)), + }, + wantErr: `nodePort: unsupported rule action "invalid"`, + }, + { + name: "nil enforce body is ignored", + svc: nodePortServiceForTest("node-port", 30080), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nil, + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + }, + wantFinal: true, + wantBlocking: false, + }, + { + name: "enforce without nodePort rules is ignored", + svc: nodePortServiceForTest("node-port", 30080), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + wantFinal: false, + wantBlocking: false, + }, + { + name: "empty nodePort range list does not require explicit nodePort", + svc: nodePortServiceForTest("node-port", 0), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + Services: apirules.NamespaceRuleEnforceServicesBody{ + NodePorts: &apirules.ServiceNodePortRule{}, + }, + }, + }, + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := serviceRulesForTest() + + evaluation, err := h.validateNodePorts(tt.svc, tt.enforceBodies) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if tt.wantNil { + if evaluation != nil { + t.Fatalf("expected nil evaluation, got %#v", evaluation) + } + + return + } + + if evaluation == nil { + t.Fatalf("expected evaluation, got nil") + } + + if tt.wantBlocking && evaluation.Blocking == nil { + t.Fatalf("expected blocking decision, got nil") + } + + if !tt.wantBlocking && evaluation.Blocking != nil { + t.Fatalf("expected no blocking decision, got %#v", evaluation.Blocking) + } + + if tt.wantFinal && evaluation.Final == nil { + t.Fatalf("expected final decision, got nil") + } + + if !tt.wantFinal && evaluation.Final != nil { + t.Fatalf("expected no final decision, got %#v", evaluation.Final) + } + + if len(tt.wantMessage) > 0 { + msg := decisionMessageForNodePortTest(evaluation) + + for _, expected := range tt.wantMessage { + if !strings.Contains(msg, expected) { + t.Fatalf("expected message %q to contain %q", msg, expected) + } + } + } + + if evaluation.Final != nil { + if evaluation.Final.EventReason != events.ReasonForbiddenNodePort { + t.Fatalf("final event reason = %q, want %q", evaluation.Final.EventReason, events.ReasonForbiddenNodePort) + } + } + + if evaluation.Blocking != nil { + if evaluation.Blocking.EventReason != events.ReasonForbiddenNodePort { + t.Fatalf("blocking event reason = %q, want %q", evaluation.Blocking.EventReason, events.ReasonForbiddenNodePort) + } + } + + for _, audit := range evaluation.Audits { + if audit.EventReason != events.ReasonForbiddenNodePort { + t.Fatalf("audit event reason = %q, want %q", audit.EventReason, events.ReasonForbiddenNodePort) + } + } + }) + } +} + +func TestDescribeNodePortRange(t *testing.T) { + tests := []struct { + name string + in apirules.ServiceNodePortRange + want string + }{ + { + name: "range", + in: nodePortRangeForTest(30000, 30100), + want: "30000-30100", + }, + { + name: "single port", + in: nodePortRangeForTest(30500, 30500), + want: "30500", + }, + { + name: "invalid range still describes values", + in: nodePortRangeForTest(30100, 30000), + want: "30100-30000", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := describeNodePortRange(tt.in) + if got != tt.want { + t.Fatalf("describeNodePortRange() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestNodePortValues(t *testing.T) { + tests := []struct { + name string + svc *corev1.Service + want []struct { + value string + path string + } + }{ + { + name: "no ports", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: nil, + }, + }, + want: nil, + }, + { + name: "zero nodePort is skipped", + svc: nodePortServiceForTest("node-port", 0), + want: nil, + }, + { + name: "single nodePort", + svc: nodePortServiceForTest("node-port", 30080), + want: []struct { + value string + path string + }{ + { + value: "30080", + path: "spec.ports[0].nodePort", + }, + }, + }, + { + name: "multiple nodePorts preserve index path and skip zero", + svc: nodePortServiceWithPortsForTest( + "node-port", + []int32{30080, 0, 30500}, + ), + want: []struct { + value string + path string + }{ + { + value: "30080", + path: "spec.ports[0].nodePort", + }, + { + value: "30500", + path: "spec.ports[2].nodePort", + }, + }, + }, + { + name: "LoadBalancer nodePorts are extracted too", + svc: loadBalancerServiceForNodePortTest("lb", true, 30080), + want: []struct { + value string + path string + }{ + { + value: "30080", + path: "spec.ports[0].nodePort", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := nodePortValues(tt.svc) + + if len(got) != len(tt.want) { + t.Fatalf("expected %d values, got %d: %#v", len(tt.want), len(got), got) + } + + for i, want := range tt.want { + if got[i].Value != want.value { + t.Fatalf("value[%d] = %q, want %q", i, got[i].Value, want.value) + } + + if got[i].Path != want.path { + t.Fatalf("path[%d] = %q, want %q", i, got[i].Path, want.path) + } + } + }) + } +} + +func TestRequiresNodePortRanges(t *testing.T) { + tests := []struct { + name string + enforceBodies []*apirules.NamespaceRuleEnforceBody + want bool + }{ + { + name: "nil bodies", + enforceBodies: nil, + want: false, + }, + { + name: "nil enforce body", + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nil, + }, + want: false, + }, + { + name: "missing nodePort rules", + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + want: false, + }, + { + name: "empty ports list", + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Services: apirules.NamespaceRuleEnforceServicesBody{ + NodePorts: &apirules.ServiceNodePortRule{}, + }, + }, + }, + want: false, + }, + { + name: "range configured", + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + }, + want: true, + }, + { + name: "later range configured", + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nil, + {}, + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30000, 30100)), + }, + want: true, + }, + { + name: "invalid range still counts as configured", + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nodePortEnforceForTest(apirules.ActionTypeAllow, nodePortRangeForTest(30100, 30000)), + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := requiresNodePortRanges(tt.enforceBodies) + if got != tt.want { + t.Fatalf("requiresNodePortRanges() = %t, want %t", got, tt.want) + } + }) + } +} + +func nodePortEnforceForTest( + action apirules.ActionType, + ports ...apirules.ServiceNodePortRange, +) *apirules.NamespaceRuleEnforceBody { + return &apirules.NamespaceRuleEnforceBody{ + Action: action, + Services: apirules.NamespaceRuleEnforceServicesBody{ + NodePorts: &apirules.ServiceNodePortRule{ + Ports: ports, + }, + }, + } +} + +func nodePortRangeForTest(from int32, to int32) apirules.ServiceNodePortRange { + return apirules.ServiceNodePortRange{ + From: from, + To: to, + } +} + +func nodePortServiceForTest(name string, nodePort int32) *corev1.Service { + return nodePortServiceWithPortsForTest(name, []int32{nodePort}) +} + +func nodePortServiceWithPortsForTest(name string, nodePorts []int32) *corev1.Service { + ports := make([]corev1.ServicePort, 0, len(nodePorts)) + + for i, nodePort := range nodePorts { + ports = append(ports, corev1.ServicePort{ + Name: "port-" + string(rune('a'+i)), + Port: int32(8080 + i), + TargetPort: intstr.FromInt(8080 + i), + NodePort: nodePort, + }) + } + + return &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Ports: ports, + }, + } +} + +func loadBalancerServiceForNodePortTest( + name string, + allocate bool, + nodePort int32, +) *corev1.Service { + return &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + AllocateLoadBalancerNodePorts: &allocate, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 80, + TargetPort: intstr.FromInt(8080), + NodePort: nodePort, + }, + }, + }, + } +} + +func loadBalancerServiceForNodePortTestWithDefaultAllocation( + name string, + nodePort int32, +) *corev1.Service { + return &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 80, + TargetPort: intstr.FromInt(8080), + NodePort: nodePort, + }, + }, + }, + } +} + +func clusterIPServiceForNodePortTest(name string) *corev1.Service { + return &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 8080, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + } +} + +func externalNameServiceForNodePortTest(name string, externalName string) *corev1.Service { + return &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeExternalName, + ExternalName: externalName, + Ports: []corev1.ServicePort{ + { + Name: "https", + Port: 443, + TargetPort: intstr.FromInt(443), + }, + }, + }, + } +} + +func decisionMessageForNodePortTest(evaluation interface { +}) string { + e, ok := evaluation.(*ruleengine.Evaluation) + if !ok || e == nil { + return "" + } + + switch { + case e.Blocking != nil: + return e.Blocking.Message + case e.Final != nil: + return e.Final.Message + case len(e.Audits) > 0: + return e.Audits[0].Message + default: + return "" + } +} diff --git a/internal/webhook/rules/services/validation/service_type.go b/internal/webhook/rules/services/validation/service_type.go new file mode 100644 index 000000000..172dd37f3 --- /dev/null +++ b/internal/webhook/rules/services/validation/service_type.go @@ -0,0 +1,61 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +func (h *serviceRules) validateServiceTypes( + svc *corev1.Service, + enforceBodies []*apirules.NamespaceRuleEnforceBody, +) (*ruleengine.Evaluation, error) { + return evaluateServiceRules[apirules.ServiceType]( + svc, + enforceBodies, + serviceRuleSet[apirules.ServiceType]{ + Name: "service type", + EventReason: events.ReasonForbiddenServiceType, + Values: func(svc *corev1.Service) []ruleengine.Value { + return []ruleengine.Value{ + { + Value: string(serviceType(svc)), + Path: "spec.type", + }, + } + }, + Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []apirules.ServiceType { + if enforce == nil { + return nil + } + + return enforce.Services.Types + }, + Matches: func(rule apirules.ServiceType, value ruleengine.Value) (ruleengine.Match, error) { + matched := string(rule) == value.Value + + match := ruleengine.Match{ + Matched: matched, + MatchedValue: rule, + } + + if matched { + match.Detail = fmt.Sprintf("service type %q matched %q", value.Value, rule) + } + + return match, nil + }, + RuleDescription: func(rule apirules.ServiceType) string { + return string(rule) + }, + AllowedDescription: "Allowed service types", + }, + ) +} diff --git a/internal/webhook/rules/services/validation/service_type_test.go b/internal/webhook/rules/services/validation/service_type_test.go new file mode 100644 index 000000000..c447c3f2f --- /dev/null +++ b/internal/webhook/rules/services/validation/service_type_test.go @@ -0,0 +1,436 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +func TestServiceRulesValidateServiceTypes(t *testing.T) { + tests := []struct { + name string + svc *corev1.Service + enforceBodies []*apirules.NamespaceRuleEnforceBody + wantBlocking bool + wantFinal bool + wantErr string + wantMessage []string + }{ + { + name: "ClusterIP service without rules returns empty evaluation", + svc: serviceTypeServiceForTest("cluster-ip", corev1.ServiceTypeClusterIP), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeAllow, + }, + }, + wantBlocking: false, + wantFinal: false, + }, + { + name: "nil enforce body is ignored", + svc: serviceTypeServiceForTest("cluster-ip", corev1.ServiceTypeClusterIP), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nil, + serviceTypeEnforceForTest( + apirules.ActionTypeAllow, + apirules.ServiceTypeClusterIP, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `service type "ClusterIP" at spec.type is allowed by namespace rule`, + `service type "ClusterIP" matched "ClusterIP"`, + }, + }, + { + name: "allow ClusterIP service type", + svc: serviceTypeServiceForTest("cluster-ip", corev1.ServiceTypeClusterIP), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeAllow, + apirules.ServiceTypeClusterIP, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `service type "ClusterIP" at spec.type is allowed by namespace rule`, + `service type "ClusterIP" matched "ClusterIP"`, + }, + }, + { + name: "allow NodePort service type", + svc: serviceTypeServiceForTest("node-port", corev1.ServiceTypeNodePort), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeAllow, + apirules.ServiceTypeNodePort, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `service type "NodePort" at spec.type is allowed by namespace rule`, + `service type "NodePort" matched "NodePort"`, + }, + }, + { + name: "allow LoadBalancer service type", + svc: serviceTypeServiceForTest("load-balancer", corev1.ServiceTypeLoadBalancer), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeAllow, + apirules.ServiceTypeLoadBalancer, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `service type "LoadBalancer" at spec.type is allowed by namespace rule`, + `service type "LoadBalancer" matched "LoadBalancer"`, + }, + }, + { + name: "allow ExternalName service type", + svc: serviceTypeServiceForTest("external-name", corev1.ServiceTypeExternalName), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeAllow, + apirules.ServiceTypeExternalName, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `service type "ExternalName" at spec.type is allowed by namespace rule`, + `service type "ExternalName" matched "ExternalName"`, + }, + }, + { + name: "empty Kubernetes service type is treated as ClusterIP", + svc: serviceTypeServiceForTest("default-cluster-ip", corev1.ServiceType("")), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeAllow, + apirules.ServiceTypeClusterIP, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `service type "ClusterIP" at spec.type is allowed by namespace rule`, + `service type "ClusterIP" matched "ClusterIP"`, + }, + }, + { + name: "allow miss denies service type missing from allowed list", + svc: serviceTypeServiceForTest("external-name", corev1.ServiceTypeExternalName), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeAllow, + apirules.ServiceTypeClusterIP, + ), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `service type "ExternalName" at spec.type is not allowed by namespace rule`, + "Allowed service types", + "ClusterIP", + }, + }, + { + name: "allow miss reports multiple allowed service types", + svc: serviceTypeServiceForTest("external-name", corev1.ServiceTypeExternalName), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeAllow, + apirules.ServiceTypeClusterIP, + apirules.ServiceTypeNodePort, + apirules.ServiceTypeLoadBalancer, + ), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `service type "ExternalName" at spec.type is not allowed by namespace rule`, + "Allowed service types", + "ClusterIP", + "NodePort", + "LoadBalancer", + }, + }, + { + name: "deny matching service type", + svc: serviceTypeServiceForTest("load-balancer", corev1.ServiceTypeLoadBalancer), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeDeny, + apirules.ServiceTypeLoadBalancer, + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `service type "LoadBalancer" at spec.type is denied by namespace rule`, + `service type "LoadBalancer" matched "LoadBalancer"`, + }, + }, + { + name: "default action is deny", + svc: serviceTypeServiceForTest("node-port", corev1.ServiceTypeNodePort), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + "", + apirules.ServiceTypeNodePort, + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `service type "NodePort" at spec.type is denied by namespace rule`, + `service type "NodePort" matched "NodePort"`, + }, + }, + { + name: "later deny overrides earlier allow", + svc: serviceTypeServiceForTest("load-balancer", corev1.ServiceTypeLoadBalancer), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeAllow, + apirules.ServiceTypeLoadBalancer, + ), + serviceTypeEnforceForTest( + apirules.ActionTypeDeny, + apirules.ServiceTypeLoadBalancer, + ), + }, + wantBlocking: true, + wantFinal: true, + wantMessage: []string{ + `service type "LoadBalancer" at spec.type is denied by namespace rule`, + `service type "LoadBalancer" matched "LoadBalancer"`, + }, + }, + { + name: "later allow overrides earlier deny", + svc: serviceTypeServiceForTest("load-balancer", corev1.ServiceTypeLoadBalancer), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeDeny, + apirules.ServiceTypeLoadBalancer, + ), + serviceTypeEnforceForTest( + apirules.ActionTypeAllow, + apirules.ServiceTypeLoadBalancer, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `service type "LoadBalancer" at spec.type is allowed by namespace rule`, + `service type "LoadBalancer" matched "LoadBalancer"`, + }, + }, + { + name: "non matching later deny does not override earlier allow", + svc: serviceTypeServiceForTest("cluster-ip", corev1.ServiceTypeClusterIP), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeAllow, + apirules.ServiceTypeClusterIP, + ), + serviceTypeEnforceForTest( + apirules.ActionTypeDeny, + apirules.ServiceTypeLoadBalancer, + ), + }, + wantBlocking: false, + wantFinal: true, + wantMessage: []string{ + `service type "ClusterIP" at spec.type is allowed by namespace rule`, + `service type "ClusterIP" matched "ClusterIP"`, + }, + }, + { + name: "audit match is observational", + svc: serviceTypeServiceForTest("external-name", corev1.ServiceTypeExternalName), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeAudit, + apirules.ServiceTypeExternalName, + ), + }, + wantBlocking: false, + wantFinal: false, + wantMessage: []string{ + `service type "ExternalName" at spec.type matched audit namespace rule`, + `service type "ExternalName" matched "ExternalName"`, + }, + }, + { + name: "audit does not satisfy allow list", + svc: serviceTypeServiceForTest("external-name", corev1.ServiceTypeExternalName), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionTypeAudit, + apirules.ServiceTypeExternalName, + ), + serviceTypeEnforceForTest( + apirules.ActionTypeAllow, + apirules.ServiceTypeClusterIP, + ), + }, + wantBlocking: true, + wantFinal: false, + wantMessage: []string{ + `service type "ExternalName" at spec.type is not allowed by namespace rule`, + "Allowed service types", + "ClusterIP", + }, + }, + { + name: "unsupported action returns error", + svc: serviceTypeServiceForTest("cluster-ip", corev1.ServiceTypeClusterIP), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + serviceTypeEnforceForTest( + apirules.ActionType("invalid"), + apirules.ServiceTypeClusterIP, + ), + }, + wantErr: `service type: unsupported rule action "invalid"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := serviceRulesForTest() + + evaluation, err := h.validateServiceTypes(tt.svc, tt.enforceBodies) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if evaluation == nil { + t.Fatalf("expected evaluation, got nil") + } + + if tt.wantBlocking && evaluation.Blocking == nil { + t.Fatalf("expected blocking decision, got nil") + } + + if !tt.wantBlocking && evaluation.Blocking != nil { + t.Fatalf("expected no blocking decision, got %#v", evaluation.Blocking) + } + + if tt.wantFinal && evaluation.Final == nil { + t.Fatalf("expected final decision, got nil") + } + + if !tt.wantFinal && evaluation.Final != nil { + t.Fatalf("expected no final decision, got %#v", evaluation.Final) + } + + if len(tt.wantMessage) > 0 { + msg := decisionMessageForServiceTypeTest(evaluation) + + for _, expected := range tt.wantMessage { + if !strings.Contains(msg, expected) { + t.Fatalf("expected message %q to contain %q", msg, expected) + } + } + } + + if evaluation.Final != nil { + if evaluation.Final.EventReason != events.ReasonForbiddenServiceType { + t.Fatalf("final event reason = %q, want %q", evaluation.Final.EventReason, events.ReasonForbiddenServiceType) + } + } + + if evaluation.Blocking != nil { + if evaluation.Blocking.EventReason != events.ReasonForbiddenServiceType { + t.Fatalf("blocking event reason = %q, want %q", evaluation.Blocking.EventReason, events.ReasonForbiddenServiceType) + } + } + + for _, audit := range evaluation.Audits { + if audit.EventReason != events.ReasonForbiddenServiceType { + t.Fatalf("audit event reason = %q, want %q", audit.EventReason, events.ReasonForbiddenServiceType) + } + } + }) + } +} + +func serviceTypeEnforceForTest( + action apirules.ActionType, + types ...apirules.ServiceType, +) *apirules.NamespaceRuleEnforceBody { + return &apirules.NamespaceRuleEnforceBody{ + Action: action, + Services: apirules.NamespaceRuleEnforceServicesBody{ + Types: types, + }, + } +} + +func serviceTypeServiceForTest( + name string, + serviceType corev1.ServiceType, +) *corev1.Service { + return &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: serviceType, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 80, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + } +} + +func decisionMessageForServiceTypeTest(evaluation interface { +}) string { + e, ok := evaluation.(*ruleengine.Evaluation) + if !ok || e == nil { + return "" + } + + switch { + case e.Blocking != nil: + return e.Blocking.Message + case e.Final != nil: + return e.Final.Message + case len(e.Audits) > 0: + return e.Audits[0].Message + default: + return "" + } +} diff --git a/internal/webhook/rules/services/validation/utils.go b/internal/webhook/rules/services/validation/utils.go new file mode 100644 index 000000000..733279d58 --- /dev/null +++ b/internal/webhook/rules/services/validation/utils.go @@ -0,0 +1,105 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "fmt" + "math/big" + "net" + "strconv" + + corev1 "k8s.io/api/core/v1" + + apirules "github.com/projectcapsule/capsule/pkg/api/rules" +) + +func serviceType(svc *corev1.Service) apirules.ServiceType { + if svc == nil { + return "" + } + + switch svc.Spec.Type { + case "", corev1.ServiceTypeClusterIP: + return apirules.ServiceTypeClusterIP + case corev1.ServiceTypeNodePort: + return apirules.ServiceTypeNodePort + case corev1.ServiceTypeLoadBalancer: + return apirules.ServiceTypeLoadBalancer + case corev1.ServiceTypeExternalName: + return apirules.ServiceTypeExternalName + default: + return apirules.ServiceType(svc.Spec.Type) + } +} + +//nolint:exhaustive +func serviceTypeIsNodePort(svc *corev1.Service) bool { + if svc == nil { + return false + } + + switch svc.Spec.Type { + case corev1.ServiceTypeNodePort: + return true + + case corev1.ServiceTypeLoadBalancer: + return svc.Spec.AllocateLoadBalancerNodePorts == nil || + *svc.Spec.AllocateLoadBalancerNodePorts + + default: + return false + } +} + +func cidrContainsIP(network *net.IPNet, ip net.IP) bool { + if network == nil || ip == nil { + return false + } + + return network.Contains(ip) +} + +func cidrContainsCIDR(parent, child *net.IPNet) bool { + if parent == nil || child == nil { + return false + } + + first := child.IP + last := lastIP(child) + + return parent.Contains(first) && parent.Contains(last) +} + +func lastIP(network *net.IPNet) net.IP { + ip := network.IP + mask := network.Mask + + ipInt := big.NewInt(0).SetBytes(ip) + maskInt := big.NewInt(0).SetBytes(mask) + + bits := uint(len(mask) * 8) + + allOnes := big.NewInt(0).Sub( + big.NewInt(0).Lsh(big.NewInt(1), bits), + big.NewInt(1), + ) + + invertedMask := big.NewInt(0).Xor(maskInt, allOnes) + + last := big.NewInt(0).Or(ipInt, invertedMask).Bytes() + + out := make(net.IP, len(ip)) + copy(out[len(out)-len(last):], last) + + return out +} + +func portFromValue(value string) (int32, error) { + port, err := strconv.ParseInt(value, 10, 32) + if err != nil { + return 0, fmt.Errorf("invalid nodePort value %q: %w", value, err) + } + + return int32(port), nil +} diff --git a/internal/webhook/rules/services/validation/utils_test.go b/internal/webhook/rules/services/validation/utils_test.go new file mode 100644 index 000000000..364096b24 --- /dev/null +++ b/internal/webhook/rules/services/validation/utils_test.go @@ -0,0 +1,528 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "net" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + + apirules "github.com/projectcapsule/capsule/pkg/api/rules" +) + +func TestServiceType(t *testing.T) { + tests := []struct { + name string + svc *corev1.Service + want apirules.ServiceType + }{ + { + name: "nil service", + svc: nil, + want: "", + }, + { + name: "empty service type is treated as ClusterIP", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{}, + }, + want: apirules.ServiceTypeClusterIP, + }, + { + name: "ClusterIP", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + }, + }, + want: apirules.ServiceTypeClusterIP, + }, + { + name: "NodePort", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + }, + }, + want: apirules.ServiceTypeNodePort, + }, + { + name: "LoadBalancer", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + }, + }, + want: apirules.ServiceTypeLoadBalancer, + }, + { + name: "ExternalName", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeExternalName, + }, + }, + want: apirules.ServiceTypeExternalName, + }, + { + name: "unknown type is preserved", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceType("CustomType"), + }, + }, + want: apirules.ServiceType("CustomType"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := serviceType(tt.svc) + if got != tt.want { + t.Fatalf("serviceType() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestServiceTypeIsNodePort(t *testing.T) { + enabled := true + disabled := false + + tests := []struct { + name string + svc *corev1.Service + want bool + }{ + { + name: "nil service", + svc: nil, + want: false, + }, + { + name: "ClusterIP", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + }, + }, + want: false, + }, + { + name: "ExternalName", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeExternalName, + }, + }, + want: false, + }, + { + name: "NodePort", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + }, + }, + want: true, + }, + { + name: "LoadBalancer allocation default", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + }, + }, + want: true, + }, + { + name: "LoadBalancer allocation explicitly enabled", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + AllocateLoadBalancerNodePorts: &enabled, + }, + }, + want: true, + }, + { + name: "LoadBalancer allocation explicitly disabled", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + AllocateLoadBalancerNodePorts: &disabled, + }, + }, + want: false, + }, + { + name: "unknown service type", + svc: &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceType("CustomType"), + }, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := serviceTypeIsNodePort(tt.svc) + if got != tt.want { + t.Fatalf("serviceTypeIsNodePort() = %t, want %t", got, tt.want) + } + }) + } +} + +func TestCIDRContainsIP(t *testing.T) { + _, allowedIPv4, err := net.ParseCIDR("10.0.0.0/8") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, allowedIPv6, err := net.ParseCIDR("2001:db8::/32") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + tests := []struct { + name string + network *net.IPNet + ip net.IP + want bool + }{ + { + name: "nil network", + network: nil, + ip: net.ParseIP("10.0.0.2"), + want: false, + }, + { + name: "nil IP", + network: allowedIPv4, + ip: nil, + want: false, + }, + { + name: "IPv4 contains IP", + network: allowedIPv4, + ip: net.ParseIP("10.0.0.2"), + want: true, + }, + { + name: "IPv4 does not contain IP", + network: allowedIPv4, + ip: net.ParseIP("192.168.0.1"), + want: false, + }, + { + name: "IPv4 network does not contain IPv6 IP", + network: allowedIPv4, + ip: net.ParseIP("2001:db8::1"), + want: false, + }, + { + name: "IPv6 contains IP", + network: allowedIPv6, + ip: net.ParseIP("2001:db8::1"), + want: true, + }, + { + name: "IPv6 does not contain IP", + network: allowedIPv6, + ip: net.ParseIP("2001:db9::1"), + want: false, + }, + { + name: "IPv6 network does not contain IPv4 IP", + network: allowedIPv6, + ip: net.ParseIP("10.0.0.2"), + want: false, + }, + { + name: "invalid parsed IP", + network: allowedIPv4, + ip: net.ParseIP("not-an-ip"), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cidrContainsIP(tt.network, tt.ip) + if got != tt.want { + t.Fatalf("cidrContainsIP() = %t, want %t", got, tt.want) + } + }) + } +} + +func TestCIDRContainsCIDR(t *testing.T) { + _, allowedIPv4, err := net.ParseCIDR("10.0.0.0/8") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, childIPv4Inside, err := net.ParseCIDR("10.0.1.0/24") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, childIPv4Exact, err := net.ParseCIDR("10.0.0.0/8") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, childIPv4PartialOutside, err := net.ParseCIDR("10.0.0.0/7") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, childIPv4Outside, err := net.ParseCIDR("192.168.0.0/16") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, allowedIPv6, err := net.ParseCIDR("2001:db8::/32") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, childIPv6Inside, err := net.ParseCIDR("2001:db8:1::/48") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, childIPv6Exact, err := net.ParseCIDR("2001:db8::/32") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + _, childIPv6Outside, err := net.ParseCIDR("2001:db9::/32") + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + tests := []struct { + name string + parent *net.IPNet + child *net.IPNet + want bool + }{ + { + name: "nil parent", + parent: nil, + child: childIPv4Inside, + want: false, + }, + { + name: "nil child", + parent: allowedIPv4, + child: nil, + want: false, + }, + { + name: "IPv4 parent contains child", + parent: allowedIPv4, + child: childIPv4Inside, + want: true, + }, + { + name: "IPv4 parent contains exact child", + parent: allowedIPv4, + child: childIPv4Exact, + want: true, + }, + { + name: "IPv4 parent does not fully contain wider child", + parent: allowedIPv4, + child: childIPv4PartialOutside, + want: false, + }, + { + name: "IPv4 parent does not contain outside child", + parent: allowedIPv4, + child: childIPv4Outside, + want: false, + }, + { + name: "IPv4 parent does not contain IPv6 child", + parent: allowedIPv4, + child: childIPv6Inside, + want: false, + }, + { + name: "IPv6 parent contains child", + parent: allowedIPv6, + child: childIPv6Inside, + want: true, + }, + { + name: "IPv6 parent contains exact child", + parent: allowedIPv6, + child: childIPv6Exact, + want: true, + }, + { + name: "IPv6 parent does not contain outside child", + parent: allowedIPv6, + child: childIPv6Outside, + want: false, + }, + { + name: "IPv6 parent does not contain IPv4 child", + parent: allowedIPv6, + child: childIPv4Inside, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cidrContainsCIDR(tt.parent, tt.child) + if got != tt.want { + t.Fatalf("cidrContainsCIDR() = %t, want %t", got, tt.want) + } + }) + } +} + +func TestLastIP(t *testing.T) { + tests := []struct { + name string + cidr string + want string + }{ + { + name: "IPv4 /24", + cidr: "10.0.1.0/24", + want: "10.0.1.255", + }, + { + name: "IPv4 /32", + cidr: "10.0.1.44/32", + want: "10.0.1.44", + }, + { + name: "IPv4 /8", + cidr: "10.0.0.0/8", + want: "10.255.255.255", + }, + { + name: "IPv6 /32", + cidr: "2001:db8::/32", + want: "2001:db8:ffff:ffff:ffff:ffff:ffff:ffff", + }, + { + name: "IPv6 /128", + cidr: "2001:db8::2/128", + want: "2001:db8::2", + }, + { + name: "IPv6 /48", + cidr: "2001:db8:1::/48", + want: "2001:db8:1:ffff:ffff:ffff:ffff:ffff", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, network, err := net.ParseCIDR(tt.cidr) + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + got := lastIP(network) + + if got.String() != tt.want { + t.Fatalf("lastIP(%q) = %q, want %q", tt.cidr, got.String(), tt.want) + } + }) + } +} + +func TestPortFromValue(t *testing.T) { + tests := []struct { + name string + value string + want int32 + wantErr string + }{ + { + name: "valid port", + value: "30080", + want: 30080, + }, + { + name: "zero is parsed", + value: "0", + want: 0, + }, + { + name: "negative is parsed", + value: "-1", + want: -1, + }, + { + name: "max int32 is parsed", + value: "2147483647", + want: 2147483647, + }, + { + name: "above int32 returns error", + value: "2147483648", + wantErr: `invalid nodePort value "2147483648"`, + }, + { + name: "empty returns error", + value: "", + wantErr: `invalid nodePort value ""`, + }, + { + name: "non numeric returns error", + value: "not-a-port", + wantErr: `invalid nodePort value "not-a-port"`, + }, + { + name: "decimal returns error", + value: "30080.5", + wantErr: `invalid nodePort value "30080.5"`, + }, + { + name: "whitespace returns error", + value: " 30080 ", + wantErr: `invalid nodePort value " 30080 "`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := portFromValue(tt.value) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if got != tt.want { + t.Fatalf("portFromValue() = %d, want %d", got, tt.want) + } + }) + } +} diff --git a/internal/webhook/rules/status/validation.go b/internal/webhook/rules/status/validation.go new file mode 100644 index 000000000..f18a28c66 --- /dev/null +++ b/internal/webhook/rules/status/validation.go @@ -0,0 +1,80 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package status + +import ( + "context" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/ruleengine" + ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/configuration" + "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/handlers" +) + +type ruleStatusHandler struct { + configuration configuration.Configuration +} + +func RuleStatusValidationHandler(configuration configuration.Configuration) handlers.Handler { + return &ruleStatusHandler{ + configuration: configuration, + } +} + +func (r *ruleStatusHandler) OnCreate( + _ client.Client, + _ client.Reader, + decoder admission.Decoder, + _ events.EventRecorder, +) handlers.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + rs := &capsulev1beta2.RuleStatus{} + if err := decoder.Decode(req, rs); err != nil { + return ad.ErroredResponse(err) + } + + return r.handle(rs) + } +} + +func (r *ruleStatusHandler) OnDelete( + client.Client, + client.Reader, + admission.Decoder, + events.EventRecorder, +) handlers.Func { + return func(context.Context, admission.Request) *admission.Response { + return nil + } +} + +func (r *ruleStatusHandler) OnUpdate( + _ client.Client, + _ client.Reader, + decoder admission.Decoder, + recorder events.EventRecorder, +) handlers.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + rs := &capsulev1beta2.RuleStatus{} + if err := decoder.Decode(req, rs); err != nil { + return ad.ErroredResponse(err) + } + + return r.handle(rs) + } +} + +func (r *ruleStatusHandler) handle(rs *capsulev1beta2.RuleStatus) *admission.Response { + err := ruleengine.ValidateRuleStatusBody(rs.Spec) + if err != nil { + return ad.Deny(err.Error()) + } + + return nil +} diff --git a/internal/webhook/rules/utils.go b/internal/webhook/rules/utils.go new file mode 100644 index 000000000..7d4ba4b2b --- /dev/null +++ b/internal/webhook/rules/utils.go @@ -0,0 +1,34 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "fmt" + "strings" + + "github.com/projectcapsule/capsule/pkg/api" +) + +func DescribeExpressionMatch(match api.ExpressionMatch) string { + parts := make([]string, 0, 3) + + prefix := "" + if match.Negate { + prefix = "not " + } + + if len(match.Exact) > 0 { + parts = append(parts, fmt.Sprintf("%sexact: %s", prefix, strings.Join(match.Exact, ", "))) + } + + if match.Expression != "" { + parts = append(parts, fmt.Sprintf("%sexp: %s", prefix, match.Expression)) + } + + if len(parts) == 0 && match.Negate { + return "not " + } + + return strings.Join(parts, "; ") +} diff --git a/internal/webhook/service/handler.go b/internal/webhook/service/handler.go index 055f90492..0601e4682 100644 --- a/internal/webhook/service/handler.go +++ b/internal/webhook/service/handler.go @@ -9,8 +9,8 @@ import ( "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) -func Handler(handler ...handlers.TypedHandlerWithTenant[*corev1.Service]) handlers.Handler { - return &handlers.TypedTenantHandler[*corev1.Service]{ +func Handler(handler ...handlers.TypedHandlerWithTenantWithRuleset[*corev1.Service]) handlers.Handler { + return &handlers.TypedTenantWithRulesetHandler[*corev1.Service]{ Factory: func() *corev1.Service { return &corev1.Service{} }, diff --git a/internal/webhook/service/validating.go b/internal/webhook/service/validating.go index ff0f18efd..a037445c7 100644 --- a/internal/webhook/service/validating.go +++ b/internal/webhook/service/validating.go @@ -17,6 +17,7 @@ import ( capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/pkg/api" caperrors "github.com/projectcapsule/capsule/pkg/api/errors" + "github.com/projectcapsule/capsule/pkg/api/rules" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" @@ -24,7 +25,7 @@ import ( type validating struct{} -func Validating() handlers.TypedHandlerWithTenant[*corev1.Service] { +func Validating() handlers.TypedHandlerWithTenantWithRuleset[*corev1.Service] { return &validating{} } @@ -35,6 +36,7 @@ func (h *validating) OnCreate( decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant, + _ []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { return h.handle(ctx, req, recorder, svc, tnt) @@ -49,6 +51,7 @@ func (h *validating) OnUpdate( decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant, + _ []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { return h.handle(ctx, req, recorder, svc, tnt) @@ -62,6 +65,7 @@ func (h *validating) OnDelete( admission.Decoder, events.EventRecorder, *capsulev1beta2.Tenant, + []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(context.Context, admission.Request) *admission.Response { return nil diff --git a/internal/webhook/tenant/validation/rule_validator.go b/internal/webhook/tenant/validation/rule_validator.go index cfdb6d9d6..07b46b057 100644 --- a/internal/webhook/tenant/validation/rule_validator.go +++ b/internal/webhook/tenant/validation/rule_validator.go @@ -5,13 +5,13 @@ package validation import ( "context" - "regexp" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/ruleengine" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" @@ -31,7 +31,7 @@ func (h *RuleValidationHandler) OnCreate( _ events.EventRecorder, ) handlers.Func { return func(_ context.Context, req admission.Request) *admission.Response { - if err := ValidateRule(tnt, req); err != nil { + if err := h.handle(tnt, req); err != nil { return err } @@ -60,7 +60,7 @@ func (h *RuleValidationHandler) OnUpdate( _ events.EventRecorder, ) handlers.Func { return func(_ context.Context, req admission.Request) *admission.Response { - if response := ValidateRule(tnt, req); response != nil { + if response := h.handle(tnt, req); response != nil { return response } @@ -68,7 +68,7 @@ func (h *RuleValidationHandler) OnUpdate( } } -func ValidateRule(tnt *capsulev1beta2.Tenant, req admission.Request) *admission.Response { +func (h *RuleValidationHandler) handle(tnt *capsulev1beta2.Tenant, req admission.Request) *admission.Response { if tnt == nil { return nil } @@ -77,7 +77,9 @@ func ValidateRule(tnt *capsulev1beta2.Tenant, req admission.Request) *admission. return nil } - for i, rule := range tnt.Spec.Rules { + var bodies []*rules.NamespaceRuleBodyNamespace + + for _, rule := range tnt.Spec.Rules { if rule == nil { continue } @@ -87,39 +89,11 @@ func ValidateRule(tnt *capsulev1beta2.Tenant, req admission.Request) *admission. continue } - if rule.Enforce == nil { - continue - } - - if rule.NamespaceSelector != nil { - if _, err := metav1.LabelSelectorAsSelector(rule.NamespaceSelector); err != nil { - return ad.Denyf("rules[%d].namespaceSelector is invalid: %v", i, err) - } - } - - for j, registry := range rule.Enforce.Workloads.Registries { - if _, err := regexp.Compile(registry.Expression); err != nil { - return ad.Denyf( - "rules[%d].enforce.workloads.registries[%d].exp %q is invalid: %v", - i, - j, - registry.Expression, - err, - ) - } - } + bodies = append(bodies, body) + } - for j, scheduler := range rule.Enforce.Workloads.Schedulers { - if _, err := regexp.Compile(scheduler.Expression); err != nil { - return ad.Denyf( - "rules[%d].enforce.workloads.schedulers[%d].exp %q is invalid: %v", - i, - j, - scheduler.Expression, - err, - ) - } - } + if err := ruleengine.ValidateRuleStatusBody(bodies); err != nil { + return ad.Deny(err.Error()) } return nil diff --git a/pkg/api/forbidden_list.go b/pkg/api/forbidden_list.go index 03aa0cbe9..e77379f29 100644 --- a/pkg/api/forbidden_list.go +++ b/pkg/api/forbidden_list.go @@ -7,7 +7,7 @@ import ( "fmt" "reflect" "regexp" - "sort" + "slices" "strings" ) @@ -17,18 +17,8 @@ type ForbiddenListSpec struct { Regex string `json:"deniedRegex,omitempty"` } -func (in ForbiddenListSpec) ExactMatch(value string) (ok bool) { - if len(in.Exact) > 0 { - sort.SliceStable(in.Exact, func(i, j int) bool { - return strings.ToLower(in.Exact[i]) < strings.ToLower(in.Exact[j]) - }) - - i := sort.SearchStrings(in.Exact, value) - - ok = i < len(in.Exact) && in.Exact[i] == value - } - - return ok +func (in ForbiddenListSpec) ExactMatch(value string) bool { + return slices.Contains(in.Exact, value) } func (in ForbiddenListSpec) RegexMatch(value string) (ok bool) { diff --git a/pkg/api/forbidden_list_test.go b/pkg/api/forbidden_list_test.go index 3a8ec4f71..bae245829 100644 --- a/pkg/api/forbidden_list_test.go +++ b/pkg/api/forbidden_list_test.go @@ -11,6 +11,16 @@ import ( "github.com/projectcapsule/capsule/pkg/api" ) +func denied() api.ForbiddenListSpec { + return api.ForbiddenListSpec{ + Exact: []string{ + "kubernetes.io/metadata.name", + "pod-security.kubernetes.io/enforce", + "NetworkPolicy", + }, + } +} + func TestForbiddenListSpec_ExactMatch(t *testing.T) { type tc struct { In []string @@ -120,3 +130,38 @@ func TestValidateForbidden(t *testing.T) { } } } + +func TestForbiddenKeysBypassed(t *testing.T) { + for _, k := range []string{"NetworkPolicy", "kubernetes.io/metadata.name"} { + if err := api.ValidateForbidden(map[string]string{k: "owned"}, denied()); err == nil { + t.Errorf("BYPASS CONFIRMED: ValidateForbidden ALLOWED denied key %q (list=%v)", k, denied().Exact) + } else { + t.Logf("(no bypass) correctly denied %q: %v", k, err) + } + } +} + +// Positive control: a third denied key in the SAME list is still correctly +// blocked — proving the policy genuinely forbids these keys and the harness is +// wired right (i.e. the bypass above is selective, not a dead enforcement path). +func TestPositiveControl_StillBlocked(t *testing.T) { + if err := api.ValidateForbidden(map[string]string{"pod-security.kubernetes.io/enforce": "privileged"}, denied()); err == nil { + t.Errorf("control failure: denied key 'pod-security.kubernetes.io/enforce' was NOT blocked") + } +} + +// Negative control: a key the admin did NOT deny is correctly allowed, +// proving the webhook is not simply denying everything. +func TestPoC_NegativeControl_BenignAllowed(t *testing.T) { + if err := api.ValidateForbidden(map[string]string{"app.kubernetes.io/name": "frontend"}, denied()); err != nil { + t.Errorf("control failure: benign key was wrongly denied: %v", err) + } +} + +// Direct primitive check, minimal repro of the root cause. +func TestExactMatch_RootCause(t *testing.T) { + spec := api.ForbiddenListSpec{Exact: []string{"B", "a"}} // mixed case + if !spec.ExactMatch("B") { + t.Errorf("ROOT CAUSE: ExactMatch(%q) returned false though %q is in %v", "B", "B", spec.Exact) + } +} diff --git a/pkg/api/rules/enforce_services_types.go b/pkg/api/rules/enforce_services_types.go new file mode 100644 index 000000000..1f047b4cf --- /dev/null +++ b/pkg/api/rules/enforce_services_types.go @@ -0,0 +1,78 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import "github.com/projectcapsule/capsule/pkg/api" + +// +kubebuilder:object:generate=true +type NamespaceRuleEnforceServicesBody struct { + // Types defines the Service types matched by this rule. + // + // Supported values: + // - ClusterIP + // - NodePort + // - LoadBalancer + // - ExternalName + // + // +optional + // +kubebuilder:validation:items:Enum=ClusterIP;NodePort;LoadBalancer;ExternalName + Types []ServiceType `json:"types,omitempty"` + + // LoadBalancers defines additional constraints for Services of type LoadBalancer. + // +optional + LoadBalancers *ServiceLoadBalancerRule `json:"loadBalancers,omitempty"` + + // ExternalNames defines additional constraints for Services of type ExternalName. + // +optional + ExternalNames *ServiceExternalNameRule `json:"externalNames,omitempty"` + + // NodePorts defines additional constraints for nodePort values. + // +optional + NodePorts *ServiceNodePortRule `json:"nodePorts,omitempty"` +} + +// +kubebuilder:validation:Enum=ClusterIP;NodePort;LoadBalancer;ExternalName +type ServiceType string + +const ( + ServiceTypeClusterIP ServiceType = "ClusterIP" + ServiceTypeNodePort ServiceType = "NodePort" + ServiceTypeLoadBalancer ServiceType = "LoadBalancer" + ServiceTypeExternalName ServiceType = "ExternalName" +) + +// +kubebuilder:object:generate=true +type ServiceLoadBalancerRule struct { + // CIDRs restricts spec.loadBalancerIP and spec.loadBalancerSourceRanges. + // Empty means no additional CIDR restriction once LoadBalancer is allowed by types. + // +optional + CIDRs []string `json:"cidrs,omitempty"` +} + +// +kubebuilder:object:generate=true +type ServiceExternalNameRule struct { + // Hostnames restricts spec.externalName. + // Empty means no additional hostname restriction once ExternalName is allowed by types. + // +optional + Hostnames []api.ExpressionMatch `json:"hostnames,omitempty"` +} + +// +kubebuilder:object:generate=true +type ServiceNodePortRule struct { + // Ports restricts explicitly requested nodePort values. + // Empty means no additional port restriction once NodePort is allowed by types. + // +optional + Ports []ServiceNodePortRange `json:"ports,omitempty"` +} + +// +kubebuilder:object:generate=true +type ServiceNodePortRange struct { + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + From int32 `json:"from"` + + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + To int32 `json:"to"` +} diff --git a/pkg/api/rules/enforce_types.go b/pkg/api/rules/enforce_types.go index 341d65211..584c4c9c1 100644 --- a/pkg/api/rules/enforce_types.go +++ b/pkg/api/rules/enforce_types.go @@ -14,4 +14,8 @@ type NamespaceRuleEnforceBody struct { // Enforcement for Workloads (Pods) Workloads NamespaceRuleEnforceWorkloadsBody `json:"workloads,omitempty"` + + // Enforcement for Services. + // +optional + Services NamespaceRuleEnforceServicesBody `json:"services,omitempty"` } diff --git a/pkg/api/rules/zz_generated.deepcopy.go b/pkg/api/rules/zz_generated.deepcopy.go index 0f1e1d976..68c23daa9 100644 --- a/pkg/api/rules/zz_generated.deepcopy.go +++ b/pkg/api/rules/zz_generated.deepcopy.go @@ -63,6 +63,7 @@ func (in *NamespaceRuleBodyTenant) DeepCopy() *NamespaceRuleBodyTenant { func (in *NamespaceRuleEnforceBody) DeepCopyInto(out *NamespaceRuleEnforceBody) { *out = *in in.Workloads.DeepCopyInto(&out.Workloads) + in.Services.DeepCopyInto(&out.Services) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleEnforceBody. @@ -75,6 +76,41 @@ func (in *NamespaceRuleEnforceBody) DeepCopy() *NamespaceRuleEnforceBody { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NamespaceRuleEnforceServicesBody) DeepCopyInto(out *NamespaceRuleEnforceServicesBody) { + *out = *in + if in.Types != nil { + in, out := &in.Types, &out.Types + *out = make([]ServiceType, len(*in)) + copy(*out, *in) + } + if in.LoadBalancers != nil { + in, out := &in.LoadBalancers, &out.LoadBalancers + *out = new(ServiceLoadBalancerRule) + (*in).DeepCopyInto(*out) + } + if in.ExternalNames != nil { + in, out := &in.ExternalNames, &out.ExternalNames + *out = new(ServiceExternalNameRule) + (*in).DeepCopyInto(*out) + } + if in.NodePorts != nil { + in, out := &in.NodePorts, &out.NodePorts + *out = new(ServiceNodePortRule) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleEnforceServicesBody. +func (in *NamespaceRuleEnforceServicesBody) DeepCopy() *NamespaceRuleEnforceServicesBody { + if in == nil { + return nil + } + out := new(NamespaceRuleEnforceServicesBody) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NamespaceRuleEnforceWorkloadsBody) DeepCopyInto(out *NamespaceRuleEnforceWorkloadsBody) { *out = *in @@ -185,3 +221,80 @@ func (in *OCIRegistry) DeepCopy() *OCIRegistry { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceExternalNameRule) DeepCopyInto(out *ServiceExternalNameRule) { + *out = *in + if in.Hostnames != nil { + in, out := &in.Hostnames, &out.Hostnames + *out = make([]api.ExpressionMatch, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceExternalNameRule. +func (in *ServiceExternalNameRule) DeepCopy() *ServiceExternalNameRule { + if in == nil { + return nil + } + out := new(ServiceExternalNameRule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceLoadBalancerRule) DeepCopyInto(out *ServiceLoadBalancerRule) { + *out = *in + if in.CIDRs != nil { + in, out := &in.CIDRs, &out.CIDRs + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceLoadBalancerRule. +func (in *ServiceLoadBalancerRule) DeepCopy() *ServiceLoadBalancerRule { + if in == nil { + return nil + } + out := new(ServiceLoadBalancerRule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceNodePortRange) DeepCopyInto(out *ServiceNodePortRange) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceNodePortRange. +func (in *ServiceNodePortRange) DeepCopy() *ServiceNodePortRange { + if in == nil { + return nil + } + out := new(ServiceNodePortRange) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceNodePortRule) DeepCopyInto(out *ServiceNodePortRule) { + *out = *in + if in.Ports != nil { + in, out := &in.Ports, &out.Ports + *out = make([]ServiceNodePortRange, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceNodePortRule. +func (in *ServiceNodePortRule) DeepCopy() *ServiceNodePortRule { + if in == nil { + return nil + } + out := new(ServiceNodePortRule) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/ruleengine/convert_test.go b/pkg/ruleengine/convert_test.go new file mode 100644 index 000000000..b21c58ef4 --- /dev/null +++ b/pkg/ruleengine/convert_test.go @@ -0,0 +1,194 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package ruleengine + +import ( + "testing" + + api "github.com/projectcapsule/capsule/pkg/api/rules" +) + +func TestEnforceBodiesFromNamespaceRules(t *testing.T) { + tests := []struct { + name string + input []*api.NamespaceRuleBodyNamespace + assert func(t *testing.T, got []*api.NamespaceRuleEnforceBody) + }{ + { + name: "nil input returns nil", + input: nil, + assert: func(t *testing.T, got []*api.NamespaceRuleEnforceBody) { + t.Helper() + + if got != nil { + t.Fatalf("expected nil, got %#v", got) + } + }, + }, + { + name: "empty input returns nil", + input: []*api.NamespaceRuleBodyNamespace{}, + assert: func(t *testing.T, got []*api.NamespaceRuleEnforceBody) { + t.Helper() + + if got != nil { + t.Fatalf("expected nil, got %#v", got) + } + }, + }, + { + name: "only nil bodies returns empty slice", + input: []*api.NamespaceRuleBodyNamespace{ + nil, + nil, + }, + assert: func(t *testing.T, got []*api.NamespaceRuleEnforceBody) { + t.Helper() + + if got == nil { + t.Fatalf("expected non-nil empty slice, got nil") + } + + if len(got) != 0 { + t.Fatalf("expected empty slice, got len=%d", len(got)) + } + }, + }, + { + name: "bodies without enforce are skipped", + input: []*api.NamespaceRuleBodyNamespace{ + {}, + { + Enforce: nil, + }, + }, + assert: func(t *testing.T, got []*api.NamespaceRuleEnforceBody) { + t.Helper() + + if got == nil { + t.Fatalf("expected non-nil empty slice, got nil") + } + + if len(got) != 0 { + t.Fatalf("expected empty slice, got len=%d", len(got)) + } + }, + }, + { + name: "returns enforce bodies in original order", + input: func() []*api.NamespaceRuleBodyNamespace { + first := &api.NamespaceRuleEnforceBody{ + Action: api.ActionTypeAllow, + } + second := &api.NamespaceRuleEnforceBody{ + Action: api.ActionTypeDeny, + } + third := &api.NamespaceRuleEnforceBody{ + Action: api.ActionTypeAudit, + } + + return []*api.NamespaceRuleBodyNamespace{ + { + Enforce: first, + }, + nil, + { + Enforce: second, + }, + {}, + { + Enforce: third, + }, + } + }(), + assert: func(t *testing.T, got []*api.NamespaceRuleEnforceBody) { + t.Helper() + + if len(got) != 3 { + t.Fatalf("expected 3 enforce bodies, got %d", len(got)) + } + + if got[0].Action != api.ActionTypeAllow { + t.Fatalf("expected first action %q, got %q", api.ActionTypeAllow, got[0].Action) + } + + if got[1].Action != api.ActionTypeDeny { + t.Fatalf("expected second action %q, got %q", api.ActionTypeDeny, got[1].Action) + } + + if got[2].Action != api.ActionTypeAudit { + t.Fatalf("expected third action %q, got %q", api.ActionTypeAudit, got[2].Action) + } + }, + }, + { + name: "returns original enforce pointers without deep copy", + input: func() []*api.NamespaceRuleBodyNamespace { + enforce := &api.NamespaceRuleEnforceBody{ + Action: api.ActionTypeAllow, + } + + return []*api.NamespaceRuleBodyNamespace{ + { + Enforce: enforce, + }, + } + }(), + assert: func(t *testing.T, got []*api.NamespaceRuleEnforceBody) { + t.Helper() + + if len(got) != 1 { + t.Fatalf("expected one enforce body, got %d", len(got)) + } + + if got[0].Action != api.ActionTypeAllow { + t.Fatalf("expected action %q, got %q", api.ActionTypeAllow, got[0].Action) + } + + got[0].Action = api.ActionTypeDeny + + if got[0].Action != api.ActionTypeDeny { + t.Fatalf("expected returned pointer to be mutable") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := EnforceBodiesFromNamespaceRules(tt.input) + tt.assert(t, got) + }) + } +} + +func TestEnforceBodiesFromNamespaceRulesReturnsOriginalPointers(t *testing.T) { + first := &api.NamespaceRuleEnforceBody{ + Action: api.ActionTypeAllow, + } + second := &api.NamespaceRuleEnforceBody{ + Action: api.ActionTypeDeny, + } + + got := EnforceBodiesFromNamespaceRules([]*api.NamespaceRuleBodyNamespace{ + { + Enforce: first, + }, + { + Enforce: second, + }, + }) + + if len(got) != 2 { + t.Fatalf("expected 2 enforce bodies, got %d", len(got)) + } + + if got[0] != first { + t.Fatalf("expected first returned enforce body to be the original pointer") + } + + if got[1] != second { + t.Fatalf("expected second returned enforce body to be the original pointer") + } +} diff --git a/pkg/ruleengine/enforce_evaluator.go b/pkg/ruleengine/enforce_evaluator.go index 8fcad7b0d..7692d06e9 100644 --- a/pkg/ruleengine/enforce_evaluator.go +++ b/pkg/ruleengine/enforce_evaluator.go @@ -5,6 +5,7 @@ package ruleengine import ( "fmt" + "strings" api "github.com/projectcapsule/capsule/pkg/api/rules" ) @@ -17,15 +18,27 @@ type Value struct { type Match struct { Matched bool MatchedValue any + + // Detail is optional human-readable matcher context. + // Example: "10.0.171.239 is contained in 10.0.0.0/16". + Detail string } type Decision struct { - SetName string - EventReason string - Action api.ActionType - Value Value + SetName string + EventReason string + Action api.ActionType + Value Value + MatchedValue any - Message string + + // MatchedRule is the human-readable rule description returned by Set.RuleDescription. + MatchedRule string + + // MatchDetail is the human-readable detail returned by Match.Detail. + MatchDetail string + + Message string } type DecisionError struct { @@ -77,18 +90,25 @@ func (e *Evaluation) Append(other *Evaluation) { } } -type Set[R any, T any] struct { - Name string - +type Set[R any, O any] struct { + Name string EventReason string - Values func(T) []Value + Values func(O) []Value + Rules func(*api.NamespaceRuleEnforceBody) []R + Matches func(R, Value) (Match, error) - Rules func(*api.NamespaceRuleEnforceBody) []R + // Message can fully override the default message. + // Prefer leaving this nil unless a rule requires very specific wording. + Message func(api.ActionType, Value, any) string - Matches func(R, Value) (Match, error) + // RuleDescription returns a human-readable representation of one rule. + // It is used only for admission/audit messages. + RuleDescription func(R) string - Message func(action api.ActionType, value Value, matchedValue any) string + // AllowedDescription optionally overrides the "Allowed values" label. + // Example: "Allowed CIDRs", "Allowed ranges", "Allowed hostnames". + AllowedDescription string } func EvaluateEnforce[R any, T any]( @@ -125,6 +145,7 @@ func EvaluateEnforce[R any, T any]( } hasAllowRule := false + allowRules := make([]R, 0) var lastDecision *Decision @@ -144,6 +165,8 @@ func EvaluateEnforce[R any, T any]( case api.ActionTypeAllow: hasAllowRule = true + allowRules = append(allowRules, items...) + case api.ActionTypeDeny, api.ActionTypeAudit: // Supported actions. @@ -165,13 +188,24 @@ func EvaluateEnforce[R any, T any]( continue } + matchedRule := describeRule(set, item) + decision := &Decision{ SetName: set.Name, EventReason: set.EventReason, Action: action, Value: value, MatchedValue: match.MatchedValue, - Message: decisionMessage(set, action, value, match.MatchedValue), + MatchedRule: matchedRule, + MatchDetail: strings.TrimSpace(match.Detail), + Message: decisionMessage( + set, + action, + value, + match.MatchedValue, + matchedRule, + match.Detail, + ), } switch action { @@ -205,12 +239,7 @@ func EvaluateEnforce[R any, T any]( EventReason: set.EventReason, Action: api.ActionTypeDeny, Value: value, - Message: fmt.Sprintf( - "%s %q at %s is not allowed by namespace rule", - set.Name, - value.Value, - value.Path, - ), + Message: allowMissMessage(set, value, allowRules), } return evaluation, nil @@ -220,41 +249,123 @@ func EvaluateEnforce[R any, T any]( return evaluation, nil } +const maxRuleDescriptions = 10 + +func describeRule[R any, O any](set Set[R, O], rule R) string { + if set.RuleDescription == nil { + return "" + } + + return strings.TrimSpace(set.RuleDescription(rule)) +} + +func describeRules[R any, O any](set Set[R, O], rules []R) string { + if len(rules) == 0 || set.RuleDescription == nil { + return "" + } + + limit := min(len(rules), maxRuleDescriptions) + + parts := make([]string, 0, limit) + + for i := range limit { + description := describeRule(set, rules[i]) + if description == "" { + continue + } + + parts = append(parts, description) + } + + if len(parts) == 0 { + return "" + } + + if len(rules) > maxRuleDescriptions { + parts = append(parts, fmt.Sprintf("and %d more", len(rules)-maxRuleDescriptions)) + } + + return strings.Join(parts, ", ") +} + +func allowedLabel[R any, O any](set Set[R, O]) string { + if set.AllowedDescription != "" { + return set.AllowedDescription + } + + return "Allowed values" +} + +func allowMissMessage[R any, T any]( + set Set[R, T], + value Value, + allowRules []R, +) string { + message := fmt.Sprintf( + "%s %q at %s is not allowed by namespace rule", + set.Name, + value.Value, + value.Path, + ) + + descriptions := describeRules(set, allowRules) + if descriptions == "" { + return message + } + + return fmt.Sprintf( + "%s: value did not match any allowed rule. %s: %s", + message, + allowedLabel(set), + descriptions, + ) +} + func decisionMessage[R any, T any]( set Set[R, T], action api.ActionType, value Value, matchedValue any, + matchedRule string, + matchDetail string, ) string { if set.Message != nil { return set.Message(action, value, matchedValue) } + matchDetail = strings.TrimSpace(matchDetail) + switch action { case api.ActionTypeAudit: - return fmt.Sprintf( + message := fmt.Sprintf( "%s %q at %s matched audit namespace rule", set.Name, value.Value, value.Path, ) + return appendMatchContext(message, matchedRule, matchDetail, "matched audit rule") + case api.ActionTypeDeny: - return fmt.Sprintf( + message := fmt.Sprintf( "%s %q at %s is denied by namespace rule", set.Name, value.Value, value.Path, ) + return appendMatchContext(message, matchedRule, matchDetail, "matched denied rule") + case api.ActionTypeAllow: - return fmt.Sprintf( + message := fmt.Sprintf( "%s %q at %s is allowed by namespace rule", set.Name, value.Value, value.Path, ) + return appendMatchContext(message, matchedRule, matchDetail, "matched allowed rule") + default: return fmt.Sprintf( "%s %q at %s matched namespace rule action %q", @@ -265,3 +376,20 @@ func decisionMessage[R any, T any]( ) } } + +func appendMatchContext( + message string, + matchedRule string, + matchDetail string, + rulePrefix string, +) string { + if matchDetail != "" { + return fmt.Sprintf("%s: %s", message, matchDetail) + } + + if matchedRule != "" { + return fmt.Sprintf("%s: %s %s", message, rulePrefix, matchedRule) + } + + return message +} diff --git a/pkg/ruleengine/enforce_evaluator_test.go b/pkg/ruleengine/enforce_evaluator_test.go index ed7a1ba56..17eb6b400 100644 --- a/pkg/ruleengine/enforce_evaluator_test.go +++ b/pkg/ruleengine/enforce_evaluator_test.go @@ -16,10 +16,11 @@ type testObject struct { } type testRule struct { - Name string - ShouldMatch bool - MatchValue any - Err error + Name string + ShouldMatch bool + MatchedValue any + Detail string + Err error } type enforceSpec struct { @@ -35,8 +36,7 @@ func TestEvaluateEnforce_ValidationErrors(t *testing.T) { t.Parallel() validFixture := newTestFixture() - - validSet := validFixture.set("test", nil) + validSet := validFixture.set("registry", nil) tests := []struct { name string @@ -55,29 +55,29 @@ func TestEvaluateEnforce_ValidationErrors(t *testing.T) { { name: "nil values extractor", set: Set[testRule, testObject]{ - Name: "test", + Name: "registry", Rules: validSet.Rules, Matches: validSet.Matches, }, - wantErr: "test: values extractor is nil", + wantErr: "registry: values extractor is nil", }, { name: "nil rules extractor", set: Set[testRule, testObject]{ - Name: "test", + Name: "registry", Values: validSet.Values, Matches: validSet.Matches, }, - wantErr: "test: rules extractor is nil", + wantErr: "registry: rules extractor is nil", }, { name: "nil matcher", set: Set[testRule, testObject]{ - Name: "test", + Name: "registry", Values: validSet.Values, Rules: validSet.Rules, }, - wantErr: "test: matcher is nil", + wantErr: "registry: matcher is nil", }, } @@ -88,21 +88,21 @@ func TestEvaluateEnforce_ValidationErrors(t *testing.T) { t.Parallel() evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "a", Path: "spec.value"}}}, + testObject{Values: []Value{{Value: "harbor/app:1", Path: "spec.containers[0].image"}}}, []*api.NamespaceRuleEnforceBody{{Action: api.ActionTypeAllow}}, tt.set, ) if err == nil { - t.Fatalf("EvaluateEnforce() expected error, got nil") + t.Fatalf("expected error, got nil") } if evaluation != nil { - t.Fatalf("EvaluateEnforce() evaluation = %#v, want nil on validation error", evaluation) + t.Fatalf("expected nil evaluation on setup validation error, got %#v", evaluation) } if !strings.Contains(err.Error(), tt.wantErr) { - t.Fatalf("EvaluateEnforce() error = %q, want containing %q", err.Error(), tt.wantErr) + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) } }) } @@ -123,47 +123,60 @@ func TestEvaluateEnforce_EmptyInputs(t *testing.T) { enforceSpecs: []enforceSpec{ { action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: true}}, + items: []testRule{ + {Name: "deny", ShouldMatch: true}, + }, }, }, }, { - name: "only empty value", + name: "empty value is skipped", obj: testObject{ - Values: []Value{{Value: "", Path: "spec.value"}}, + Values: []Value{ + {Value: "", Path: "spec.value"}, + }, }, enforceSpecs: []enforceSpec{ { action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: true}}, + items: []testRule{ + {Name: "deny", ShouldMatch: true}, + }, }, }, }, { name: "nil enforce bodies", obj: testObject{ - Values: []Value{{Value: "a", Path: "spec.value"}}, + Values: []Value{ + {Value: "harbor/app:1", Path: "spec.containers[0].image"}, + }, }, - enforceSpecs: nil, }, { name: "empty enforce bodies", obj: testObject{ - Values: []Value{{Value: "a", Path: "spec.value"}}, + Values: []Value{ + {Value: "harbor/app:1", Path: "spec.containers[0].image"}, + }, }, enforceSpecs: []enforceSpec{}, }, { name: "nil enforce body is ignored", obj: testObject{ - Values: []Value{{Value: "a", Path: "spec.value"}}, + Values: []Value{ + {Value: "harbor/app:1", Path: "spec.containers[0].image"}, + }, }, includeNilBody: true, }, { name: "enforce body without rule items is ignored", obj: testObject{ - Values: []Value{{Value: "a", Path: "spec.value"}}, + Values: []Value{ + {Value: "harbor/app:1", Path: "spec.containers[0].image"}, + }, }, enforceSpecs: []enforceSpec{ { @@ -174,12 +187,16 @@ func TestEvaluateEnforce_EmptyInputs(t *testing.T) { { name: "non matching rule item is ignored", obj: testObject{ - Values: []Value{{Value: "a", Path: "spec.value"}}, + Values: []Value{ + {Value: "harbor/app:1", Path: "spec.containers[0].image"}, + }, }, enforceSpecs: []enforceSpec{ { action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: false}}, + items: []testRule{ + {Name: "deny", ShouldMatch: false}, + }, }, }, }, @@ -200,14 +217,14 @@ func TestEvaluateEnforce_EmptyInputs(t *testing.T) { evaluation, err := EvaluateEnforce(tt.obj, enforceBodies, fixture.set("registry", nil)) if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) + t.Fatalf("unexpected error: %v", err) } assertNoBlocking(t, evaluation) assertNoFinal(t, evaluation) if len(evaluation.Audits) != 0 { - t.Fatalf("audits = %d, want 0", len(evaluation.Audits)) + t.Fatalf("expected no audits, got %d", len(evaluation.Audits)) } }) } @@ -219,157 +236,100 @@ func TestEvaluateEnforce_LastMatchingAllowDenyWins(t *testing.T) { tests := []struct { name string enforceSpecs []enforceSpec - wantBlocking bool wantFinalAction api.ActionType + wantBlocking bool + wantMessage string }{ { name: "single allow allows", enforceSpecs: []enforceSpec{ { action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: true}}, + items: []testRule{ + {Name: "harbor/.*", ShouldMatch: true}, + }, }, }, wantFinalAction: api.ActionTypeAllow, + wantMessage: `registry "harbor/app:1" at spec.containers[0].image is allowed by namespace rule: matched allowed rule harbor/.*`, }, { name: "single deny denies", enforceSpecs: []enforceSpec{ { action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: true}}, + items: []testRule{ + {Name: "harbor/blocked/.*", ShouldMatch: true}, + }, }, }, - wantBlocking: true, wantFinalAction: api.ActionTypeDeny, + wantBlocking: true, + wantMessage: `registry "harbor/app:1" at spec.containers[0].image is denied by namespace rule: matched denied rule harbor/blocked/.*`, }, { name: "deny then allow allows", enforceSpecs: []enforceSpec{ { action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: true}}, + items: []testRule{ + {Name: "harbor/.*", ShouldMatch: true}, + }, }, { action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: true}}, + items: []testRule{ + {Name: "harbor/app:.*", ShouldMatch: true}, + }, }, }, wantFinalAction: api.ActionTypeAllow, + wantMessage: `registry "harbor/app:1" at spec.containers[0].image is allowed by namespace rule: matched allowed rule harbor/app:.*`, }, { name: "allow then deny denies", enforceSpecs: []enforceSpec{ { action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: true}}, - }, - { - action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: true}}, - }, - }, - wantBlocking: true, - wantFinalAction: api.ActionTypeDeny, - }, - { - name: "unmatched later deny does not override previous allow", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: true}}, - }, - { - action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: false}}, + items: []testRule{ + {Name: "harbor/.*", ShouldMatch: true}, + }, }, - }, - wantFinalAction: api.ActionTypeAllow, - }, - { - name: "unmatched later allow does not override previous deny", - enforceSpecs: []enforceSpec{ { action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: true}}, - }, - { - action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: false}}, + items: []testRule{ + {Name: "harbor/app:.*", ShouldMatch: true}, + }, }, }, - wantBlocking: true, wantFinalAction: api.ActionTypeDeny, - }, - { - name: "multiple matching decisive rules last allow wins", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAllow, - items: []testRule{{Name: "allow-1", ShouldMatch: true}}, - }, - { - action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: true}}, - }, - { - action: api.ActionTypeAllow, - items: []testRule{{Name: "allow-2", ShouldMatch: true}}, - }, - }, - wantFinalAction: api.ActionTypeAllow, - }, - { - name: "multiple matching decisive rules last deny wins", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeDeny, - items: []testRule{{Name: "deny-1", ShouldMatch: true}}, - }, - { - action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: true}}, - }, - { - action: api.ActionTypeDeny, - items: []testRule{{Name: "deny-2", ShouldMatch: true}}, - }, - }, wantBlocking: true, - wantFinalAction: api.ActionTypeDeny, - }, - { - name: "only unmatched allow implicitly denies", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: false}}, - }, - }, - wantBlocking: true, + wantMessage: `registry "harbor/app:1" at spec.containers[0].image is denied by namespace rule: matched denied rule harbor/app:.*`, }, { - name: "only unmatched deny allows", + name: "last matching rule wins while non matching later rules are ignored", enforceSpecs: []enforceSpec{ { action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: false}}, + items: []testRule{ + {Name: "first-deny", ShouldMatch: true}, + }, }, - }, - }, - { - name: "unrelated allow before unrelated deny implicitly denies", - enforceSpecs: []enforceSpec{ { action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: false}}, + items: []testRule{ + {Name: "allow", ShouldMatch: true}, + }, }, { action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: false}}, + items: []testRule{ + {Name: "later-non-matching-deny", ShouldMatch: false}, + }, }, }, - wantBlocking: true, + wantFinalAction: api.ActionTypeAllow, + wantMessage: `registry "harbor/app:1" at spec.containers[0].image is allowed by namespace rule: matched allowed rule allow`, }, } @@ -380,1029 +340,489 @@ func TestEvaluateEnforce_LastMatchingAllowDenyWins(t *testing.T) { t.Parallel() fixture := newTestFixture() - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, + testObject{ + Values: []Value{ + {Value: "harbor/app:1", Path: "spec.containers[0].image"}, + }, + }, buildEnforceBodies(fixture, tt.enforceSpecs), fixture.set("registry", nil), ) if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) + t.Fatalf("unexpected error: %v", err) } - if tt.wantBlocking { - if evaluation.Blocking == nil { - t.Fatalf("Blocking = nil, want decision") - } - - if evaluation.Blocking.Action != api.ActionTypeDeny { - t.Fatalf("Blocking.Action = %q, want %q", evaluation.Blocking.Action, api.ActionTypeDeny) - } + assertFinalAction(t, evaluation, tt.wantFinalAction) - if err := evaluation.BlockingError(); err == nil { - t.Fatalf("BlockingError() = nil, want error") - } + if tt.wantBlocking { + assertBlockingAction(t, evaluation, api.ActionTypeDeny) } else { assertNoBlocking(t, evaluation) } - if tt.wantFinalAction == "" { - assertNoFinal(t, evaluation) - - return - } - - if evaluation.Final == nil { - t.Fatalf("Final = nil, want action %q", tt.wantFinalAction) - } - - if evaluation.Final.Action != tt.wantFinalAction { - t.Fatalf("Final.Action = %q, want %q", evaluation.Final.Action, tt.wantFinalAction) - } - - if tt.wantBlocking && evaluation.Blocking != evaluation.Final { - t.Fatalf("Blocking and Final should point to same deny decision") + if evaluation.Final.Message != tt.wantMessage { + t.Fatalf("final message = %q, want %q", evaluation.Final.Message, tt.wantMessage) } }) } } -func TestEvaluateEnforce_AuditSemantics(t *testing.T) { +func TestEvaluateEnforce_DefaultActionIsDeny(t *testing.T) { t.Parallel() - tests := []struct { - name string - enforceSpecs []enforceSpec - wantAudits int - wantBlocking bool - wantFinal api.ActionType - }{ - { - name: "single audit allows and records audit", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit", ShouldMatch: true}}, - }, - }, - wantAudits: 1, - }, - { - name: "multiple audits are all recorded", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit-1", ShouldMatch: true}}, - }, - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit-2", ShouldMatch: true}}, - }, - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit-3", ShouldMatch: true}}, - }, - }, - wantAudits: 3, - }, - { - name: "unmatched audit is ignored", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit", ShouldMatch: false}}, - }, - }, - wantAudits: 0, - }, - { - name: "audit plus deny records audit and denies", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit", ShouldMatch: true}}, - }, - { - action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: true}}, - }, - }, - wantAudits: 1, - wantBlocking: true, - wantFinal: api.ActionTypeDeny, - }, - { - name: "deny plus audit records audit but final deny remains", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: true}}, - }, - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit", ShouldMatch: true}}, - }, - }, - wantAudits: 1, - wantBlocking: true, - wantFinal: api.ActionTypeDeny, - }, - { - name: "audit plus allow records audit and allows", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit", ShouldMatch: true}}, - }, - { - action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: true}}, - }, - }, - wantAudits: 1, - wantFinal: api.ActionTypeAllow, - }, - { - name: "allow plus audit records audit and final allow remains", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: true}}, - }, - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit", ShouldMatch: true}}, - }, - }, - wantAudits: 1, - wantFinal: api.ActionTypeAllow, - }, - { - name: "audit records audit but does not prevent implicit allow-list deny", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: false}}, - }, - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit", ShouldMatch: true}}, - }, - }, - wantAudits: 1, - wantBlocking: true, - }, - { - name: "audit plus allow plus later deny denies and records audit", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit", ShouldMatch: true}}, - }, - { - action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: true}}, - }, - { - action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: true}}, - }, - }, - wantAudits: 1, - wantBlocking: true, - wantFinal: api.ActionTypeDeny, - }, - { - name: "audit plus deny plus later allow allows and records audit", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit", ShouldMatch: true}}, - }, - { - action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: true}}, - }, - { - action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: true}}, - }, - }, - wantAudits: 1, - wantFinal: api.ActionTypeAllow, - }, - { - name: "unmatched audit after matching allow does not change final allow", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeAllow, - items: []testRule{{Name: "allow", ShouldMatch: true}}, - }, - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit", ShouldMatch: false}}, - }, + fixture := newTestFixture() + + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "harbor/app:1", Path: "spec.containers[0].image"}, }, - wantAudits: 0, - wantFinal: api.ActionTypeAllow, }, - { - name: "unmatched audit after matching deny does not change final deny", - enforceSpecs: []enforceSpec{ - { - action: api.ActionTypeDeny, - items: []testRule{{Name: "deny", ShouldMatch: true}}, - }, - { - action: api.ActionTypeAudit, - items: []testRule{{Name: "audit", ShouldMatch: false}}, + buildEnforceBodies(fixture, []enforceSpec{ + { + action: "", + items: []testRule{ + {Name: "default-deny", ShouldMatch: true}, }, }, - wantAudits: 0, - wantBlocking: true, - wantFinal: api.ActionTypeDeny, - }, + }), + fixture.set("registry", nil), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) } - for _, tt := range tests { - tt := tt - - t.Run(tt.name, func(t *testing.T) { - t.Parallel() + assertBlockingAction(t, evaluation, api.ActionTypeDeny) - fixture := newTestFixture() + if evaluation.Blocking.Message != `registry "harbor/app:1" at spec.containers[0].image is denied by namespace rule: matched denied rule default-deny` { + t.Fatalf("blocking message = %q", evaluation.Blocking.Message) + } +} - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, - buildEnforceBodies(fixture, tt.enforceSpecs), - fixture.set("registry", nil), - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } +func TestEvaluateEnforce_AllowListMissDenies(t *testing.T) { + t.Parallel() - if len(evaluation.Audits) != tt.wantAudits { - t.Fatalf("audits = %d, want %d", len(evaluation.Audits), tt.wantAudits) - } + fixture := newTestFixture() - for _, audit := range evaluation.Audits { - if audit.Action != api.ActionTypeAudit { - t.Fatalf("audit action = %q, want %q", audit.Action, api.ActionTypeAudit) - } + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "docker.io/library/nginx:latest", Path: "spec.containers[0].image"}, + }, + }, + buildEnforceBodies(fixture, []enforceSpec{ + { + action: api.ActionTypeAllow, + items: []testRule{ + {Name: "harbor/.*", ShouldMatch: false}, + {Name: "registry.local/.*", ShouldMatch: false}, + }, + }, + }), + fixture.set("registry", nil), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } - if audit.Message == "" { - t.Fatalf("audit message is empty") - } - } + assertBlockingAction(t, evaluation, api.ActionTypeDeny) + assertNoFinal(t, evaluation) - if tt.wantBlocking { - if evaluation.Blocking == nil { - t.Fatalf("Blocking = nil, want decision") - } + want := `registry "docker.io/library/nginx:latest" at spec.containers[0].image is not allowed by namespace rule: value did not match any allowed rule. Allowed registries: harbor/.*, registry.local/.*` + if evaluation.Blocking.Message != want { + t.Fatalf("blocking message = %q, want %q", evaluation.Blocking.Message, want) + } +} - if evaluation.Blocking.Action != api.ActionTypeDeny { - t.Fatalf("Blocking.Action = %q, want %q", evaluation.Blocking.Action, api.ActionTypeDeny) - } +func TestEvaluateEnforce_AllowMissWithoutRuleDescriptionsUsesBaseMessage(t *testing.T) { + t.Parallel() - if err := evaluation.BlockingError(); err == nil { - t.Fatalf("BlockingError() = nil, want error") - } - } else { - assertNoBlocking(t, evaluation) - } + fixture := newTestFixture() - if tt.wantFinal == "" { - assertNoFinal(t, evaluation) + set := fixture.set("registry", nil) + set.RuleDescription = nil + set.AllowedDescription = "" - return - } + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "docker.io/library/nginx:latest", Path: "spec.containers[0].image"}, + }, + }, + buildEnforceBodies(fixture, []enforceSpec{ + { + action: api.ActionTypeAllow, + items: []testRule{ + {Name: "harbor/.*", ShouldMatch: false}, + }, + }, + }), + set, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } - if evaluation.Final == nil { - t.Fatalf("Final = nil, want %q", tt.wantFinal) - } + assertBlockingAction(t, evaluation, api.ActionTypeDeny) - if evaluation.Final.Action != tt.wantFinal { - t.Fatalf("Final.Action = %q, want %q", evaluation.Final.Action, tt.wantFinal) - } - }) + want := `registry "docker.io/library/nginx:latest" at spec.containers[0].image is not allowed by namespace rule` + if evaluation.Blocking.Message != want { + t.Fatalf("blocking message = %q, want %q", evaluation.Blocking.Message, want) } } -func TestEvaluateEnforce_ListValues(t *testing.T) { +func TestEvaluateEnforce_AllowMissDescriptionsAreLimited(t *testing.T) { t.Parallel() - t.Run("blocks on first value not matched by allow-list", func(t *testing.T) { - t.Parallel() + fixture := newTestFixture() + + items := make([]testRule, 0, 12) + for i := 0; i < 12; i++ { + items = append(items, testRule{ + Name: "rule-" + string(rune('a'+i)), + ShouldMatch: false, + }) + } - set := Set[string, testObject]{ - Name: "registry", - EventReason: "ForbiddenRegistry", - Values: func(obj testObject) []Value { - return obj.Values + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "unmatched", Path: "spec.value"}, }, - Rules: func(_ *api.NamespaceRuleEnforceBody) []string { - return []string{"allowed"} - }, - Matches: func(rule string, value Value) (Match, error) { - return Match{Matched: value.Value == rule}, nil - }, - } - - evaluation, err := EvaluateEnforce( - testObject{ - Values: []Value{ - {Value: "blocked", Path: "containers[0]"}, - {Value: "allowed", Path: "containers[1]"}, - }, - }, - []*api.NamespaceRuleEnforceBody{ - {Action: api.ActionTypeAllow}, - }, - set, - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } + }, + buildEnforceBodies(fixture, []enforceSpec{ + { + action: api.ActionTypeAllow, + items: items, + }, + }), + fixture.set("registry", nil), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } - if evaluation.Blocking == nil { - t.Fatalf("Blocking = nil, want implicit allow-list deny") - } + assertBlockingAction(t, evaluation, api.ActionTypeDeny) - if evaluation.Blocking.Value.Value != "blocked" { - t.Fatalf("Blocking.Value.Value = %q, want blocked", evaluation.Blocking.Value.Value) - } + msg := evaluation.Blocking.Message - if evaluation.Blocking.Value.Path != "containers[0]" { - t.Fatalf("Blocking.Value.Path = %q, want containers[0]", evaluation.Blocking.Value.Path) + for _, expected := range []string{ + "rule-a", + "rule-j", + "and 2 more", + } { + if !strings.Contains(msg, expected) { + t.Fatalf("expected message %q to contain %q", msg, expected) } + } - if evaluation.Final != nil { - t.Fatalf("Final = %#v, want nil because first value was implicitly denied", evaluation.Final) + for _, unexpected := range []string{ + "rule-k", + "rule-l", + } { + if strings.Contains(msg, unexpected) { + t.Fatalf("expected message %q not to contain %q", msg, unexpected) } - }) - + } } -func TestEvaluateEnforce_AllowListImplicitDeny(t *testing.T) { +func TestEvaluateEnforce_AuditIsObservational(t *testing.T) { t.Parallel() - t.Run("unmatched allow creates blocking decision without final decision", func(t *testing.T) { - t.Parallel() - - fixture := newTestFixture() + fixture := newTestFixture() - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "harbor/app:1", Path: "containers[0]"}}}, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(api.ActionTypeAllow, testRule{ - Name: "allow", - ShouldMatch: false, - }), + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "audit/app:1", Path: "spec.containers[0].image"}, }, - fixture.set("registry", nil), - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } - - if evaluation.Blocking == nil { - t.Fatalf("Blocking = nil, want implicit allow-list deny") - } - - if evaluation.Blocking.Action != api.ActionTypeDeny { - t.Fatalf("Blocking.Action = %q, want %q", evaluation.Blocking.Action, api.ActionTypeDeny) - } - - if evaluation.Blocking.Value.Value != "harbor/app:1" { - t.Fatalf("Blocking.Value.Value = %q, want harbor/app:1", evaluation.Blocking.Value.Value) - } - - if evaluation.Blocking.Value.Path != "containers[0]" { - t.Fatalf("Blocking.Value.Path = %q, want containers[0]", evaluation.Blocking.Value.Path) - } - - if evaluation.Final != nil { - t.Fatalf("Final = %#v, want nil for implicit allow-list deny", evaluation.Final) - } - - if !strings.Contains(evaluation.Blocking.Message, "is not allowed by namespace rule") { - t.Fatalf("Blocking.Message = %q, want implicit allow-list deny message", evaluation.Blocking.Message) - } - }) - - t.Run("audit-only rules remain observational and do not deny", func(t *testing.T) { - t.Parallel() - - fixture := newTestFixture() - - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "harbor/app:1", Path: "containers[0]"}}}, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(api.ActionTypeAudit, testRule{ - Name: "audit", - ShouldMatch: true, - }), + }, + buildEnforceBodies(fixture, []enforceSpec{ + { + action: api.ActionTypeAudit, + items: []testRule{ + { + Name: "audit/.*", + ShouldMatch: true, + Detail: `"audit/app:1" matched registry rule audit/.*`, + }, + }, }, - fixture.set("registry", nil), - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } + }), + fixture.set("registry", nil), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } - if len(evaluation.Audits) != 1 { - t.Fatalf("audits = %d, want 1", len(evaluation.Audits)) - } + assertNoBlocking(t, evaluation) + assertNoFinal(t, evaluation) - assertNoBlocking(t, evaluation) - assertNoFinal(t, evaluation) - }) + if len(evaluation.Audits) != 1 { + t.Fatalf("expected 1 audit, got %d", len(evaluation.Audits)) + } - t.Run("matched audit does not prevent implicit allow-list deny", func(t *testing.T) { - t.Parallel() + audit := evaluation.Audits[0] - fixture := newTestFixture() - - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "harbor/app:1", Path: "containers[0]"}}}, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(api.ActionTypeAudit, testRule{ - Name: "audit", - ShouldMatch: true, - }), - fixture.enforce(api.ActionTypeAllow, testRule{ - Name: "allow", - ShouldMatch: false, - }), - }, - fixture.set("registry", nil), - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } - - if len(evaluation.Audits) != 1 { - t.Fatalf("audits = %d, want 1", len(evaluation.Audits)) - } + if audit.Action != api.ActionTypeAudit { + t.Fatalf("audit action = %q, want %q", audit.Action, api.ActionTypeAudit) + } - if evaluation.Blocking == nil { - t.Fatalf("Blocking = nil, want implicit allow-list deny") - } + if audit.MatchedRule != "audit/.*" { + t.Fatalf("audit matched rule = %q, want %q", audit.MatchedRule, "audit/.*") + } - if evaluation.Blocking.Action != api.ActionTypeDeny { - t.Fatalf("Blocking.Action = %q, want %q", evaluation.Blocking.Action, api.ActionTypeDeny) - } + if audit.MatchDetail != `"audit/app:1" matched registry rule audit/.*` { + t.Fatalf("audit match detail = %q", audit.MatchDetail) + } - if evaluation.Final != nil { - t.Fatalf("Final = %#v, want nil for implicit allow-list deny", evaluation.Final) - } - }) + want := `registry "audit/app:1" at spec.containers[0].image matched audit namespace rule: "audit/app:1" matched registry rule audit/.*` + if audit.Message != want { + t.Fatalf("audit message = %q, want %q", audit.Message, want) + } } -func TestEvaluateEnforce_MultipleValues(t *testing.T) { +func TestEvaluateEnforce_AuditDoesNotSatisfyAllowList(t *testing.T) { t.Parallel() - t.Run("continues after allowed first value and blocks on second value", func(t *testing.T) { - t.Parallel() + fixture := newTestFixture() - set := Set[string, testObject]{ - Name: "registry", - EventReason: "ForbiddenRegistry", - Values: func(obj testObject) []Value { - return obj.Values - }, - Rules: func(_ *api.NamespaceRuleEnforceBody) []string { - return []string{"bad"} + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "audit/app:1", Path: "spec.containers[0].image"}, }, - Matches: func(rule string, value Value) (Match, error) { - return Match{Matched: value.Value == rule}, nil - }, - } - - evaluation, err := EvaluateEnforce( - testObject{ - Values: []Value{ - {Value: "good", Path: "containers[0]"}, - {Value: "bad", Path: "containers[1]"}, + }, + buildEnforceBodies(fixture, []enforceSpec{ + { + action: api.ActionTypeAudit, + items: []testRule{ + {Name: "audit/.*", ShouldMatch: true}, }, }, - []*api.NamespaceRuleEnforceBody{ - {Action: api.ActionTypeDeny}, + { + action: api.ActionTypeAllow, + items: []testRule{ + {Name: "allowed/.*", ShouldMatch: false}, + }, }, - set, - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } + }), + fixture.set("registry", nil), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } - if evaluation.Blocking == nil { - t.Fatalf("Blocking = nil, want decision") - } + if len(evaluation.Audits) != 1 { + t.Fatalf("expected 1 audit, got %d", len(evaluation.Audits)) + } - if evaluation.Blocking.Value.Value != "bad" { - t.Fatalf("Blocking.Value.Value = %q, want %q", evaluation.Blocking.Value.Value, "bad") - } + assertBlockingAction(t, evaluation, api.ActionTypeDeny) + assertNoFinal(t, evaluation) - if evaluation.Blocking.Value.Path != "containers[1]" { - t.Fatalf("Blocking.Value.Path = %q, want %q", evaluation.Blocking.Value.Path, "containers[1]") - } - }) + if !strings.Contains(evaluation.Blocking.Message, "Allowed registries: allowed/.*") { + t.Fatalf("blocking message = %q", evaluation.Blocking.Message) + } +} - t.Run("skips empty values and evaluates non-empty values", func(t *testing.T) { - t.Parallel() +func TestEvaluateEnforce_MatchDetailOverridesMatchedRuleInMessage(t *testing.T) { + t.Parallel() - fixture := newTestFixture() + fixture := newTestFixture() - evaluation, err := EvaluateEnforce( - testObject{ - Values: []Value{ - {Value: "", Path: "containers[0]"}, - {Value: "bad", Path: "containers[1]"}, - }, - }, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(api.ActionTypeDeny, testRule{Name: "deny", ShouldMatch: true}), + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "10.0.171.239", Path: "spec.loadBalancerIP"}, }, - fixture.set("registry", nil), - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } - - if evaluation.Blocking == nil { - t.Fatalf("Blocking = nil, want decision") - } - - if evaluation.Blocking.Value.Path != "containers[1]" { - t.Fatalf("Blocking.Value.Path = %q, want containers[1]", evaluation.Blocking.Value.Path) - } - }) - - t.Run("audits all matching values", func(t *testing.T) { - t.Parallel() - - fixture := newTestFixture() - - evaluation, err := EvaluateEnforce( - testObject{ - Values: []Value{ - {Value: "a", Path: "containers[0]"}, - {Value: "b", Path: "containers[1]"}, + }, + buildEnforceBodies(fixture, []enforceSpec{ + { + action: api.ActionTypeDeny, + items: []testRule{ + { + Name: "10.0.0.0/8", + ShouldMatch: true, + MatchedValue: "10.0.0.0/8", + Detail: "10.0.171.239 is contained in 10.0.0.0/8", + }, }, }, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(api.ActionTypeAudit, testRule{Name: "audit", ShouldMatch: true}), - }, - fixture.set("registry", nil), - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } - - if len(evaluation.Audits) != 2 { - t.Fatalf("audits = %d, want 2", len(evaluation.Audits)) - } - - if evaluation.Audits[0].Value.Path != "containers[0]" { - t.Fatalf("first audit path = %q, want containers[0]", evaluation.Audits[0].Value.Path) - } - - if evaluation.Audits[1].Value.Path != "containers[1]" { - t.Fatalf("second audit path = %q, want containers[1]", evaluation.Audits[1].Value.Path) - } - }) + }), + fixture.set("loadBalancer CIDR", nil), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } - t.Run("stops after first blocking value", func(t *testing.T) { - t.Parallel() + assertBlockingAction(t, evaluation, api.ActionTypeDeny) - set := Set[string, testObject]{ - Name: "registry", - Values: func(obj testObject) []Value { - return obj.Values - }, - Rules: func(_ *api.NamespaceRuleEnforceBody) []string { - return []string{"bad", "worse"} - }, - Matches: func(rule string, value Value) (Match, error) { - return Match{Matched: value.Value == rule}, nil - }, - } + blocking := evaluation.Blocking - evaluation, err := EvaluateEnforce( - testObject{ - Values: []Value{ - {Value: "bad", Path: "containers[0]"}, - {Value: "worse", Path: "containers[1]"}, - }, - }, - []*api.NamespaceRuleEnforceBody{ - {Action: api.ActionTypeDeny}, - }, - set, - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } + if blocking.MatchedRule != "10.0.0.0/8" { + t.Fatalf("matched rule = %q, want %q", blocking.MatchedRule, "10.0.0.0/8") + } - if evaluation.Blocking == nil { - t.Fatalf("Blocking = nil, want decision") - } + if blocking.MatchDetail != "10.0.171.239 is contained in 10.0.0.0/8" { + t.Fatalf("match detail = %q", blocking.MatchDetail) + } - if evaluation.Blocking.Value.Value != "bad" { - t.Fatalf("Blocking.Value.Value = %q, want bad", evaluation.Blocking.Value.Value) - } - }) + want := `loadBalancer CIDR "10.0.171.239" at spec.loadBalancerIP is denied by namespace rule: 10.0.171.239 is contained in 10.0.0.0/8` + if blocking.Message != want { + t.Fatalf("blocking message = %q, want %q", blocking.Message, want) + } } -func TestEvaluateEnforce_MatchedValueAndMessages(t *testing.T) { +func TestEvaluateEnforce_CustomMessageOverridesDefaultMessage(t *testing.T) { t.Parallel() - t.Run("matched value is propagated to final decision", func(t *testing.T) { - t.Parallel() - - fixture := newTestFixture() - - matchedValue := map[string]string{"rule": "compiled-registry-rule"} - - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(api.ActionTypeAllow, testRule{ - Name: "allow", - ShouldMatch: true, - MatchValue: matchedValue, - }), - }, - fixture.set("registry", nil), - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } - - if evaluation.Final == nil { - t.Fatalf("Final = nil, want decision") - } - - got, ok := evaluation.Final.MatchedValue.(map[string]string) - if !ok { - t.Fatalf("Final.MatchedValue type = %T, want map[string]string", evaluation.Final.MatchedValue) - } - - if got["rule"] != "compiled-registry-rule" { - t.Fatalf("Final.MatchedValue[rule] = %q", got["rule"]) - } - }) - - t.Run("matched value is propagated to blocking decision", func(t *testing.T) { - t.Parallel() - - fixture := newTestFixture() - - matchedValue := "compiled-deny-rule" - - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(api.ActionTypeDeny, testRule{ - Name: "deny", - ShouldMatch: true, - MatchValue: matchedValue, - }), - }, - fixture.set("registry", nil), - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } - - if evaluation.Blocking == nil { - t.Fatalf("Blocking = nil, want decision") - } + fixture := newTestFixture() - if evaluation.Blocking.MatchedValue != matchedValue { - t.Fatalf("Blocking.MatchedValue = %#v, want %#v", evaluation.Blocking.MatchedValue, matchedValue) - } + set := fixture.set("registry", func(action api.ActionType, value Value, matched any) string { + return "custom message" }) - t.Run("matched value is propagated to audit decision", func(t *testing.T) { - t.Parallel() - - fixture := newTestFixture() - - matchedValue := "compiled-audit-rule" - - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(api.ActionTypeAudit, testRule{ - Name: "audit", - ShouldMatch: true, - MatchValue: matchedValue, - }), + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "harbor/app:1", Path: "spec.containers[0].image"}, }, - fixture.set("registry", nil), - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } - - if len(evaluation.Audits) != 1 { - t.Fatalf("audits = %d, want 1", len(evaluation.Audits)) - } - - if evaluation.Audits[0].MatchedValue != matchedValue { - t.Fatalf("audit MatchedValue = %#v, want %#v", evaluation.Audits[0].MatchedValue, matchedValue) - } - }) - - t.Run("custom message is used", func(t *testing.T) { - t.Parallel() - - fixture := newTestFixture() - - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(api.ActionTypeDeny, testRule{Name: "deny", ShouldMatch: true}), + }, + buildEnforceBodies(fixture, []enforceSpec{ + { + action: api.ActionTypeDeny, + items: []testRule{ + {Name: "harbor/.*", ShouldMatch: true}, + }, }, - fixture.set("registry", func(action api.ActionType, value Value, matchedValue any) string { - return "custom: " + string(action) + " " + value.Value - }), - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } - - if evaluation.Blocking == nil { - t.Fatalf("Blocking = nil, want decision") - } - - if evaluation.Blocking.Message != "custom: deny app:1" { - t.Fatalf("Blocking.Message = %q, want custom message", evaluation.Blocking.Message) - } - }) - - t.Run("default messages are populated", func(t *testing.T) { - t.Parallel() - - for _, action := range []api.ActionType{ - api.ActionTypeAllow, - api.ActionTypeDeny, - api.ActionTypeAudit, - } { - action := action - - t.Run(string(action), func(t *testing.T) { - t.Parallel() + }), + set, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } - fixture := newTestFixture() + assertBlockingAction(t, evaluation, api.ActionTypeDeny) - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(action, testRule{Name: string(action), ShouldMatch: true}), - }, - fixture.set("registry", nil), - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } - - switch action { - case api.ActionTypeAllow: - if evaluation.Final == nil || evaluation.Final.Message == "" { - t.Fatalf("allow final message is empty") - } - case api.ActionTypeDeny: - if evaluation.Blocking == nil || evaluation.Blocking.Message == "" { - t.Fatalf("deny blocking message is empty") - } - case api.ActionTypeAudit: - if len(evaluation.Audits) != 1 || evaluation.Audits[0].Message == "" { - t.Fatalf("audit message is empty") - } - } - }) - } - }) + if evaluation.Blocking.Message != "custom message" { + t.Fatalf("blocking message = %q, want custom message", evaluation.Blocking.Message) + } } -func TestEvaluateEnforce_DefaultActionAndUnsupportedAction(t *testing.T) { +func TestEvaluateEnforce_MatcherError(t *testing.T) { t.Parallel() - t.Run("empty action defaults to deny", func(t *testing.T) { - t.Parallel() - - fixture := newTestFixture() + fixture := newTestFixture() + matchErr := errors.New("invalid regex") - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce("", testRule{Name: "default-deny", ShouldMatch: true}), + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "harbor/app:1", Path: "spec.containers[0].image"}, }, - fixture.set("registry", nil), - ) - if err != nil { - t.Fatalf("EvaluateEnforce() unexpected error: %v", err) - } - - if evaluation.Blocking == nil { - t.Fatalf("Blocking = nil, want default deny decision") - } - - if evaluation.Blocking.Action != api.ActionTypeDeny { - t.Fatalf("Blocking.Action = %q, want %q", evaluation.Blocking.Action, api.ActionTypeDeny) - } - }) - - t.Run("unsupported action returns error with partial evaluation", func(t *testing.T) { - t.Parallel() - - fixture := newTestFixture() - - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(api.ActionTypeAudit, testRule{Name: "audit", ShouldMatch: true}), - fixture.enforce(api.ActionType("unsupported"), testRule{Name: "bad", ShouldMatch: true}), + }, + buildEnforceBodies(fixture, []enforceSpec{ + { + action: api.ActionTypeDeny, + items: []testRule{ + { + Name: "bad-rule", + ShouldMatch: true, + Err: matchErr, + }, + }, }, - fixture.set("registry", nil), - ) - if err == nil { - t.Fatalf("EvaluateEnforce() expected error, got nil") - } - - if !strings.Contains(err.Error(), `registry: unsupported rule action "unsupported"`) { - t.Fatalf("error = %q, want unsupported action message", err.Error()) - } + }), + fixture.set("registry", nil), + ) + if err == nil { + t.Fatalf("expected error, got nil") + } - if evaluation == nil { - t.Fatalf("evaluation = nil, want partial evaluation") - } + if evaluation == nil { + t.Fatalf("expected non-nil evaluation after runtime matcher error") + } - if len(evaluation.Audits) != 1 { - t.Fatalf("audits = %d, want 1 from partial evaluation", len(evaluation.Audits)) - } - }) + if !strings.Contains(err.Error(), "registry: invalid rule: invalid regex") { + t.Fatalf("error = %q", err.Error()) + } } -func TestEvaluateEnforce_MatcherErrors(t *testing.T) { +func TestEvaluateEnforce_UnsupportedAction(t *testing.T) { t.Parallel() - t.Run("matcher error is wrapped and returns partial evaluation", func(t *testing.T) { - t.Parallel() - - fixture := newTestFixture() - - wantErr := errors.New("matcher failed") + fixture := newTestFixture() - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(api.ActionTypeAllow, testRule{Name: "allow", ShouldMatch: true, Err: wantErr}), + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "harbor/app:1", Path: "spec.containers[0].image"}, }, - fixture.set("registry", nil), - ) - - if err == nil { - t.Fatalf("EvaluateEnforce() expected error, got nil") - } - - if !errors.Is(err, wantErr) { - t.Fatalf("EvaluateEnforce() error = %v, want wrapping %v", err, wantErr) - } - - if !strings.Contains(err.Error(), "registry: invalid rule") { - t.Fatalf("EvaluateEnforce() error = %q, want invalid rule context", err.Error()) - } - - if evaluation == nil { - t.Fatalf("evaluation = nil, want partial evaluation") - } - }) - - t.Run("matcher error after audit keeps audit in partial evaluation", func(t *testing.T) { - t.Parallel() - - fixture := newTestFixture() - - wantErr := errors.New("matcher failed") - - evaluation, err := EvaluateEnforce( - testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, - []*api.NamespaceRuleEnforceBody{ - fixture.enforce(api.ActionTypeAudit, testRule{Name: "audit", ShouldMatch: true}), - fixture.enforce(api.ActionTypeAllow, testRule{Name: "allow", Err: wantErr}), + }, + buildEnforceBodies(fixture, []enforceSpec{ + { + action: api.ActionType("invalid"), + items: []testRule{ + {Name: "harbor/.*", ShouldMatch: true}, + }, }, - fixture.set("registry", nil), - ) - - if err == nil { - t.Fatalf("EvaluateEnforce() expected error, got nil") - } - - if !errors.Is(err, wantErr) { - t.Fatalf("EvaluateEnforce() error = %v, want wrapping %v", err, wantErr) - } + }), + fixture.set("registry", nil), + ) + if err == nil { + t.Fatalf("expected error, got nil") + } - if evaluation == nil { - t.Fatalf("evaluation = nil, want partial evaluation") - } + if evaluation == nil { + t.Fatalf("expected non-nil evaluation after runtime action error") + } - if len(evaluation.Audits) != 1 { - t.Fatalf("audits = %d, want 1", len(evaluation.Audits)) - } - }) + if !strings.Contains(err.Error(), `registry: unsupported rule action "invalid"`) { + t.Fatalf("error = %q", err.Error()) + } } func TestEvaluation_BlockingError(t *testing.T) { t.Parallel() - t.Run("nil evaluation has no blocking error", func(t *testing.T) { - t.Parallel() - - var evaluation *Evaluation - - if err := evaluation.BlockingError(); err != nil { - t.Fatalf("BlockingError() = %v, want nil", err) - } - }) - - t.Run("evaluation without blocking has no blocking error", func(t *testing.T) { - t.Parallel() - - evaluation := &Evaluation{} - - if err := evaluation.BlockingError(); err != nil { - t.Fatalf("BlockingError() = %v, want nil", err) - } - }) - - t.Run("evaluation with blocking returns decision error", func(t *testing.T) { - t.Parallel() - - decision := &Decision{ - SetName: "registry", - Action: api.ActionTypeDeny, - Message: "denied", - } - - evaluation := &Evaluation{ - Blocking: decision, - } + if err := (*Evaluation)(nil).BlockingError(); err != nil { + t.Fatalf("nil evaluation BlockingError() = %v, want nil", err) + } - err := evaluation.BlockingError() - if err == nil { - t.Fatalf("BlockingError() = nil, want error") - } + evaluation := &Evaluation{} + if err := evaluation.BlockingError(); err != nil { + t.Fatalf("empty evaluation BlockingError() = %v, want nil", err) + } - var decisionErr *DecisionError - if !errors.As(err, &decisionErr) { - t.Fatalf("BlockingError() type = %T, want *DecisionError", err) - } + evaluation.Blocking = &Decision{ + Message: "blocked by rule", + } - if decisionErr.Decision != decision { - t.Fatalf("DecisionError.Decision = %#v, want original decision", decisionErr.Decision) - } + err := evaluation.BlockingError() + if err == nil { + t.Fatalf("expected blocking error, got nil") + } - if err.Error() != "denied" { - t.Fatalf("BlockingError().Error() = %q, want denied", err.Error()) - } - }) + if err.Error() != "blocked by rule" { + t.Fatalf("blocking error = %q, want %q", err.Error(), "blocked by rule") + } } -func TestDecisionError_Error(t *testing.T) { +func TestDecisionError_ErrorFallback(t *testing.T) { t.Parallel() tests := []struct { name string err *DecisionError - want string }{ { - name: "nil error receiver", + name: "nil error", err: nil, - want: "namespace rule decision denied request", }, { name: "nil decision", err: &DecisionError{}, - want: "namespace rule decision denied request", - }, - { - name: "decision message", - err: &DecisionError{ - Decision: &Decision{ - Message: "custom denied message", - }, - }, - want: "custom denied message", - }, - { - name: "empty decision message", - err: &DecisionError{ - Decision: &Decision{}, - }, - want: "", }, } @@ -1412,9 +832,8 @@ func TestDecisionError_Error(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := tt.err.Error() - if got != tt.want { - t.Fatalf("Error() = %q, want %q", got, tt.want) + if tt.err.Error() != "namespace rule decision denied request" { + t.Fatalf("Error() = %q", tt.err.Error()) } }) } @@ -1423,193 +842,148 @@ func TestDecisionError_Error(t *testing.T) { func TestEvaluation_Append(t *testing.T) { t.Parallel() - t.Run("nil receiver is no-op", func(t *testing.T) { + t.Run("nil receiver or nil other does nothing", func(t *testing.T) { t.Parallel() var evaluation *Evaluation + evaluation.Append(&Evaluation{}) - evaluation.Append(&Evaluation{ - Audits: []*Decision{{SetName: "audit"}}, - Final: &Decision{Action: api.ActionTypeAllow}, - }) - }) + nonNil := &Evaluation{} + nonNil.Append(nil) - t.Run("nil other is no-op", func(t *testing.T) { - t.Parallel() + assertNoBlocking(t, nonNil) + assertNoFinal(t, nonNil) - evaluation := &Evaluation{ - Audits: []*Decision{{SetName: "audit-1"}}, - Final: &Decision{Action: api.ActionTypeAllow}, - } - - evaluation.Append(nil) - - if len(evaluation.Audits) != 1 { - t.Fatalf("audits = %d, want 1", len(evaluation.Audits)) - } - - if evaluation.Final == nil || evaluation.Final.Action != api.ActionTypeAllow { - t.Fatalf("Final = %#v, want allow", evaluation.Final) + if len(nonNil.Audits) != 0 { + t.Fatalf("expected no audits, got %d", len(nonNil.Audits)) } }) - t.Run("appends audits and replaces final and blocking", func(t *testing.T) { + t.Run("appends audits and overrides final and blocking", func(t *testing.T) { t.Parallel() - initialFinal := &Decision{SetName: "initial", Action: api.ActionTypeAllow} - newFinal := &Decision{SetName: "new", Action: api.ActionTypeDeny} - newBlocking := &Decision{SetName: "new", Action: api.ActionTypeDeny} + firstAudit := &Decision{Action: api.ActionTypeAudit, Message: "audit-1"} + secondAudit := &Decision{Action: api.ActionTypeAudit, Message: "audit-2"} + final := &Decision{Action: api.ActionTypeAllow, Message: "final"} + blocking := &Decision{Action: api.ActionTypeDeny, Message: "blocking"} evaluation := &Evaluation{ - Audits: []*Decision{{SetName: "audit-1"}}, - Final: initialFinal, + Audits: []*Decision{ + firstAudit, + }, } evaluation.Append(&Evaluation{ - Audits: []*Decision{{SetName: "audit-2"}, {SetName: "audit-3"}}, - Final: newFinal, - Blocking: newBlocking, + Audits: []*Decision{ + secondAudit, + }, + Final: final, + Blocking: blocking, }) - if len(evaluation.Audits) != 3 { - t.Fatalf("audits = %d, want 3", len(evaluation.Audits)) - } - - if evaluation.Final != newFinal { - t.Fatalf("Final = %#v, want new final", evaluation.Final) + if len(evaluation.Audits) != 2 { + t.Fatalf("audits = %d, want 2", len(evaluation.Audits)) } - if evaluation.Blocking != newBlocking { - t.Fatalf("Blocking = %#v, want new blocking", evaluation.Blocking) + if evaluation.Audits[0] != firstAudit { + t.Fatalf("first audit pointer was not preserved") } - }) - - t.Run("does not replace final or blocking with nil", func(t *testing.T) { - t.Parallel() - - final := &Decision{SetName: "final", Action: api.ActionTypeAllow} - blocking := &Decision{SetName: "blocking", Action: api.ActionTypeDeny} - evaluation := &Evaluation{ - Final: final, - Blocking: blocking, + if evaluation.Audits[1] != secondAudit { + t.Fatalf("second audit pointer was not appended") } - evaluation.Append(&Evaluation{ - Audits: []*Decision{{SetName: "audit"}}, - }) - if evaluation.Final != final { - t.Fatalf("Final was replaced unexpectedly") + t.Fatalf("final was not updated") } if evaluation.Blocking != blocking { - t.Fatalf("Blocking was replaced unexpectedly") - } - - if len(evaluation.Audits) != 1 { - t.Fatalf("audits = %d, want 1", len(evaluation.Audits)) + t.Fatalf("blocking was not updated") } }) } -func TestDecisionMessage(t *testing.T) { +func TestMessageHelpers(t *testing.T) { t.Parallel() set := Set[testRule, testObject]{ - Name: "registry", + Name: "registry", + AllowedDescription: "Allowed registries", + RuleDescription: func(rule testRule) string { + return rule.Name + }, } - value := Value{ - Value: "harbor/app:1", - Path: "containers[0]", - } + t.Run("allowedLabel default", func(t *testing.T) { + t.Parallel() - tests := []struct { - name string - action api.ActionType - want string - }{ - { - name: "audit", - action: api.ActionTypeAudit, - want: `registry "harbor/app:1" at containers[0] matched audit namespace rule`, - }, - { - name: "deny", - action: api.ActionTypeDeny, - want: `registry "harbor/app:1" at containers[0] is denied by namespace rule`, - }, - { - name: "allow", - action: api.ActionTypeAllow, - want: `registry "harbor/app:1" at containers[0] is allowed by namespace rule`, - }, - { - name: "unknown", - action: api.ActionType("custom"), - want: `registry "harbor/app:1" at containers[0] matched namespace rule action "custom"`, - }, - } + defaultSet := Set[testRule, testObject]{} - for _, tt := range tests { - tt := tt + if got := allowedLabel(defaultSet); got != "Allowed values" { + t.Fatalf("allowedLabel() = %q, want %q", got, "Allowed values") + } + }) - t.Run(tt.name, func(t *testing.T) { - t.Parallel() + t.Run("appendMatchContext uses detail before rule", func(t *testing.T) { + t.Parallel() - got := decisionMessage(set, tt.action, value, nil) - if got != tt.want { - t.Fatalf("decisionMessage() = %q, want %q", got, tt.want) - } - }) - } -} + got := appendMatchContext("message", "rule", "detail", "matched rule") + if got != "message: detail" { + t.Fatalf("appendMatchContext() = %q, want %q", got, "message: detail") + } + }) -func TestDecisionMessage_CustomMessageReceivesMatchedValue(t *testing.T) { - t.Parallel() + t.Run("appendMatchContext uses rule when detail is empty", func(t *testing.T) { + t.Parallel() - set := Set[testRule, testObject]{ - Name: "registry", - Message: func(action api.ActionType, value Value, matchedValue any) string { - return string(action) + ":" + value.Value + ":" + matchedValue.(string) - }, - } + got := appendMatchContext("message", "rule", "", "matched rule") + if got != "message: matched rule rule" { + t.Fatalf("appendMatchContext() = %q", got) + } + }) - value := Value{ - Value: "harbor/app:1", - Path: "containers[0]", - } + t.Run("appendMatchContext leaves message unchanged without context", func(t *testing.T) { + t.Parallel() - got := decisionMessage(set, api.ActionTypeAudit, value, "compiled-rule") - want := "audit:harbor/app:1:compiled-rule" + got := appendMatchContext("message", "", "", "matched rule") + if got != "message" { + t.Fatalf("appendMatchContext() = %q, want message", got) + } + }) - if got != want { - t.Fatalf("decisionMessage() = %q, want %q", got, want) - } + t.Run("describeRules skips empty descriptions", func(t *testing.T) { + t.Parallel() + + got := describeRules(set, []testRule{ + {Name: "first"}, + {Name: ""}, + {Name: "second"}, + }) + + if got != "first, second" { + t.Fatalf("describeRules() = %q, want %q", got, "first, second") + } + }) } func newTestFixture() *testFixture { return &testFixture{ - items: make(map[*api.NamespaceRuleEnforceBody][]testRule), + items: map[*api.NamespaceRuleEnforceBody][]testRule{}, } } func (f *testFixture) set( name string, - message func(action api.ActionType, value Value, matchedValue any) string, + message func(api.ActionType, Value, any) string, ) Set[testRule, testObject] { return Set[testRule, testObject]{ - Name: name, - EventReason: "TestReason", + Name: name, + EventReason: "NamespaceRuleViolation", + AllowedDescription: "Allowed registries", Values: func(obj testObject) []Value { return obj.Values }, Rules: func(enforce *api.NamespaceRuleEnforceBody) []testRule { - if enforce == nil { - return nil - } - return f.items[enforce] }, Matches: func(rule testRule, value Value) (Match, error) { @@ -1617,54 +991,91 @@ func (f *testFixture) set( return Match{}, rule.Err } + if !rule.ShouldMatch { + return Match{}, nil + } + + matchedValue := rule.MatchedValue + if matchedValue == nil { + matchedValue = rule.Name + } + return Match{ - Matched: rule.ShouldMatch, - MatchedValue: rule.MatchValue, + Matched: true, + MatchedValue: matchedValue, + Detail: rule.Detail, }, nil }, Message: message, + RuleDescription: func(rule testRule) string { + return rule.Name + }, } } -func (f *testFixture) enforce( - action api.ActionType, - rules ...testRule, -) *api.NamespaceRuleEnforceBody { - body := &api.NamespaceRuleEnforceBody{ - Action: action, - } - - f.items[body] = rules - - return body -} - func buildEnforceBodies( fixture *testFixture, specs []enforceSpec, ) []*api.NamespaceRuleEnforceBody { + if len(specs) == 0 { + return nil + } + out := make([]*api.NamespaceRuleEnforceBody, 0, len(specs)) for _, spec := range specs { - out = append(out, fixture.enforce(spec.action, spec.items...)) + enforce := &api.NamespaceRuleEnforceBody{ + Action: spec.action, + } + + fixture.items[enforce] = spec.items + out = append(out, enforce) } return out } +func assertBlockingAction(t *testing.T, evaluation *Evaluation, action api.ActionType) { + t.Helper() + + if evaluation == nil { + t.Fatalf("expected evaluation, got nil") + } + + if evaluation.Blocking == nil { + t.Fatalf("expected blocking decision, got nil") + } + + if evaluation.Blocking.Action != action { + t.Fatalf("blocking action = %q, want %q", evaluation.Blocking.Action, action) + } +} + func assertNoBlocking(t *testing.T, evaluation *Evaluation) { t.Helper() if evaluation == nil { - t.Fatalf("evaluation = nil, want non-nil") + t.Fatalf("expected evaluation, got nil") } if evaluation.Blocking != nil { - t.Fatalf("Blocking = %#v, want nil", evaluation.Blocking) + t.Fatalf("expected no blocking decision, got %#v", evaluation.Blocking) } +} - if err := evaluation.BlockingError(); err != nil { - t.Fatalf("BlockingError() = %v, want nil", err) +func assertFinalAction(t *testing.T, evaluation *Evaluation, action api.ActionType) { + t.Helper() + + if evaluation == nil { + t.Fatalf("expected evaluation, got nil") + } + + if evaluation.Final == nil { + t.Fatalf("expected final decision, got nil") + } + + if evaluation.Final.Action != action { + t.Fatalf("final action = %q, want %q", evaluation.Final.Action, action) } } @@ -1672,10 +1083,10 @@ func assertNoFinal(t *testing.T, evaluation *Evaluation) { t.Helper() if evaluation == nil { - t.Fatalf("evaluation = nil, want non-nil") + t.Fatalf("expected evaluation, got nil") } if evaluation.Final != nil { - t.Fatalf("Final = %#v, want nil", evaluation.Final) + t.Fatalf("expected no final decision, got %#v", evaluation.Final) } } diff --git a/pkg/ruleengine/validate.go b/pkg/ruleengine/validate.go new file mode 100644 index 000000000..58909363e --- /dev/null +++ b/pkg/ruleengine/validate.go @@ -0,0 +1,183 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 +package ruleengine + +import ( + "fmt" + "net" + "regexp" + "strings" + + "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/api/rules" +) + +func ValidateRuleStatusBody(bodies []*rules.NamespaceRuleBodyNamespace) error { + for i, rule := range bodies { + if rule == nil || rule.Enforce == nil { + continue + } + + if err := validateWorkloadRules(i, rule.Enforce.Workloads); err != nil { + return err + } + + if err := validateServiceRules(i, rule.Enforce.Services); err != nil { + return err + } + } + + return nil +} + +func validateWorkloadRules( + ruleIndex int, + workloads rules.NamespaceRuleEnforceWorkloadsBody, +) error { + for j, registry := range workloads.Registries { + if err := validateExpression( + registry.Expression, + fmt.Sprintf("rules[%d].enforce.workloads.registries[%d].exp", ruleIndex, j), + ); err != nil { + return err + } + } + + for j, scheduler := range workloads.Schedulers { + if err := validateExpression( + scheduler.Expression, + fmt.Sprintf("rules[%d].enforce.workloads.schedulers[%d].exp", ruleIndex, j), + ); err != nil { + return err + } + } + + return nil +} + +func validateServiceRules( + ruleIndex int, + services rules.NamespaceRuleEnforceServicesBody, +) error { + for j, serviceType := range services.Types { + if err := validateServiceType(serviceType); err != nil { + return fmt.Errorf( + "rules[%d].enforce.services.types[%d] %q is invalid: %w", + ruleIndex, + j, + serviceType, + err, + ) + } + } + + if services.LoadBalancers != nil { + for j, cidr := range services.LoadBalancers.CIDRs { + if err := validateCIDR(cidr); err != nil { + return fmt.Errorf( + "rules[%d].enforce.services.loadBalancers.cidrs[%d] %q is invalid: %w", + ruleIndex, + j, + cidr, + err, + ) + } + } + } + + if services.ExternalNames != nil { + for j, hostname := range services.ExternalNames.Hostnames { + if err := validateExpressionMatch( + hostname, + fmt.Sprintf("rules[%d].enforce.services.externalNames.hostnames[%d]", ruleIndex, j), + ); err != nil { + return err + } + } + } + + if services.NodePorts != nil { + for j, portRange := range services.NodePorts.Ports { + if err := validateNodePortRange(portRange); err != nil { + return fmt.Errorf( + "rules[%d].enforce.services.nodePorts.ports[%d] is invalid: %w", + ruleIndex, + j, + err, + ) + } + } + } + + return nil +} + +func validateExpressionMatch(match api.ExpressionMatch, fieldPath string) error { + if err := validateExpression(match.Expression, fieldPath+".exp"); err != nil { + return err + } + + return nil +} + +func validateExpression(expression string, fieldPath string) error { + if strings.TrimSpace(expression) == "" { + return nil + } + + if _, err := regexp.Compile(expression); err != nil { + return fmt.Errorf("%s %q is invalid: %w", fieldPath, expression, err) + } + + return nil +} + +func validateServiceType(serviceType rules.ServiceType) error { + switch serviceType { + case rules.ServiceTypeClusterIP, + rules.ServiceTypeNodePort, + rules.ServiceTypeLoadBalancer, + rules.ServiceTypeExternalName: + return nil + default: + return fmt.Errorf("unsupported service type") + } +} + +func validateCIDR(raw string) error { + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("CIDR is empty") + } + + if !strings.Contains(raw, "/") { + ip := net.ParseIP(raw) + if ip == nil { + return fmt.Errorf("must be a valid IP or CIDR") + } + + return nil + } + + if _, _, err := net.ParseCIDR(raw); err != nil { + return err + } + + return nil +} + +func validateNodePortRange(portRange rules.ServiceNodePortRange) error { + if portRange.From < 1 || portRange.From > 65535 { + return fmt.Errorf("from %d must be between 1 and 65535", portRange.From) + } + + if portRange.To < 1 || portRange.To > 65535 { + return fmt.Errorf("to %d must be between 1 and 65535", portRange.To) + } + + if portRange.From > portRange.To { + return fmt.Errorf("from %d must be lower than or equal to %d", portRange.From, portRange.To) + } + + return nil +} diff --git a/pkg/ruleengine/validate_test.go b/pkg/ruleengine/validate_test.go new file mode 100644 index 000000000..e39b9a815 --- /dev/null +++ b/pkg/ruleengine/validate_test.go @@ -0,0 +1,360 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 +package ruleengine + +import ( + "strings" + "testing" + + "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/api/rules" +) + +func TestValidateRuleStatusBody(t *testing.T) { + tests := []struct { + name string + bodies []*rules.NamespaceRuleBodyNamespace + wantErr string + }{ + { + name: "nil bodies are valid", + bodies: []*rules.NamespaceRuleBodyNamespace{ + nil, + {}, + { + Enforce: nil, + }, + }, + }, + { + name: "valid workload and service rules", + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeAllow, + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Registries: []rules.OCIRegistry{ + { + ExpressionMatch: api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: "harbor/.*", + }, + }, + }, + { + ExpressionMatch: api.ExpressionMatch{ + Exact: []string{ + "harbor/platform/debian:latest", + }, + }, + }, + }, + Schedulers: []api.ExpressionMatch{ + { + ExpressionRegex: api.ExpressionRegex{ + Expression: "tenant-[a-z0-9-]+", + }, + }, + }, + }, + Services: rules.NamespaceRuleEnforceServicesBody{ + Types: []rules.ServiceType{ + rules.ServiceTypeClusterIP, + rules.ServiceTypeNodePort, + rules.ServiceTypeLoadBalancer, + rules.ServiceTypeExternalName, + }, + LoadBalancers: &rules.ServiceLoadBalancerRule{ + CIDRs: []string{ + "10.0.0.2/32", + "10.0.1.0/24", + "2001:db8::/32", + "10.0.0.3", + }, + }, + ExternalNames: &rules.ServiceExternalNameRule{ + Hostnames: []api.ExpressionMatch{ + { + Exact: []string{ + "internal.git.com", + }, + }, + { + ExpressionRegex: api.ExpressionRegex{ + Expression: ".*\\.example\\.com", + }, + }, + { + ExpressionRegex: api.ExpressionRegex{ + Expression: "trusted\\..*", + Negate: true, + }, + }, + }, + }, + NodePorts: &rules.ServiceNodePortRule{ + Ports: []rules.ServiceNodePortRange{ + { + From: 30000, + To: 32767, + }, + { + From: 30500, + To: 30500, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "invalid workload registry regex", + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Registries: []rules.OCIRegistry{ + { + ExpressionMatch: api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: "[", + }, + }, + }, + }, + }, + }, + }, + }, + wantErr: `rules[0].enforce.workloads.registries[0].exp "[" is invalid`, + }, + { + name: "invalid workload scheduler regex", + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Schedulers: []api.ExpressionMatch{ + { + ExpressionRegex: api.ExpressionRegex{ + Expression: "[", + }, + }, + }, + }, + }, + }, + }, + wantErr: `rules[0].enforce.workloads.schedulers[0].exp "[" is invalid`, + }, + { + name: "invalid service type", + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Services: rules.NamespaceRuleEnforceServicesBody{ + Types: []rules.ServiceType{ + rules.ServiceTypeClusterIP, + rules.ServiceType("InvalidType"), + }, + }, + }, + }, + }, + wantErr: `rules[0].enforce.services.types[1] "InvalidType" is invalid`, + }, + { + name: "invalid loadBalancer CIDR", + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Services: rules.NamespaceRuleEnforceServicesBody{ + LoadBalancers: &rules.ServiceLoadBalancerRule{ + CIDRs: []string{ + "10.0.0.0/33", + }, + }, + }, + }, + }, + }, + wantErr: `rules[0].enforce.services.loadBalancers.cidrs[0] "10.0.0.0/33" is invalid`, + }, + { + name: "empty loadBalancer CIDR", + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Services: rules.NamespaceRuleEnforceServicesBody{ + LoadBalancers: &rules.ServiceLoadBalancerRule{ + CIDRs: []string{ + "", + }, + }, + }, + }, + }, + }, + wantErr: `rules[0].enforce.services.loadBalancers.cidrs[0] "" is invalid: CIDR is empty`, + }, + { + name: "invalid externalName hostname regex", + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Services: rules.NamespaceRuleEnforceServicesBody{ + ExternalNames: &rules.ServiceExternalNameRule{ + Hostnames: []api.ExpressionMatch{ + { + ExpressionRegex: api.ExpressionRegex{ + Expression: "[", + }, + }, + }, + }, + }, + }, + }, + }, + wantErr: `rules[0].enforce.services.externalNames.hostnames[0].exp "[" is invalid`, + }, + { + name: "nodePort from greater than to", + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Services: rules.NamespaceRuleEnforceServicesBody{ + NodePorts: &rules.ServiceNodePortRule{ + Ports: []rules.ServiceNodePortRange{ + { + From: 32767, + To: 30000, + }, + }, + }, + }, + }, + }, + }, + wantErr: `rules[0].enforce.services.nodePorts.ports[0] is invalid: from 32767 must be lower than or equal to 30000`, + }, + { + name: "nodePort from below valid port range", + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Services: rules.NamespaceRuleEnforceServicesBody{ + NodePorts: &rules.ServiceNodePortRule{ + Ports: []rules.ServiceNodePortRange{ + { + From: 0, + To: 30000, + }, + }, + }, + }, + }, + }, + }, + wantErr: `rules[0].enforce.services.nodePorts.ports[0] is invalid: from 0 must be between 1 and 65535`, + }, + { + name: "nodePort to above valid port range", + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Services: rules.NamespaceRuleEnforceServicesBody{ + NodePorts: &rules.ServiceNodePortRule{ + Ports: []rules.ServiceNodePortRange{ + { + From: 30000, + To: 70000, + }, + }, + }, + }, + }, + }, + }, + wantErr: `rules[0].enforce.services.nodePorts.ports[0] is invalid: to 70000 must be between 1 and 65535`, + }, + { + name: "single nodePort range is valid", + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Services: rules.NamespaceRuleEnforceServicesBody{ + NodePorts: &rules.ServiceNodePortRule{ + Ports: []rules.ServiceNodePortRange{ + { + From: 30500, + To: 30500, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "reports correct indexes across multiple rules", + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Services: rules.NamespaceRuleEnforceServicesBody{ + Types: []rules.ServiceType{ + rules.ServiceTypeClusterIP, + }, + }, + }, + }, + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Services: rules.NamespaceRuleEnforceServicesBody{ + ExternalNames: &rules.ServiceExternalNameRule{ + Hostnames: []api.ExpressionMatch{ + { + ExpressionRegex: api.ExpressionRegex{ + Expression: "valid\\..*", + }, + }, + { + ExpressionRegex: api.ExpressionRegex{ + Expression: "[", + }, + }, + }, + }, + }, + }, + }, + }, + wantErr: `rules[1].enforce.services.externalNames.hostnames[1].exp "[" is invalid`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateRuleStatusBody(tt.bodies) + + if tt.wantErr == "" { + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + return + } + + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + }) + } +} diff --git a/pkg/runtime/events/reasons.go b/pkg/runtime/events/reasons.go index 79fe344e0..d6c61d638 100644 --- a/pkg/runtime/events/reasons.go +++ b/pkg/runtime/events/reasons.go @@ -16,7 +16,6 @@ const ( // RuleStatus. ReasonNamespaceRuleAudit string = "NamespaceRuleAudit" - // Namespace. ReasonNamespaceHijack string = "ReasonNamespacePatch" @@ -59,6 +58,8 @@ const ( ReasonForbiddenLoadBalancer string = "ForbiddenLoadBalancer" ReasonForbiddenExternalName string = "ForbiddenExternalName" ReasonForbiddenNodePort string = "ForbiddenNodePort" + ReasonForbiddenServiceType string = "ForbiddenServiceType" + ReasonForbiddenLoadBalancerCIDR string = "ForbiddenLoadBalancerCIDR" // Storage. ReasonCrossTenantReference string = "CrossTenantReference"