diff --git a/README.md b/README.md index fec22191cc1..9d72d9631a6 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,7 @@ Note the `match` field, which defines the scope of objects to which a given cons * `kinds` accepts a list of objects with `apiGroups` and `kinds` fields that list the groups/kinds of objects to which the constraint will apply. If multiple groups/kinds objects are specified, only one match is needed for the resource to be in scope. * `namespaces` is a list of namespace names. If defined, a constraint will only apply to resources in a listed namespace. * `labelSelector` is a standard Kubernetes label selector. + * `namespaceSelector` is a standard Kubernetes namespace selector. If defined, make sure to add `Namespaces` to your `configs.config.gatekeeper.sh` object to ensure namespaces are synced into OPA. Refer to the [Replicating Data section](#replicating-data) for more details. Note that if multiple matchers are specified, a resource must satisfy each top-level matcher (`kinds`, `namespaces`, etc.) to be in scope. Each top-level matcher has its own semantics for what qualifies as a match. An empty matcher is deemed to be inclusive (matches everything). diff --git a/example/constraints/all_pod_must_have_gatekeeper_namespaceselector.yaml b/example/constraints/all_pod_must_have_gatekeeper_namespaceselector.yaml new file mode 100644 index 00000000000..9be92e4785d --- /dev/null +++ b/example/constraints/all_pod_must_have_gatekeeper_namespaceselector.yaml @@ -0,0 +1,16 @@ +apiVersion: constraints.gatekeeper.sh/v1alpha1 +kind: K8sRequiredLabels +metadata: + name: pod-must-have-gk +spec: + match: + kinds: + - apiGroups: [""] + kinds: ["Pod"] + namespaceSelector: + matchExpressions: + - key: workloadtype + operator: In + values: [prodworkload] + parameters: + labels: ["gatekeeper"] diff --git a/example/resources/bad_ns_namespaceselector.yaml b/example/resources/bad_ns_namespaceselector.yaml new file mode 100644 index 00000000000..ebecdcd6690 --- /dev/null +++ b/example/resources/bad_ns_namespaceselector.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: bad-prod-ns + labels: + workloadtype: prodworkload diff --git a/example/resources/bad_pod_namespaceselector.yaml b/example/resources/bad_pod_namespaceselector.yaml new file mode 100644 index 00000000000..7163f081325 --- /dev/null +++ b/example/resources/bad_pod_namespaceselector.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Pod +metadata: + name: opa + namespace: bad-prod-ns +spec: + containers: + - name: opa + image: openpolicyagent/opa:0.9.2 + args: + - "run" + - "--server" + - "--addr=localhost:8080" diff --git a/example/templates/k8srequiredlabels_template.yaml b/example/templates/k8srequiredlabels_template.yaml new file mode 100644 index 00000000000..e4af1f70313 --- /dev/null +++ b/example/templates/k8srequiredlabels_template.yaml @@ -0,0 +1,31 @@ +apiVersion: templates.gatekeeper.sh/v1alpha1 +kind: ConstraintTemplate +metadata: + name: k8srequiredlabels +spec: + crd: + spec: + names: + kind: K8sRequiredLabels + listKind: K8sRequiredLabelsList + plural: k8srequiredlabels + singular: k8srequiredlabels + validation: + # Schema for the `parameters` field + openAPIV3Schema: + properties: + labels: + type: array + items: string + targets: + - target: admission.k8s.gatekeeper.sh + rego: | + package k8srequiredlabels + + deny[{"msg": msg, "details": {"missing_labels": missing}}] { + provided := {label | input.review.object.metadata.labels[label]} + required := {label | label := input.constraint.spec.parameters.labels[_]} + missing := required - provided + count(missing) > 0 + msg := sprintf("you must provide labels: %v", [missing]) + } diff --git a/pkg/target/regolib/src.rego b/pkg/target/regolib/src.rego index fb367580b79..4d1438d961d 100644 --- a/pkg/target/regolib/src.rego +++ b/pkg/target/regolib/src.rego @@ -13,6 +13,8 @@ matching_constraints[constraint] { matches_namespaces(match) + matches_nsselector(match) + label_selector := get_default(match, "labelSelector", {}) obj := get_default(input.review, "object", {}) metadata := get_default(obj, "metadata", {}) @@ -192,4 +194,23 @@ matches_namespaces(match) { has_field(match, "namespaces") ns := {n | n = match.namespaces[_]} count({input.review.namespace} - ns) == 0 -} \ No newline at end of file +} + +matches_nsselector(match) { + not has_field(match, "namespaceSelector") +} + +matches_nsselector(match) { + has_field(match, "namespaceSelector") + ns := data["{{.DataRoot}}"].cluster["v1"]["Namespace"][input.review.namespace] + matches_namespace_selector(match, ns) +} + +# Checks to see if a kubernetes NamespaceSelector matches a namespace with a given set of labels +# A non-existent selector or labels should be represented by an empty object ("{}") +matches_namespace_selector(match, ns) { + metadata := get_default(ns, "metadata", {}) + nslabels := get_default(metadata, "labels", {}) + namespace_selector := get_default(match, "namespaceSelector", {}) + matches_label_selector(namespace_selector, nslabels) +} diff --git a/pkg/target/target.go b/pkg/target/target.go index 4148d552975..f3b84ce437f 100644 --- a/pkg/target/target.go +++ b/pkg/target/target.go @@ -43,6 +43,8 @@ matching_constraints[constraint] { matches_namespaces(match) + matches_nsselector(match) + label_selector := get_default(match, "labelSelector", {}) obj := get_default(input.review, "object", {}) metadata := get_default(obj, "metadata", {}) @@ -221,6 +223,24 @@ matches_namespaces(match) { ns := {n | n = match.namespaces[_]} count({input.review.namespace} - ns) == 0 } + +matches_nsselector(match) { + not has_field(match, "namespaceSelector") +} + +matches_nsselector(match) { + has_field(match, "namespaceSelector") + ns := {{.DataRoot}}.cluster["v1"]["Namespace"][input.review.namespace] + matches_namespace_selector(match, ns) +} + +matches_namespace_selector(match, ns) { + metadata := get_default(ns, "metadata", {}) + nslabels := get_default(metadata, "labels", {}) + namespace_selector := get_default(match, "namespaceSelector", {}) + matches_label_selector(namespace_selector, nslabels) +} + ` var libTempl = template.Must(template.New("library").Parse(templSrc)) @@ -390,6 +410,41 @@ func (h *K8sValidationTarget) MatchSchema() apiextensionsv1beta1.JSONSchemaProps }, }, }, + "namespaceSelector": apiextensionsv1beta1.JSONSchemaProps{ + Properties: map[string]apiextensionsv1beta1.JSONSchemaProps{ + // Map schema validation will only work in kubernetes versions > 1.10. See https://github.com/kubernetes/kubernetes/pull/62333 + //"matchLabels": apiextensionsv1beta1.JSONSchemaProps{ + // AdditionalProperties: &apiextensionsv1beta1.JSONSchemaPropsOrBool{ + // Allows: true, + // Schema: &apiextensionsv1beta1.JSONSchemaProps{Type: "string"}, + // }, + //}, + "matchExpressions": apiextensionsv1beta1.JSONSchemaProps{ + Type: "array", + Items: &apiextensionsv1beta1.JSONSchemaPropsOrArray{ + Schema: &apiextensionsv1beta1.JSONSchemaProps{ + Properties: map[string]apiextensionsv1beta1.JSONSchemaProps{ + "key": apiextensionsv1beta1.JSONSchemaProps{Type: "string"}, + "operator": apiextensionsv1beta1.JSONSchemaProps{ + Type: "string", + Enum: []apiextensionsv1beta1.JSON{ + apiextensionsv1beta1.JSON{Raw: []byte(`"In"`)}, + apiextensionsv1beta1.JSON{Raw: []byte(`"NotIn"`)}, + apiextensionsv1beta1.JSON{Raw: []byte(`"Exists"`)}, + apiextensionsv1beta1.JSON{Raw: []byte(`"DoesNotExist"`)}, + }}, + "values": apiextensionsv1beta1.JSONSchemaProps{ + Type: "array", + Items: &apiextensionsv1beta1.JSONSchemaPropsOrArray{ + Schema: &apiextensionsv1beta1.JSONSchemaProps{Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + }, }, } } @@ -399,10 +454,8 @@ func (h *K8sValidationTarget) ValidateConstraint(u *unstructured.Unstructured) e if err != nil { return err } - if !found { - return nil - } - if labelSelector != nil { + + if found && labelSelector != nil { labelSelectorObj, err := convertToLabelSelector(labelSelector) if err != nil { return err @@ -412,6 +465,22 @@ func (h *K8sValidationTarget) ValidateConstraint(u *unstructured.Unstructured) e return errorList.ToAggregate() } } + + namespaceSelector, found, err := unstructured.NestedMap(u.Object, "spec", "match", "namespaceSelector") + if err != nil { + return err + } + + if found && namespaceSelector != nil { + namespaceSelectorObj, err := convertToLabelSelector(namespaceSelector) + if err != nil { + return err + } + errorList := validation.ValidateLabelSelector(namespaceSelectorObj, field.NewPath("spec", "labelSelector")) + if len(errorList) > 0 { + return errorList.ToAggregate() + } + } return nil } diff --git a/pkg/target/target_test.go b/pkg/target/target_test.go index 24fcfd901c8..fa1be3096a6 100644 --- a/pkg/target/target_test.go +++ b/pkg/target/target_test.go @@ -121,6 +121,102 @@ func TestValidateConstraint(t *testing.T) { } } } +`, + ErrorExpected: true, + }, + { + Name: "No NamespaceSelector", + Constraint: ` +{ + "apiVersion": "constraints.gatekeeper.sh/v1alpha1", + "kind": "K8sAllowedRepos", + "metadata": { + "name": "prod-nslabels-is-openpolicyagent" + }, + "spec": { + "match": { + "kinds": [ + { + "apiGroups": [""], + "kinds": ["Pod"] + }], + "labelSelector": { + "matchExpressions": [{ + "key": "someKey", + "operator": "In", + "values": ["some value"] + }] + } + }, + "parameters": { + "repos": ["openpolicyagent"] + } + } +} +`, + ErrorExpected: false, + }, + { + Name: "Valid NamespaceSelector", + Constraint: ` +{ + "apiVersion": "constraints.gatekeeper.sh/v1alpha1", + "kind": "K8sAllowedRepos", + "metadata": { + "name": "prod-nslabels-is-openpolicyagent" + }, + "spec": { + "match": { + "kinds": [ + { + "apiGroups": [""], + "kinds": ["Pod"] + }], + "namespaceSelector": { + "matchExpressions": [{ + "key": "someKey", + "operator": "In", + "values": ["some value"] + }] + } + }, + "parameters": { + "repos": ["openpolicyagent"] + } + } +} +`, + ErrorExpected: false, + }, + { + Name: "Invalid NamespaceSelector", + Constraint: ` +{ + "apiVersion": "constraints.gatekeeper.sh/v1alpha1", + "kind": "K8sAllowedRepos", + "metadata": { + "name": "prod-nslabels-is-openpolicyagent" + }, + "spec": { + "match": { + "kinds": [ + { + "apiGroups": [""], + "kinds": ["Pod"] + }], + "namespaceSelector": { + "matchExpressions": [{ + "key": "someKey", + "operator": "Blah", + "values": ["some value"] + }] + } + }, + "parameters": { + "repos": ["openpolicyagent"] + } + } +} `, ErrorExpected: true, }, diff --git a/pkg/webhook/policy_test.go b/pkg/webhook/policy_test.go index 47dbdfd7c21..be1b64131ca 100644 --- a/pkg/webhook/policy_test.go +++ b/pkg/webhook/policy_test.go @@ -95,6 +95,39 @@ spec: key: "something" values: ["anything"] ` + + bad_namespaceselector = ` +apiVersion: constraints.gatekeeper.sh/v1alpha1 +kind: K8sGoodRego +metadata: + name: bad-namespaceselector +spec: + match: + kinds: + - apiGroups: [""] + kinds: ["Pod"] + namespaceSelector: + matchExpressions: + - operator: "In" + key: "something" +` + + good_namespaceselector = ` +apiVersion: constraints.gatekeeper.sh/v1alpha1 +kind: K8sGoodRego +metadata: + name: good-namespaceselector +spec: + match: + kinds: + - apiGroups: [""] + kinds: ["Pod"] + namespaceSelector: + matchExpressions: + - operator: "In" + key: "something" + values: ["anything"] +` ) func makeOpaClient() (client.Client, error) { @@ -170,17 +203,29 @@ func TestConstraintValidation(t *testing.T) { ErrorExpected bool }{ { - Name: "Valid Constraint", + Name: "Valid Constraint labelselector", Template: good_rego_template, Constraint: good_labelselector, ErrorExpected: false, }, { - Name: "Invalid Constraint", + Name: "Invalid Constraint labelselector", Template: good_rego_template, Constraint: bad_labelselector, ErrorExpected: true, }, + { + Name: "Valid Constraint namespaceselector", + Template: good_rego_template, + Constraint: good_namespaceselector, + ErrorExpected: false, + }, + { + Name: "Invalid Constraint namespaceselector", + Template: good_rego_template, + Constraint: bad_namespaceselector, + ErrorExpected: true, + }, } for _, tt := range tc { t.Run(tt.Name, func(t *testing.T) {