Skip to content
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
65 changes: 59 additions & 6 deletions api/v1alpha3/access_grants.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,59 @@ type KubeProviderApplierClaimGrant struct {
ApplierNamespaces []ScopeNamespace `json:"applierNamespaces"`
}

// The API server defaults an omitted grant list on GitBranch and KubeProvider (the
// +kubebuilder:default markers on their fields), so a list read from it is never nil. These mirror
// those markers for an object that did not come from the API server, such as one built in memory or
// read through a fake client: nil is the default, and only a non-nil empty list admits nobody.
// TestGrantDefaultsMatchCRDs keeps the two in step.

// DefaultGitBranchGitTargetGrants is GitBranch.spec.allowedGitTargets when it is omitted: GitTargets
// in the GitBranch's own namespace.
func DefaultGitBranchGitTargetGrants() []GitBranchGitTargetGrant {
return []GitBranchGitTargetGrant{{ConsumerNamespaces: ConsumerNamespaces{From: GrantFromSame}}}
}

// DefaultKubeProviderGitTargetGrants is KubeProvider.spec.allowedGitTargets when it is omitted:
// GitTargets in the provider's own namespace, mirroring the namespace of the same name.
func DefaultKubeProviderGitTargetGrants() []KubeProviderGitTargetGrant {
return []KubeProviderGitTargetGrant{{
ConsumerNamespaces: ConsumerNamespaces{From: GrantFromSame},
SourceNamespaces: []ScopeNamespace{ConsumerNamespaceVariable},
}}
}

// DefaultKubeProviderApplierClaimGrants is KubeProvider.spec.allowedApplierClaims when it is omitted:
// applier claims in the provider's own namespace, holding appliers in the namespace of the same name.
func DefaultKubeProviderApplierClaimGrants() []KubeProviderApplierClaimGrant {
return []KubeProviderApplierClaimGrant{{
ConsumerNamespaces: ConsumerNamespaces{From: GrantFromSame},
ApplierNamespaces: []ScopeNamespace{ConsumerNamespaceVariable},
}}
}

// orDefault is grants, or def when grants is nil (omitted rather than empty).
func orDefault[G any](grants []G, def func() []G) []G {
if grants == nil {
return def()
}
return grants
}

// gitTargetGrants is spec.allowedGitTargets as the API server serves it.
func (b *GitBranch) gitTargetGrants() []GitBranchGitTargetGrant {
return orDefault(b.Spec.AllowedGitTargets, DefaultGitBranchGitTargetGrants)
}

// gitTargetGrants is spec.allowedGitTargets as the API server serves it.
func (p *KubeProvider) gitTargetGrants() []KubeProviderGitTargetGrant {
return orDefault(p.Spec.AllowedGitTargets, DefaultKubeProviderGitTargetGrants)
}

// applierClaimGrants is spec.allowedApplierClaims as the API server serves it.
func (p *KubeProvider) applierClaimGrants() []KubeProviderApplierClaimGrant {
return orDefault(p.Spec.AllowedApplierClaims, DefaultKubeProviderApplierClaimGrants)
}

// consumers is the admitting half of a GitProviderGitBranchGrant.
func (g *GitProviderGitBranchGrant) consumers() ConsumerNamespaces { return g.ConsumerNamespaces }

Expand Down Expand Up @@ -438,13 +491,13 @@ func (p *GitProvider) DescribeAllowedGitBranches() string {
// AdmitsGitTarget reports whether spec.allowedGitTargets admits a GitTarget in namespace, by the
// labels that namespace carries in the control cluster.
func (b *GitBranch) AdmitsGitTarget(namespace string, nsLabels map[string]string) (bool, error) {
admitting, err := admitAll(consumersOf(b.Spec.AllowedGitTargets), b.Namespace, namespace, nsLabels)
admitting, err := admitAll(consumersOf(b.gitTargetGrants()), b.Namespace, namespace, nsLabels)
return len(admitting) > 0, err
}

// DescribeAllowedGitTargets names the namespaces spec.allowedGitTargets admits.
func (b *GitBranch) DescribeAllowedGitTargets() string {
return describeAdmitted(consumersOf(b.Spec.AllowedGitTargets), b.Namespace)
return describeAdmitted(consumersOf(b.gitTargetGrants()), b.Namespace)
}

// KubeProviderGitTargetScope is what a KubeProvider's allowedGitTargets give a GitTarget: whether it may mirror at
Expand Down Expand Up @@ -492,7 +545,7 @@ func scopeAllows(scope []string, value string) bool {
func (p *KubeProvider) AdmitsGitTarget(
consumer types.NamespacedName, nsLabels map[string]string,
) (KubeProviderGitTargetScope, error) {
grants := p.Spec.AllowedGitTargets
grants := p.gitTargetGrants()
admitting, err := admitAll(consumersOf(grants), p.Namespace, consumer.Namespace, nsLabels)
if err != nil || len(admitting) == 0 {
return KubeProviderGitTargetScope{}, err
Expand All @@ -512,7 +565,7 @@ func (p *KubeProvider) AdmitsGitTarget(

// DescribeAllowedGitTargets names the namespaces spec.allowedGitTargets admits.
func (p *KubeProvider) DescribeAllowedGitTargets() string {
return describeAdmitted(consumersOf(p.Spec.AllowedGitTargets), p.Namespace)
return describeAdmitted(consumersOf(p.gitTargetGrants()), p.Namespace)
}

// KubeProviderApplierClaimScope is what a KubeProvider's allowedApplierClaims give an applier claim.
Expand All @@ -535,7 +588,7 @@ func (s KubeProviderApplierClaimScope) AllowsApplierNamespace(namespace string)
func (p *KubeProvider) AdmitsApplierClaim(
consumer types.NamespacedName, nsLabels map[string]string,
) (KubeProviderApplierClaimScope, error) {
grants := p.Spec.AllowedApplierClaims
grants := p.applierClaimGrants()
admitting, err := admitAll(consumersOf(grants), p.Namespace, consumer.Namespace, nsLabels)
if err != nil || len(admitting) == 0 {
return KubeProviderApplierClaimScope{}, err
Expand All @@ -549,5 +602,5 @@ func (p *KubeProvider) AdmitsApplierClaim(

// DescribeAllowedApplierClaims names the namespaces spec.allowedApplierClaims admits.
func (p *KubeProvider) DescribeAllowedApplierClaims() string {
return describeAdmitted(consumersOf(p.Spec.AllowedApplierClaims), p.Namespace)
return describeAdmitted(consumersOf(p.applierClaimGrants()), p.Namespace)
}
79 changes: 79 additions & 0 deletions api/v1alpha3/access_grants_defaults_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: Apache-2.0

package v1alpha3

import (
"encoding/json"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"sigs.k8s.io/yaml"
)

// crdSpecFieldDefault is the default the generated CRD gives spec.<field> of its first version.
func crdSpecFieldDefault(t *testing.T, file, field string) []byte {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "config", "crd", "bases", file))
require.NoError(t, err)
var crd apiextensionsv1.CustomResourceDefinition
require.NoError(t, yaml.Unmarshal(raw, &crd))
require.NotEmpty(t, crd.Spec.Versions)
prop, ok := crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"].Properties[field]
require.True(t, ok, "%s has no spec.%s", file, field)
require.NotNil(t, prop.Default, "%s spec.%s has no default", file, field)
return prop.Default.Raw
}

// TestGrantDefaultsMatchCRDs keeps the Go defaults the grant methods use for a nil list equal to the
// defaults the API server applies from the CRDs.
func TestGrantDefaultsMatchCRDs(t *testing.T) {
t.Run("GitBranch allowedGitTargets", func(t *testing.T) {
assertCRDDefault(t, "configbutler.ai_gitbranches.yaml", "allowedGitTargets", DefaultGitBranchGitTargetGrants())
})
t.Run("KubeProvider allowedGitTargets", func(t *testing.T) {
assertCRDDefault(t, "configbutler.ai_kubeproviders.yaml", "allowedGitTargets",
DefaultKubeProviderGitTargetGrants())
})
t.Run("KubeProvider allowedApplierClaims", func(t *testing.T) {
assertCRDDefault(t, "configbutler.ai_kubeproviders.yaml", "allowedApplierClaims",
DefaultKubeProviderApplierClaimGrants())
})
}

// assertCRDDefault asserts that the CRD's default for spec.<field> decodes to want.
func assertCRDDefault[G any](t *testing.T, file, field string, want []G) {
t.Helper()
var got []G
require.NoError(t, json.Unmarshal(crdSpecFieldDefault(t, file, field), &got))
assert.Equal(t, want, got)
}

// TestGrantLists_EmptySurvivesSerialization pins that [] stays [] on the wire, so an explicit deny-all
// is never sent as an omitted field that the API server would default back to a grant.
func TestGrantLists_EmptySurvivesSerialization(t *testing.T) {
branch := GitBranchSpec{AllowedGitTargets: []GitBranchGitTargetGrant{}}
provider := KubeProviderSpec{
AllowedGitTargets: []KubeProviderGitTargetGrant{},
AllowedApplierClaims: []KubeProviderApplierClaimGrant{},
}
for name, spec := range map[string]any{"GitBranch": branch, "KubeProvider": provider} {
raw, err := json.Marshal(spec)
require.NoError(t, err)
var fields map[string]json.RawMessage
require.NoError(t, json.Unmarshal(raw, &fields))
for _, field := range []string{"allowedGitTargets", "allowedApplierClaims"} {
if name == "GitBranch" && field == "allowedApplierClaims" {
continue
}
assert.JSONEq(t, "[]", string(fields[field]), "%s spec.%s", name, field)
}
}

var decoded GitBranchSpec
require.NoError(t, json.Unmarshal([]byte(`{"allowedGitTargets":[]}`), &decoded))
assert.NotNil(t, decoded.AllowedGitTargets, "a decoded [] stays distinguishable from omitted")
}
47 changes: 42 additions & 5 deletions api/v1alpha3/access_grants_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,18 +136,55 @@ func TestConsumerNamespaces_InvalidSelectorIsAnError(t *testing.T) {
assert.False(t, admits)
}

func TestGitBranch_AdmitsGitTarget_NothingAutomatic(t *testing.T) {
func TestGitBranch_AdmitsGitTarget_DefaultsToSameNamespace(t *testing.T) {
branch := &GitBranch{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a"}}
admits, err := branch.AdmitsGitTarget("team-a", nil)
require.NoError(t, err)
assert.False(t, admits, "an omitted list admits nobody, the GitBranch's own namespace included")
assert.True(t, admits, "an omitted list is the default grant for the GitBranch's own namespace")
admits, err = branch.AdmitsGitTarget("team-b", nil)
require.NoError(t, err)
assert.False(t, admits, "the default admits only the GitBranch's own namespace")
assert.Equal(t, "admits team-a", branch.DescribeAllowedGitTargets())

branch.Spec.AllowedGitTargets = []GitBranchGitTargetGrant{}
admits, err = branch.AdmitsGitTarget("team-a", nil)
require.NoError(t, err)
assert.False(t, admits, "[] admits nobody, the GitBranch's own namespace included")
assert.Equal(t, "admits no namespace", branch.DescribeAllowedGitTargets())

branch.Spec.AllowedGitTargets = []GitBranchGitTargetGrant{
{ConsumerNamespaces: ConsumerNamespaces{From: GrantFromSame}},
{ConsumerNamespaces: ConsumerNamespaces{From: GrantFromNames, Names: []GrantNamespace{"team-b"}}},
}
assert.Equal(t, "admits team-a, team-b", branch.DescribeAllowedGitTargets())
admits, err = branch.AdmitsGitTarget("team-a", nil)
require.NoError(t, err)
assert.False(t, admits, "a written list is the whole policy: the default is not added to it")
assert.Equal(t, "admits team-b", branch.DescribeAllowedGitTargets())
}

func TestKubeProvider_DefaultsToSameNamespace(t *testing.T) {
provider := &KubeProvider{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a"}}
scope, err := provider.AdmitsGitTarget(consumer("team-a", "apps"), nil)
require.NoError(t, err)
assert.True(t, scope.Admitted)
assert.Equal(t, []string{"team-a"}, scope.SourceNamespaces,
"an omitted list mirrors the namespace of the same name")
assert.Empty(t, scope.ClusterResources, "and no cluster-scoped resource")
scope, err = provider.AdmitsGitTarget(consumer("team-b", "apps"), nil)
require.NoError(t, err)
assert.False(t, scope.Admitted)

claim, err := provider.AdmitsApplierClaim(consumer("team-a", "checkout"), nil)
require.NoError(t, err)
assert.Equal(t, KubeProviderApplierClaimScope{Admitted: true, ApplierNamespaces: []string{"team-a"}}, claim)

provider.Spec.AllowedGitTargets = []KubeProviderGitTargetGrant{}
provider.Spec.AllowedApplierClaims = []KubeProviderApplierClaimGrant{}
scope, err = provider.AdmitsGitTarget(consumer("team-a", "apps"), nil)
require.NoError(t, err)
assert.False(t, scope.Admitted, "[] admits nobody")
claim, err = provider.AdmitsApplierClaim(consumer("team-a", "checkout"), nil)
require.NoError(t, err)
assert.False(t, claim.Admitted, "[] admits nobody")
}

func TestGitProvider_AdmitsGitBranch(t *testing.T) {
Expand Down Expand Up @@ -194,7 +231,7 @@ func TestGitProvider_AdmitsGitBranch(t *testing.T) {
provider.Spec.AllowedGitBranches = nil
got, err = provider.AdmitsGitBranch(consumer("platform", "live"), nil, "main")
require.NoError(t, err)
assert.False(t, got.Admitted, "nothing is automatic, the provider's own namespace included")
assert.False(t, got.Admitted, "allowedGitBranches has no default, the provider's own namespace included")
}

func TestKubeProvider_AdmitsGitTarget(t *testing.T) {
Expand Down
12 changes: 7 additions & 5 deletions api/v1alpha3/gitbranch_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,16 @@ type GitBranchSpec struct {
// +kubebuilder:validation:MinLength=1
ParentBranch string `json:"parentBranch,omitempty"`

// AllowedGitTargets says which GitTargets may write through this GitBranch. Nothing is admitted
// automatically, this GitBranch's own namespace included: a GitTarget there needs a grant too,
// such as consumerNamespaces: {from: Same}. Omitted or empty, no GitTarget writes through it.
// Grants combine by union. Mutable: claiming a branch includes the right to share it.
// AllowedGitTargets says which GitTargets may write through this GitBranch. Omitted, it defaults
// to one grant for this GitBranch's own namespace (consumerNamespaces: {from: Same}). A list you
// write is the whole policy: nothing is added to it, so a list without a from: Same grant shuts
// this namespace out, and [] admits no GitTarget. Grants combine by union. Mutable: claiming a
// branch includes the right to share it.
// +optional
// +listType=atomic
// +kubebuilder:validation:MaxItems=64
AllowedGitTargets []GitBranchGitTargetGrant `json:"allowedGitTargets,omitempty"`
// +kubebuilder:default={{consumerNamespaces: {from: Same}}}
AllowedGitTargets []GitBranchGitTargetGrant `json:"allowedGitTargets"`
}

// GitBranchStatus defines the observed state of GitBranch.
Expand Down
7 changes: 4 additions & 3 deletions api/v1alpha3/gitprovider_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,10 @@ type GitProviderSpec struct {

// AllowedGitBranches says which GitBranches may claim a branch of this repository through this
// GitProvider, and which branches: a GitBranch claims only while a grant admits its namespace and
// one of that grant's branchPatterns matches its spec.branch. Nothing is admitted automatically, this
// GitProvider's own namespace included; omitted or empty, no GitBranch claims through it. Grants
// combine by union. Who may write through a claimed branch is that GitBranch's
// one of that grant's branchPatterns matches its spec.branch. It has no default, unlike the grant
// lists of GitBranch and KubeProvider, because which branches a namespace may claim is always
// written: omitted or empty, no GitBranch claims through it, this GitProvider's own namespace
// included. Grants combine by union. Who may write through a claimed branch is that GitBranch's
// spec.allowedGitTargets.
// +optional
// +listType=atomic
Expand Down
19 changes: 13 additions & 6 deletions api/v1alpha3/kubeprovider_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,21 +46,28 @@ type KubeProviderSpec struct {
// AllowedGitTargets says which GitTargets may mirror through this provider, and what: each grant
// admits some control-cluster namespaces and bounds what the WatchRules feeding a GitTarget there
// may watch (sourceNamespaces), and what a ClusterWatchRule may mirror through it
// (clusterResources). Nothing is admitted automatically, this KubeProvider's own namespace
// included; omitted or empty, no GitTarget mirrors through it. Grants combine by union.
// (clusterResources). Omitted, it defaults to one grant for this KubeProvider's own namespace,
// mirroring the namespace of the same name (consumerNamespaces: {from: Same}, sourceNamespaces:
// ["{consumer.namespace}"]); it grants no cluster-scoped resource. A list you write is the whole
// policy: nothing is added to it, and [] admits no GitTarget. Grants combine by union.
// +optional
// +listType=atomic
// +kubebuilder:validation:MaxItems=64
AllowedGitTargets []KubeProviderGitTargetGrant `json:"allowedGitTargets,omitempty"`
// +kubebuilder:default={{consumerNamespaces: {from: Same}, sourceNamespaces: {"{consumer.namespace}"}}}
AllowedGitTargets []KubeProviderGitTargetGrant `json:"allowedGitTargets"`

// AllowedApplierClaims says which applier claims (FluxClaims and ArgoCDClaims) may hold an applier
// through this provider, and in which namespaces that applier may live (applierNamespaces).
// Admission to mirror does not admit to apply. Nothing is admitted automatically; omitted or
// empty, no applier claim holds an applier through it. Grants combine by union.
// Admission to mirror does not admit to apply. Omitted, it defaults to one grant for this
// KubeProvider's own namespace, holding appliers in the namespace of the same name
// (consumerNamespaces: {from: Same}, applierNamespaces: ["{consumer.namespace}"]). A list you
// write is the whole policy: nothing is added to it, and [] admits no applier claim. Grants
// combine by union.
// +optional
// +listType=atomic
// +kubebuilder:validation:MaxItems=64
AllowedApplierClaims []KubeProviderApplierClaimGrant `json:"allowedApplierClaims,omitempty"`
// +kubebuilder:default={{consumerNamespaces: {from: Same}, applierNamespaces: {"{consumer.namespace}"}}}
AllowedApplierClaims []KubeProviderApplierClaimGrant `json:"allowedApplierClaims"`

// Client overrides the operator's outgoing kube-client throttles for this cluster. Omitted,
// the built-in defaults apply (20 QPS, burst 30). Refused beside inCluster.
Expand Down
Loading
Loading