Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow disabling cross-namespace references to image repositories #228

Merged
merged 3 commits into from
Jan 29, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/fluxcd/image-reflector-controller/api
go 1.17

require (
github.com/fluxcd/pkg/apis/acl v0.0.3
github.com/fluxcd/pkg/apis/meta v0.10.2
k8s.io/apimachinery v0.23.1
sigs.k8s.io/controller-runtime v0.11.0
Expand Down
2 changes: 2 additions & 0 deletions api/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMi
github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fluxcd/pkg/apis/acl v0.0.3 h1:Lw0ZHdpnO4G7Zy9KjrzwwBmDZQuy4qEjaU/RvA6k1lc=
github.com/fluxcd/pkg/apis/acl v0.0.3/go.mod h1:XPts6lRJ9C9fIF9xVWofmQwftvhY25n1ps7W9xw0XLU=
github.com/fluxcd/pkg/apis/meta v0.10.2 h1:pnDBBEvfs4HaKiVAYgz+e/AQ8dLvcgmVfSeBroZ/KKI=
github.com/fluxcd/pkg/apis/meta v0.10.2/go.mod h1:KQ2er9xa6koy7uoPMZjIjNudB5p4tXs+w0GO6fRcy7I=
github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
Expand Down
11 changes: 2 additions & 9 deletions api/v1beta1/imagerepository_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

"github.com/fluxcd/pkg/apis/acl"
"github.com/fluxcd/pkg/apis/meta"
)

Expand Down Expand Up @@ -71,15 +72,7 @@ type ImageRepositorySpec struct {
// AccessFrom defines an ACL for allowing cross-namespace references
// to the ImageRepository object based on the caller's namespace labels.
// +optional
AccessFrom *AccessFrom `json:"accessFrom,omitempty"`
}

type AccessFrom struct {
NamespaceSelectors []NamespaceSelector `json:"namespaceSelectors,omitempty"`
}

type NamespaceSelector struct {
MatchLabels map[string]string `json:"matchLabels,omitempty"`
AccessFrom *acl.AccessFrom `json:"accessFrom,omitempty"`
}

type ScanResult struct {
Expand Down
47 changes: 2 additions & 45 deletions api/v1beta1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions config/crd/bases/image.toolkit.fluxcd.io_imagerepositories.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -394,14 +394,27 @@ spec:
labels.
properties:
namespaceSelectors:
description: NamespaceSelectors is the list of namespace selectors
to which this ACL applies. Items in this list are evaluated
using a logical OR operation.
items:
description: NamespaceSelector selects the namespaces to which
this ACL applies. An empty map of MatchLabels matches all
namespaces in a cluster.
properties:
matchLabels:
additionalProperties:
type: string
description: MatchLabels is a map of {key,value} pairs.
A single {key,value} in the matchLabels map is equivalent
to an element of matchExpressions, whose key field is
"key", the operator is "In", and the values array contains
only "value". The requirements are ANDed.
type: object
type: object
type: array
required:
- namespaceSelectors
type: object
certSecretRef:
description: "CertSecretRef can be given the name of a secret containing
Expand Down
62 changes: 24 additions & 38 deletions controllers/imagepolicy_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,8 @@ import (
"fmt"
"time"

v1 "k8s.io/api/core/v1"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
kuberecorder "k8s.io/client-go/tools/record"
Expand All @@ -36,7 +34,9 @@ import (
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"sigs.k8s.io/controller-runtime/pkg/source"

aclapi "github.com/fluxcd/pkg/apis/acl"
"github.com/fluxcd/pkg/apis/meta"
"github.com/fluxcd/pkg/runtime/acl"
"github.com/fluxcd/pkg/runtime/events"
"github.com/fluxcd/pkg/runtime/metrics"

Expand All @@ -57,6 +57,7 @@ type ImagePolicyReconciler struct {
ExternalEventRecorder *events.Recorder
MetricsRecorder *metrics.Recorder
Database DatabaseReader
ACLOptions acl.Options
}

type ImagePolicyReconcilerOptions struct {
Expand Down Expand Up @@ -97,6 +98,23 @@ func (r *ImagePolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
if pol.Spec.ImageRepositoryRef.Namespace != "" {
repoNamespacedName.Namespace = pol.Spec.ImageRepositoryRef.Namespace
}

// check if we're allowed to reference across namespaces, before trying to fetch it
if r.ACLOptions.NoCrossNamespaceRefs && repoNamespacedName.Namespace != pol.GetNamespace() {
err := fmt.Errorf("cannot access '%s/%s', cross-namespace references have been blocked", imagev1.ImageRepositoryKind, repoNamespacedName)
imagev1.SetImagePolicyReadiness(
&pol,
metav1.ConditionFalse,
aclapi.AccessDeniedReason,
err.Error(),
)
if err := r.patchStatus(ctx, req, pol.Status); err != nil {
return ctrl.Result{Requeue: true}, err
}
log.Error(err, "access denied to cross-namespace ImageRepository")
return ctrl.Result{}, nil // this cannot proceed until the spec changes, so no need to requeue explicitly
}

if err := r.Get(ctx, repoNamespacedName, &repo); err != nil {
if client.IgnoreNotFound(err) == nil {
imagev1.SetImagePolicyReadiness(
Expand All @@ -115,11 +133,13 @@ func (r *ImagePolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
}

// check if we are allowed to use the referenced ImageRepository
if _, err := r.hasAccessToRepository(ctx, req, pol.Spec.ImageRepositoryRef, repo.Spec.AccessFrom); err != nil {

aclAuth := acl.NewAuthorization(r.Client)
if err := aclAuth.HasAccessToRef(ctx, &pol, repoNamespacedName, repo.Spec.AccessFrom); err != nil {
imagev1.SetImagePolicyReadiness(
&pol,
metav1.ConditionFalse,
"AccessDenied",
aclapi.AccessDeniedReason,
err.Error(),
)
if err := r.patchStatus(ctx, req, pol.Status); err != nil {
Expand Down Expand Up @@ -327,37 +347,3 @@ func (r *ImagePolicyReconciler) patchStatus(ctx context.Context, req ctrl.Reques

return r.Status().Patch(ctx, &res, patch)
}

func (r *ImagePolicyReconciler) hasAccessToRepository(ctx context.Context, policy ctrl.Request, repo meta.NamespacedObjectReference, acl *imagev1.AccessFrom) (bool, error) {
// grant access if the policy is in the same namespace as the repository
if repo.Namespace == "" || policy.Namespace == repo.Namespace {
return true, nil
}

// deny access if the repository has no ACL defined
if acl == nil {
return false, fmt.Errorf("ImageRepository '%s/%s' can't be accessed due to missing access list",
repo.Namespace, repo.Name)
}

// get the policy namespace labels
var policyNamespace v1.Namespace
if err := r.Get(ctx, types.NamespacedName{Name: policy.Namespace}, &policyNamespace); err != nil {
return false, err
}
policyLabels := policyNamespace.GetLabels()

// check if the policy namespace labels match any ACL
for _, selector := range acl.NamespaceSelectors {
sel, err := metav1.LabelSelectorAsSelector(&metav1.LabelSelector{MatchLabels: selector.MatchLabels})
if err != nil {
return false, err
}
if sel.Matches(labels.Set(policyLabels)) {
return true, nil
}
}

return false, fmt.Errorf("ImageRepository '%s/%s' can't be accessed due to labels mismatch on namespace '%s'",
repo.Namespace, repo.Name, policy.Namespace)
}
84 changes: 76 additions & 8 deletions controllers/policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"

aclapi "github.com/fluxcd/pkg/apis/acl"

imagev1 "github.com/fluxcd/image-reflector-controller/api/v1beta1"
// +kubebuilder:scaffold:imports
)
Expand All @@ -47,6 +49,72 @@ var _ = Describe("ImagePolicy controller", func() {
registryServer.Close()
})

When("cross-namespace refs disallowed", func() {
BeforeEach(func() {
imagePolicyReconciler.ACLOptions.NoCrossNamespaceRefs = true
})

AfterEach(func() {
imagePolicyReconciler.ACLOptions.NoCrossNamespaceRefs = false
})

It("fails to reconcile an ImagePolicy with a cross-ns ref", func() {
// a bona fide image repo is needed so that it _would_ succeed if not for the disallowed cross-ns ref.
versions := []string{"1.0.1", "1.0.2", "1.1.0-alpha"}
imgRepo := loadImages(registryServer, "test-semver-policy-"+randStringRunes(5), versions)

repo := imagev1.ImageRepository{
Spec: imagev1.ImageRepositorySpec{
Interval: metav1.Duration{Duration: reconciliationInterval},
Image: imgRepo,
},
}
imageObjectName := types.NamespacedName{
Name: "polimage-" + randStringRunes(5),
Namespace: "default",
}
repo.Name = imageObjectName.Name
repo.Namespace = imageObjectName.Namespace

ctx, cancel := context.WithTimeout(context.Background(), contextTimeout)
defer cancel()
Expect(k8sClient.Create(ctx, &repo)).To(Succeed())

ns := corev1.Namespace{}
ns.Name = "cross-ns-test-" + randStringRunes(5)
Expect(k8sClient.Create(ctx, &ns)).To(Succeed())

imagePolicyName := types.NamespacedName{
Namespace: ns.Name,
Name: "policy-test-" + randStringRunes(5),
}
imagePolicy := imagev1.ImagePolicy{
Spec: imagev1.ImagePolicySpec{
ImageRepositoryRef: meta.NamespacedObjectReference{
Namespace: repo.Namespace,
Name: repo.Name,
},
Policy: imagev1.ImagePolicyChoice{
SemVer: &imagev1.SemVerPolicy{
Range: "1.x",
},
},
},
}
imagePolicy.Namespace = imagePolicyName.Namespace
imagePolicy.Name = imagePolicyName.Name
Expect(k8sClient.Create(ctx, &imagePolicy)).To(Succeed())

var pol imagev1.ImagePolicy
Eventually(func() bool {
err := k8sClient.Get(ctx, imagePolicyName, &pol)
return err == nil && apimeta.IsStatusConditionFalse(pol.Status.Conditions, meta.ReadyCondition)
}, timeout, interval).Should(BeTrue())
ready := apimeta.FindStatusCondition(pol.Status.Conditions, meta.ReadyCondition)
Expect(ready.Reason).To(Equal(aclapi.AccessDeniedReason))
})
})

Context("Calculates an image from a repository's tags", func() {
When("Using SemVerPolicy", func() {
It("calculates an image from a repository's tags", func() {
Expand Down Expand Up @@ -480,7 +548,7 @@ var _ = Describe("ImagePolicy controller", func() {
Spec: imagev1.ImageRepositorySpec{
Interval: metav1.Duration{Duration: reconciliationInterval},
Image: imgRepo,
AccessFrom: &imagev1.AccessFrom{},
AccessFrom: nil,
},
}
repoObjectName := types.NamespacedName{
Expand Down Expand Up @@ -532,7 +600,7 @@ var _ = Describe("ImagePolicy controller", func() {
_ = r.Get(ctx, polObjectName, &pol)
return apimeta.IsStatusConditionFalse(pol.Status.Conditions, meta.ReadyCondition)
}, timeout, interval).Should(BeTrue())
Expect(apimeta.FindStatusCondition(pol.Status.Conditions, meta.ReadyCondition).Reason).To(Equal("AccessDenied"))
Expect(apimeta.FindStatusCondition(pol.Status.Conditions, meta.ReadyCondition).Reason).To(Equal(aclapi.AccessDeniedReason))

Expect(r.Delete(ctx, &pol)).To(Succeed())
})
Expand All @@ -553,8 +621,8 @@ var _ = Describe("ImagePolicy controller", func() {
Spec: imagev1.ImageRepositorySpec{
Interval: metav1.Duration{Duration: reconciliationInterval},
Image: imgRepo,
AccessFrom: &imagev1.AccessFrom{
NamespaceSelectors: []imagev1.NamespaceSelector{
AccessFrom: &aclapi.AccessFrom{
NamespaceSelectors: []aclapi.NamespaceSelector{
{
MatchLabels: make(map[string]string),
},
Expand Down Expand Up @@ -636,8 +704,8 @@ var _ = Describe("ImagePolicy controller", func() {
Spec: imagev1.ImageRepositorySpec{
Interval: metav1.Duration{Duration: reconciliationInterval},
Image: imgRepo,
AccessFrom: &imagev1.AccessFrom{
NamespaceSelectors: []imagev1.NamespaceSelector{
AccessFrom: &aclapi.AccessFrom{
NamespaceSelectors: []aclapi.NamespaceSelector{
{
MatchLabels: policyNamespace.Labels,
},
Expand Down Expand Up @@ -737,8 +805,8 @@ var _ = Describe("ImagePolicy controller", func() {
Spec: imagev1.ImageRepositorySpec{
Interval: metav1.Duration{Duration: reconciliationInterval},
Image: imgRepo,
AccessFrom: &imagev1.AccessFrom{
NamespaceSelectors: []imagev1.NamespaceSelector{
AccessFrom: &aclapi.AccessFrom{
NamespaceSelectors: []aclapi.NamespaceSelector{
{
MatchLabels: map[string]string{
"tenant": "b",
Expand Down
Loading