Enforcing container registry via list or regex (#142)

Adding also NamespaceSelector to specific webhooks in order to decrease
the chance ov breaking other critical Namespaces in case of Capsule
failures.
This commit is contained in:
Dario Tranchitella
2020-11-24 00:40:40 +01:00
committed by GitHub
parent 8442eef72b
commit 5aed7a01d5
14 changed files with 546 additions and 5 deletions

View File

@@ -0,0 +1,83 @@
/*
Copyright 2020 Clastix Labs.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package domain
import (
"regexp"
)
type registry map[string]string
func (r registry) Registry() string {
res, ok := r["registry"]
if !ok {
return ""
}
if len(res) == 0 {
return "docker.io"
}
return res
}
func (r registry) Repository() string {
res, ok := r["repository"]
if !ok {
return ""
}
if res == "docker.io" {
return ""
}
return res
}
func (r registry) Image() string {
res, ok := r["image"]
if !ok {
return ""
}
return res
}
func (r registry) Tag() string {
res, ok := r["tag"]
if !ok {
return ""
}
if len(res) == 0 {
res = "latest"
}
return res
}
func NewRegistry(value string) Registry {
registry := make(registry)
r := regexp.MustCompile(`(((?P<registry>[a-zA-Z0-9-.]+)\/)?((?P<repository>[a-zA-Z0-9-.]+)\/))?(?P<image>[a-zA-Z0-9-.]+)(:(?P<tag>[a-zA-Z0-9-.]+))?`)
match := r.FindStringSubmatch(value)
for i, name := range r.SubexpNames() {
if i > 0 && i <= len(match) {
registry[name] = match[i]
}
}
return registry
}
type Registry interface {
Registry() string
Repository() string
Image() string
Tag() string
}

View File

@@ -0,0 +1,78 @@
/*
Copyright 2020 Clastix Labs.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package domain
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewRegistry(t *testing.T) {
type tc struct {
registry string
repo string
image string
tag string
}
for name, tc := range map[string]tc{
"docker.io/my-org/my-repo:v0.0.1": {
registry: "docker.io",
repo: "my-org",
image: "my-repo",
tag: "v0.0.1",
},
"unnamed/repository:1.2.3": {
registry: "docker.io",
repo: "unnamed",
image: "repository",
tag: "1.2.3",
},
"quay.io/clastix/capsule:v1.0.0": {
registry: "quay.io",
repo: "clastix",
image: "capsule",
tag: "v1.0.0",
},
"docker.io/redis:alpine": {
registry: "docker.io",
repo: "",
image: "redis",
tag: "alpine",
},
"nginx:alpine": {
registry: "docker.io",
repo: "",
image: "nginx",
tag: "alpine",
},
"nginx": {
registry: "docker.io",
repo: "",
image: "nginx",
tag: "latest",
},
} {
t.Run(name, func(t *testing.T) {
r := NewRegistry(name)
assert.Equal(t, tc.registry, r.Registry())
assert.Equal(t, tc.repo, r.Repository())
assert.Equal(t, tc.image, r.Image())
assert.Equal(t, tc.tag, r.Tag())
})
}
}

View File

@@ -0,0 +1,43 @@
/*
Copyright 2020 Clastix Labs.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package v1alpha1
import (
"sort"
"strings"
)
type RegistryList []string
func (in RegistryList) Len() int {
return len(in)
}
func (in RegistryList) Swap(i, j int) {
in[i], in[j] = in[j], in[i]
}
func (in RegistryList) Less(i, j int) bool {
return strings.ToLower(in[i]) < strings.ToLower(in[j])
}
func (in RegistryList) IsStringInList(value string) (ok bool) {
sort.Sort(in)
i := sort.SearchStrings(in, value)
ok = i < in.Len() && in[i] == value
return
}

View File

@@ -47,15 +47,23 @@ type IngressClassesSpec struct {
AllowedRegex string `json:"allowedRegex"`
}
type ContainerRegistriesSpec struct {
// +nullable
Allowed RegistryList `json:"allowed"`
// +nullable
AllowedRegex string `json:"allowedRegex"`
}
// TenantSpec defines the desired state of Tenant
type TenantSpec struct {
Owner OwnerSpec `json:"owner"`
// +kubebuilder:validation:Optional
NamespacesMetadata AdditionalMetadata `json:"namespacesMetadata"`
// +kubebuilder:validation:Optional
ServicesMetadata AdditionalMetadata `json:"servicesMetadata"`
StorageClasses StorageClassesSpec `json:"storageClasses"`
IngressClasses IngressClassesSpec `json:"ingressClasses"`
ServicesMetadata AdditionalMetadata `json:"servicesMetadata"`
StorageClasses StorageClassesSpec `json:"storageClasses"`
IngressClasses IngressClassesSpec `json:"ingressClasses"`
ContainerRegistries *ContainerRegistriesSpec `json:"containerRegistries,omitempty"`
// +kubebuilder:validation:Optional
NodeSelector map[string]string `json:"nodeSelector"`
NamespaceQuota NamespaceQuota `json:"namespaceQuota"`

View File

@@ -76,6 +76,26 @@ func (in *AdditionalRoleBindings) DeepCopy() *AdditionalRoleBindings {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ContainerRegistriesSpec) DeepCopyInto(out *ContainerRegistriesSpec) {
*out = *in
if in.Allowed != nil {
in, out := &in.Allowed, &out.Allowed
*out = make(RegistryList, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ContainerRegistriesSpec.
func (in *ContainerRegistriesSpec) DeepCopy() *ContainerRegistriesSpec {
if in == nil {
return nil
}
out := new(ContainerRegistriesSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in IngressClassList) DeepCopyInto(out *IngressClassList) {
{
@@ -149,6 +169,25 @@ func (in *OwnerSpec) DeepCopy() *OwnerSpec {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in RegistryList) DeepCopyInto(out *RegistryList) {
{
in := &in
*out = make(RegistryList, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegistryList.
func (in RegistryList) DeepCopy() RegistryList {
if in == nil {
return nil
}
out := new(RegistryList)
in.DeepCopyInto(out)
return *out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in StorageClassList) DeepCopyInto(out *StorageClassList) {
{
@@ -255,6 +294,11 @@ func (in *TenantSpec) DeepCopyInto(out *TenantSpec) {
in.ServicesMetadata.DeepCopyInto(&out.ServicesMetadata)
in.StorageClasses.DeepCopyInto(&out.StorageClasses)
in.IngressClasses.DeepCopyInto(&out.IngressClasses)
if in.ContainerRegistries != nil {
in, out := &in.ContainerRegistries, &out.ContainerRegistries
*out = new(ContainerRegistriesSpec)
(*in).DeepCopyInto(*out)
}
if in.NodeSelector != nil {
in, out := &in.NodeSelector, &out.NodeSelector
*out = make(map[string]string, len(*in))

View File

@@ -106,6 +106,20 @@ spec:
- subjects
type: object
type: array
containerRegistries:
properties:
allowed:
items:
type: string
nullable: true
type: array
allowedRegex:
nullable: true
type: string
required:
- allowed
- allowedRegex
type: object
ingressClasses:
properties:
allowed:

View File

@@ -90,3 +90,7 @@ spec:
allowed:
- default
allowedRegex: ""
containerRegistries:
allowed:
- docker.io
allowedRegex: ""

View File

@@ -2,5 +2,13 @@ resources:
- manifests.yaml
- service.yaml
patchesJson6902:
- target:
group: admissionregistration.k8s.io
kind: ValidatingWebhookConfiguration
name: validating-webhook-configuration
version: v1beta1
path: patch_ns_selector.yaml
configurations:
- kustomizeconfig.yaml

View File

@@ -121,6 +121,23 @@ webhooks:
- CREATE
resources:
- persistentvolumeclaims
- clientConfig:
caBundle: Cg==
service:
name: webhook-service
namespace: system
path: /validating-v1-registry
failurePolicy: Ignore
name: pod.capsule.clastix.io
rules:
- apiGroups:
- ""
apiVersions:
- v1
operations:
- CREATE
resources:
- pods
- clientConfig:
caBundle: Cg==
service:

View File

@@ -0,0 +1,30 @@
- op: add
path: /webhooks/0/namespaceSelector
value:
matchExpressions:
- key: capsule.clastix.io/tenant
operator: Exists
- op: add
path: /webhooks/1/namespaceSelector
value:
matchExpressions:
- key: capsule.clastix.io/tenant
operator: Exists
- op: add
path: /webhooks/3/namespaceSelector
value:
matchExpressions:
- key: capsule.clastix.io/tenant
operator: Exists
- op: add
path: /webhooks/4/namespaceSelector
value:
matchExpressions:
- key: capsule.clastix.io/tenant
operator: Exists
- op: add
path: /webhooks/5/namespaceSelector
value:
matchExpressions:
- key: capsule.clastix.io/tenant
operator: Exists

View File

@@ -44,6 +44,7 @@ import (
"github.com/clastix/capsule/pkg/webhook/network_policies"
"github.com/clastix/capsule/pkg/webhook/owner_reference"
"github.com/clastix/capsule/pkg/webhook/pvc"
"github.com/clastix/capsule/pkg/webhook/registry"
"github.com/clastix/capsule/pkg/webhook/tenant"
"github.com/clastix/capsule/pkg/webhook/tenant_prefix"
"github.com/clastix/capsule/pkg/webhook/utils"
@@ -152,11 +153,12 @@ func main() {
}
// +kubebuilder:scaffold:builder
// webhooks
// webhooks: the order matters, don't change it and just append
wl := append(
make([]webhook.Webhook, 0),
ingress.Webhook(ingress.Handler()),
pvc.Webhook(pvc.Handler()),
registry.Webhook(registry.Handler()),
owner_reference.Webhook(utils.InCapsuleGroup(capsuleGroup, owner_reference.Handler(forceTenantPrefix))),
namespace_quota.Webhook(utils.InCapsuleGroup(capsuleGroup, namespace_quota.Handler())),
network_policies.Webhook(utils.InCapsuleGroup(capsuleGroup, network_policies.Handler())),

View File

@@ -0,0 +1,49 @@
/*
Copyright 2020 Clastix Labs.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package registry
import (
"fmt"
"strings"
"github.com/clastix/capsule/api/v1alpha1"
)
type registryClassForbidden struct {
fqdi string
spec v1alpha1.ContainerRegistriesSpec
}
func NewContainerRegistryForbidden(image string, spec v1alpha1.ContainerRegistriesSpec) error {
return &registryClassForbidden{
fqdi: image,
spec: spec,
}
}
func (f registryClassForbidden) Error() (err string) {
err = fmt.Sprintf("Container image %s registry is forbidden for the current Tenant: ", f.fqdi)
var extra []string
if len(f.spec.Allowed) > 0 {
extra = append(extra, fmt.Sprintf("use one from the following list (%s)", strings.Join(f.spec.Allowed, ", ")))
}
if len(f.spec.AllowedRegex) > 0 {
extra = append(extra, fmt.Sprintf(" use one matching the following regex (%s)", f.spec.AllowedRegex))
}
err += strings.Join(extra, " or ")
return
}

View File

@@ -0,0 +1,111 @@
/*
Copyright 2020 Clastix Labs.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package registry
import (
"context"
"net/http"
"regexp"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/fields"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1alpha1 "github.com/clastix/capsule/api/v1alpha1"
"github.com/clastix/capsule/api/v1alpha1/domain"
capsulewebhook "github.com/clastix/capsule/pkg/webhook"
)
// +kubebuilder:webhook:path=/validating-v1-registry,mutating=false,failurePolicy=ignore,groups="",resources=pods,verbs=create,versions=v1,name=pod.capsule.clastix.io
type webhook struct {
handler capsulewebhook.Handler
}
func Webhook(handler capsulewebhook.Handler) capsulewebhook.Webhook {
return &webhook{handler: handler}
}
func (w *webhook) GetName() string {
return "registry"
}
func (w *webhook) GetPath() string {
return "/validating-v1-registry"
}
func (w *webhook) GetHandler() capsulewebhook.Handler {
return w.handler
}
type handler struct {
}
func Handler() capsulewebhook.Handler {
return &handler{}
}
func (h *handler) OnCreate(c client.Client, decoder *admission.Decoder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) admission.Response {
pod := &v1.Pod{}
if err := decoder.Decode(req, pod); err != nil {
return admission.Errored(http.StatusBadRequest, err)
}
tl := &capsulev1alpha1.TenantList{}
if err := c.List(ctx, tl, client.MatchingFieldsSelector{
Selector: fields.OneTermEqualSelector(".status.namespaces", pod.Namespace),
}); err != nil {
return admission.Errored(http.StatusBadRequest, err)
}
if len(tl.Items) == 0 {
return admission.Allowed("")
}
tnt := tl.Items[0]
if tnt.Spec.ContainerRegistries != nil {
var valid, matched bool
regex := regexp.MustCompile(tnt.Spec.ContainerRegistries.AllowedRegex)
for _, container := range pod.Spec.Containers {
r := domain.NewRegistry(container.Image)
valid = tnt.Spec.ContainerRegistries.Allowed.IsStringInList(r.Registry())
if len(tnt.Spec.ContainerRegistries.AllowedRegex) > 0 {
matched = regex.MatchString(r.Registry())
}
if !valid && !matched {
return admission.Errored(http.StatusBadRequest, NewContainerRegistryForbidden(container.Image, *tnt.Spec.ContainerRegistries))
}
}
}
return admission.Allowed("")
}
}
func (h *handler) OnDelete(client client.Client, decoder *admission.Decoder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) admission.Response {
return admission.Allowed("")
}
}
func (h *handler) OnUpdate(client client.Client, decoder *admission.Decoder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) admission.Response {
return admission.Allowed("")
}
}

View File

@@ -33,7 +33,7 @@ Acme Corp. can use Capsule to address the following scenarios:
* [Control the Ingress selector in the tenant](#control-the-ingress-selector-in-the-tenant)
* [Assign Storage classes in the tenant](#assign-storage-classes-in-the-tenant)
* [Set network policies in the tenant](#set-network-policies-in-the-tenant)
* [Enforce Pod running images provided by a set of trusted registries](#enforce-pod-running-images-provided-by-a-set-of-trusted-registries)
### Onboarding of a new customer
Bill receives a new request from the CaaS onboarding system that a new
@@ -963,3 +963,53 @@ the given subjects.
> With the following example, Capsule is forbidding to any authenticated user
> to run privileged pods and let them to performs privilege escalation as
> declared by the Cluster Role `psp:privileged`.
# Enforce Pod running images provided by a set of trusted registries
Let's say you have a strict policy on the ownership running in a certain
Tenant: you'd like to allow running just images hosted on a list of specific
container registries.
The spec `containerRegistries` addresses this task and can provide combination
with hard enforcement using a list of allowed values as a valid regular
expression for a maximum flexibility.
## Allowing using a regex
This can be useful if you want to allow run images from any registry in your
organization or for any other particular use case.
```yaml
apiVersion: capsule.clastix.io/v1alpha1
kind: Tenant
spec:
containerRegistries:
allowed: []
regex: "internal.registry.\\w.tld"
```
A Pod running `internal.registry.foo.tld` as registry will be allowed, as well
`internal.registry.bar.tld` since these are matching the regular expression.
> You can also set a catch-all as .* to allow every kind of registry,
> that would be the same result of unsetting `containerRegistries` at all
## Allowing from a list of registries
For more strict requirements, you can specify an array of string.
```yaml
apiVersion: capsule.clastix.io/v1alpha1
kind: Tenant
spec:
containerRegistries:
allowed:
- docker.io
- quay.io
regex: ""
```
> In case of naked and official images hosted on Docker Hub, Capsule is going
> to retrieve the registry even if it's not explicit: a `busybox:latest` Pod
> running on a Tenant allowing `docker.io` will not blocked, even if the image
> field is not explicit as `docker.io/busybox:latest`.