Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
c63b409
docs: plan who may reference what, before accessFrom ships
sunib Oct 23, 2026
ab31df7
docs: make every grant explicit, and map access between all objects
sunib Oct 23, 2026
eb5e7df
docs: settle the chart default, branch patterns and revocation promise
sunib Oct 23, 2026
8688825
docs: add a platform example, and state identity and hand-back precisely
sunib Oct 23, 2026
51e7654
docs: say where a grant admits from with an enum, and drop booleans
sunib Oct 23, 2026
91ed0a8
docs: name the grant lists allowedGitBranches, allowedGitTargets, all…
sunib Oct 23, 2026
18e4db6
docs: rewrap the naming paragraph to the line limit
sunib Oct 23, 2026
0f591da
docs: lead with one tenant end to end, and define cluster resources
sunib Oct 23, 2026
3e2f23f
feat(api): add access grants with a required from, and branch patterns
sunib Oct 23, 2026
5a74f5f
feat(authz): admit by explicit grants and remove accessFrom
sunib Oct 23, 2026
1276c5c
feat(git): claim only while admitted, and carry the provider's namespace
sunib Oct 23, 2026
9256470
feat(controller): watch the Namespaces and providers that grants read
sunib Oct 23, 2026
cee08e4
feat(watch): bound ClusterWatchRules by the grant's clusterResources
sunib Oct 23, 2026
a47aac9
feat(applier): hand an applier back when its claim is denied
sunib Oct 23, 2026
65274aa
feat(chart): the local KubeProvider grants nothing by default
sunib Oct 23, 2026
6ff17ad
test(e2e): grant the suite's access, and cover tenant branch grants
sunib Oct 23, 2026
89ae4d4
docs: document access grants between providers, branches and their users
sunib Oct 23, 2026
9aa5aaf
test: cover clusterResources recovery by watch, and applier own names…
sunib Oct 23, 2026
5020949
test(e2e): pass the suite's grants to the Helm install as values
sunib Oct 23, 2026
b5e5c7d
fix(git): a released branch claim hands the branch to nobody
sunib Oct 23, 2026
74183d5
fix(watch): check clusterResources against what the planner selects
sunib Oct 23, 2026
cc83930
test(e2e): resolve the install mode outside a _test.go file
sunib Oct 23, 2026
227c24a
docs: move the provider access plan to finished, with what shipped
sunib Oct 23, 2026
7d35abf
fix(applier): give a hand-back a denial began its own Held reason
sunib Oct 23, 2026
433138c
refactor: fold duplicated grant and mapper code, and fix the clusterR…
sunib Oct 24, 2026
5ccb58b
test: name tests after grants, and share the grant fixtures
sunib Oct 24, 2026
dbfa1dd
docs: point two plans at the grants that replaced accessFrom
sunib Oct 24, 2026
fd34097
test(e2e): time out a hung k3d image import so the retry runs
sunib Oct 24, 2026
093bf85
feat(api): name the grant consumer with {consumer.namespace} and {con…
sunib Oct 24, 2026
6ba88dc
feat(api): allow {consumer.name} only beside {consumer.namespace} in …
sunib Oct 24, 2026
fb16a98
refactor(api): rename the applier claim's Held condition to InControl
sunib Oct 24, 2026
81d0aed
docs(api): say what each grant scope field permits, in the CRDs
sunib Oct 24, 2026
c84ca81
refactor(api)!: nest each grant's "who" in a required consumerNamespa…
sunib Oct 24, 2026
7f41206
docs: write every grant in the consumerNamespaces shape, and say what…
sunib Oct 24, 2026
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
2 changes: 1 addition & 1 deletion Taskfile-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ tasks:
#
# It exists because the table it guards was written by hand once and got three
# defaults backwards, each in the direction that costs a reader an outage:
# accessFrom omitted DENIES every namespace, serializeNamespace omitted INFERS
# a grant list omitted DENIES every namespace, serializeNamespace omitted INFERS
# per document rather than meaning false, and qps/burst fall back to the
# operator-wide flags. The schema knew all three. Nothing was asking it.
sources:
Expand Down
553 changes: 553 additions & 0 deletions api/v1alpha3/access_grants.go

Large diffs are not rendered by default.

314 changes: 314 additions & 0 deletions api/v1alpha3/access_grants_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,314 @@
// SPDX-License-Identifier: Apache-2.0

package v1alpha3

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
)

// consumer is the object asking for access, by namespace and name.
func consumer(namespace, name string) types.NamespacedName {
return types.NamespacedName{Namespace: namespace, Name: name}
}

func TestMatchBranchPattern(t *testing.T) {
tests := []struct {
pattern BranchPattern
namespace string
name string
branch string
matches bool
}{
{"teams/{consumer.namespace}/*", "team-a", "live", "teams/team-a/live", true},
{"teams/{consumer.namespace}/*", "team-a", "live", "teams/team-ab/live", false},
{"teams/{consumer.namespace}/*", "team-a", "live", "teams/team-a", false},
{"teams/{consumer.namespace}/*", "team-a", "live", "teams/team-a/", true},
{"feature/*", "x", "live", "feature/checkout/v2", true},
{"feature/*", "x", "live", "feature", false},
{"*", "x", "live", "main", true},
{"*", "x", "live", "teams/a/b", true},
{"teams/*", "x", "live", "teams/a/b", true},
{"main", "x", "live", "main", true},
{"main", "x", "live", "main-old", false},
{"main", "x", "live", "old-main", false},
{"release/*/hotfix", "x", "live", "release/1.2/hotfix", true},
{"release/*/hotfix", "x", "live", "release/1.2/hotfix/2", false},
{"*-old", "x", "live", "main-old", true},
{"a*a", "x", "live", "a", false},
{"a*a", "x", "live", "aa", true},
{"{consumer.namespace}", "team-a", "live", "team-a", true},
{"{consumer.namespace}/*", "team-a", "live", "team-b/x", false},
{"teams/{consumer.namespace}/{consumer.name}", "team-a", "checkout", "teams/team-a/checkout", true},
{"teams/{consumer.namespace}/{consumer.name}", "team-a", "checkout", "teams/team-a/payments", false},
{"{consumer.namespace}/{consumer.name}/*", "team-a", "checkout", "team-a/checkout/feature", true},
{"{consumer.namespace}/{consumer.name}/*", "team-a", "checkout", "team-a/payments/feature", false},
{"{namespace}", "team-a", "live", "team-a", false},
}
for _, tt := range tests {
t.Run(string(tt.pattern)+"~"+tt.branch, func(t *testing.T) {
assert.Equal(t, tt.matches, MatchBranchPattern(tt.pattern, consumer(tt.namespace, tt.name), tt.branch))
})
}
}

func TestConsumerNamespaces_Admits(t *testing.T) {
tenant := map[string]string{"tenant": "true"}
tests := []struct {
name string
grant ConsumerNamespaces
namespace string
labels map[string]string
admits bool
}{
{"Same admits the holder's namespace", ConsumerNamespaces{From: GrantFromSame}, "platform", nil, true},
{"Same admits nothing else", ConsumerNamespaces{From: GrantFromSame}, "team-a", tenant, false},
{"Names admits a listed namespace",
ConsumerNamespaces{From: GrantFromNames, Names: []GrantNamespace{"team-a"}}, "team-a", nil, true},
{"Names admits nothing else",
ConsumerNamespaces{From: GrantFromNames, Names: []GrantNamespace{"team-a"}}, "team-b", nil, false},
// An exact allow-list, never a prefix: otherwise "team-a-evil" would inherit team-a's grant.
{"Names is no prefix match",
ConsumerNamespaces{From: GrantFromNames, Names: []GrantNamespace{"team-a"}}, "team-a-evil", nil, false},
{"Names is case-sensitive",
ConsumerNamespaces{From: GrantFromNames, Names: []GrantNamespace{"team-a"}}, "Team-A", nil, false},
{"Selector admits a matching namespace", ConsumerNamespaces{From: GrantFromSelector,
Selector: &metav1.LabelSelector{MatchLabels: tenant}}, "team-a", tenant, true},
{"Selector admits nothing else", ConsumerNamespaces{From: GrantFromSelector,
Selector: &metav1.LabelSelector{MatchLabels: tenant}}, "team-a", nil, false},
{"an empty selector admits nobody", ConsumerNamespaces{From: GrantFromSelector,
Selector: &metav1.LabelSelector{}}, "team-a", tenant, false},
{"All admits every namespace", ConsumerNamespaces{From: GrantFromAll}, "anything", nil, true},
{"an unknown from admits nothing", ConsumerNamespaces{From: "Everyone"}, "platform", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
admits, err := tt.grant.Admits("platform", tt.namespace, tt.labels)
require.NoError(t, err)
assert.Equal(t, tt.admits, admits)
})
}
}

// TestConsumerNamespaces_SameFollowsTheHolder: Same names no namespace, so a kustomize overlay that moves
// the object holding the grant moves what it admits with it.
func TestConsumerNamespaces_SameFollowsTheHolder(t *testing.T) {
branch := &GitBranch{
ObjectMeta: metav1.ObjectMeta{Namespace: "team-a"},
Spec: GitBranchSpec{
AllowedGitTargets: []GitBranchGitTargetGrant{{ConsumerNamespaces: ConsumerNamespaces{From: GrantFromSame}}},
},
}
admits, err := branch.AdmitsGitTarget("team-a", nil)
require.NoError(t, err)
assert.True(t, admits)

branch.Namespace = "team-a-staging" // the overlay's namespace:
admits, err = branch.AdmitsGitTarget("team-a-staging", nil)
require.NoError(t, err)
assert.True(t, admits, "Same admits the namespace the object is in now")
admits, err = branch.AdmitsGitTarget("team-a", nil)
require.NoError(t, err)
assert.False(t, admits, "and nothing else")
}

func TestConsumerNamespaces_InvalidSelectorIsAnError(t *testing.T) {
grant := ConsumerNamespaces{From: GrantFromSelector, Selector: &metav1.LabelSelector{
MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "tenant", Operator: "Bogus"}},
}}
_, err := grant.Admits("platform", "team-a", nil)
require.Error(t, err)

// One malformed grant refuses nobody another grant admits, and admits nobody itself.
branch := &GitBranch{ObjectMeta: metav1.ObjectMeta{Namespace: "platform"},
Spec: GitBranchSpec{AllowedGitTargets: []GitBranchGitTargetGrant{
{ConsumerNamespaces: grant}, {ConsumerNamespaces: ConsumerNamespaces{From: GrantFromSame}},
}}}
admits, err := branch.AdmitsGitTarget("platform", nil)
require.NoError(t, err)
assert.True(t, admits)
admits, err = branch.AdmitsGitTarget("team-a", nil)
require.Error(t, err)
assert.False(t, admits)
}

func TestGitBranch_AdmitsGitTarget_NothingAutomatic(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.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())
}

func TestGitProvider_AdmitsGitBranch(t *testing.T) {
provider := &GitProvider{
ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "shared-repo"},
Spec: GitProviderSpec{AllowedGitBranches: []GitProviderGitBranchGrant{
{
ConsumerNamespaces: ConsumerNamespaces{From: GrantFromSame},
BranchPatterns: []BranchPattern{"main", "release/*"},
},
{
ConsumerNamespaces: ConsumerNamespaces{
From: GrantFromSelector,
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tenant": "true"}},
},
BranchPatterns: []BranchPattern{"teams/{consumer.namespace}/*", "feature/*"},
},
}},
}
tenant := map[string]string{"tenant": "true"}

got, err := provider.AdmitsGitBranch(consumer("team-a", "live"), tenant, "teams/team-a/live")
require.NoError(t, err)
assert.Equal(t, GitBranchAdmission{Admitted: true, Allowed: true,
BranchPatterns: []string{"feature/*", "teams/team-a/*"}}, got)

got, err = provider.AdmitsGitBranch(consumer("team-a", "live"), tenant, "main")
require.NoError(t, err)
assert.True(t, got.Admitted)
assert.False(t, got.Allowed, "main is the platform's")

got, err = provider.AdmitsGitBranch(consumer("team-a", "live"), tenant, "teams/team-b/live")
require.NoError(t, err)
assert.False(t, got.Allowed, "another team's prefix")

got, err = provider.AdmitsGitBranch(consumer("team-c", "live"), nil, "teams/team-c/live")
require.NoError(t, err)
assert.False(t, got.Admitted, "no grant admits an unlabelled namespace")

got, err = provider.AdmitsGitBranch(consumer("platform", "live"), nil, "release/1.0")
require.NoError(t, err)
assert.True(t, got.Allowed)

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")
}

func TestKubeProvider_AdmitsGitTarget(t *testing.T) {
tenant := map[string]string{"tenant": "true"}
provider := &KubeProvider{
ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "production"},
Spec: KubeProviderSpec{AllowedGitTargets: []KubeProviderGitTargetGrant{
{
ConsumerNamespaces: ConsumerNamespaces{
From: GrantFromSelector,
Selector: &metav1.LabelSelector{MatchLabels: tenant},
},
SourceNamespaces: []ScopeNamespace{"{consumer.namespace}"},
},
{
ConsumerNamespaces: ConsumerNamespaces{From: GrantFromNames, Names: []GrantNamespace{"platform-audit"}},
SourceNamespaces: []ScopeNamespace{"*"},
ClusterResources: []ClusterResourceIdentity{"*"},
},
{
ConsumerNamespaces: ConsumerNamespaces{From: GrantFromNames, Names: []GrantNamespace{"rbac-mirror"}},
ClusterResources: []ClusterResourceIdentity{"clusterroles.rbac.authorization.k8s.io", "nodes"},
},
}},
}

scope, err := provider.AdmitsGitTarget(consumer("team-a", "mirror"), tenant)
require.NoError(t, err)
assert.True(t, scope.Admitted)
assert.True(t, scope.AllowsSourceNamespace("team-a"))
assert.False(t, scope.AllowsSourceNamespace("kube-system"))
assert.False(t, scope.AllowsSourceNamespace("*"), "{consumer.namespace} does not allow a wildcard")
assert.False(t, scope.AllowsClusterResource("", "nodes"))

scope, err = provider.AdmitsGitTarget(consumer("platform-audit", "mirror"), nil)
require.NoError(t, err)
assert.True(t, scope.AllowsSourceNamespace("*"))
assert.True(t, scope.AllowsSourceNamespace("kube-system"))
assert.True(t, scope.AllowsAllClusterResources())

scope, err = provider.AdmitsGitTarget(consumer("rbac-mirror", "mirror"), nil)
require.NoError(t, err)
assert.True(t, scope.Admitted)
assert.Empty(t, scope.SourceNamespaces, "clusterResources alone grants no namespace")
assert.True(t, scope.AllowsClusterResource("rbac.authorization.k8s.io", "clusterroles"))
assert.True(t, scope.AllowsClusterResource("", "nodes"))
assert.False(t, scope.AllowsClusterResource("", "clusterroles"), "a bare name is the core group only")
assert.False(t, scope.AllowsAllClusterResources())

scope, err = provider.AdmitsGitTarget(consumer("platform", "mirror"), nil)
require.NoError(t, err)
assert.False(t, scope.Admitted, "the provider's own namespace needs a grant")
}

// TestKubeProvider_AdmitsGitTarget_UnionOfScopes: a namespace two grants admit gets both scopes.
func TestKubeProvider_AdmitsGitTarget_UnionOfScopes(t *testing.T) {
provider := &KubeProvider{
ObjectMeta: metav1.ObjectMeta{Namespace: "platform"},
Spec: KubeProviderSpec{AllowedGitTargets: []KubeProviderGitTargetGrant{
{
ConsumerNamespaces: ConsumerNamespaces{From: GrantFromAll},
SourceNamespaces: []ScopeNamespace{"{consumer.namespace}"},
},
{
ConsumerNamespaces: ConsumerNamespaces{
From: GrantFromSelector,
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"audit": "true"}},
},
SourceNamespaces: []ScopeNamespace{"*"},
},
}},
}
scope, err := provider.AdmitsGitTarget(consumer("team-a", "mirror"), map[string]string{"audit": "true"})
require.NoError(t, err)
assert.Equal(t, []string{"*", "team-a"}, scope.SourceNamespaces)

scope, err = provider.AdmitsGitTarget(consumer("team-a", "mirror"), nil)
require.NoError(t, err)
assert.Equal(t, []string{"team-a"}, scope.SourceNamespaces, "losing the label narrows the scope")
}

func TestKubeProvider_AdmitsApplierClaim(t *testing.T) {
provider := &KubeProvider{
ObjectMeta: metav1.ObjectMeta{Namespace: "platform"},
Spec: KubeProviderSpec{
AllowedGitTargets: []KubeProviderGitTargetGrant{
{
ConsumerNamespaces: ConsumerNamespaces{From: GrantFromAll},
SourceNamespaces: []ScopeNamespace{"{consumer.namespace}"},
},
},
AllowedApplierClaims: []KubeProviderApplierClaimGrant{
{
ConsumerNamespaces: ConsumerNamespaces{From: GrantFromNames, Names: []GrantNamespace{"team-a"}},
ApplierNamespaces: []ScopeNamespace{"flux-system"},
},
},
},
}
scope, err := provider.AdmitsApplierClaim(consumer("team-a", "checkout"), nil)
require.NoError(t, err)
assert.True(t, scope.AllowsApplierNamespace("flux-system"))
assert.False(t, scope.AllowsApplierNamespace("team-a"), "an explicit list is the whole scope")

scope, err = provider.AdmitsApplierClaim(consumer("team-b", "checkout"), nil)
require.NoError(t, err)
assert.False(t, scope.Admitted, "admission to mirror does not admit to apply")
assert.Equal(t, "admits team-a", provider.DescribeAllowedApplierClaims())
}

func TestGitBranch_GitProviderKey(t *testing.T) {
branch := &GitBranch{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a"}}
branch.Spec.GitProviderRef.Name = "shared-repo"
assert.Equal(t, "team-a/shared-repo", branch.GitProviderKey().String(), "omitted is the own namespace")
branch.Spec.GitProviderRef.Namespace = "platform"
assert.Equal(t, "platform/shared-repo", branch.GitProviderKey().String())
}
16 changes: 10 additions & 6 deletions api/v1alpha3/argocdclaim_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ type ArgoCDClaimSpec struct {

// KubeProviderRef names the KubeProvider whose cluster holds the Application, such as a
// management cluster running Argo CD for the source cluster. Namespace omitted means this
// applier claim's own namespace. The provider must admit this applier claim's namespace: its own, or one its
// spec.accessFrom admits. Its identity needs get, update and patch on the Application. Immutable.
// applier claim's own namespace. The provider's spec.allowedApplierClaims must admit this applier
// claim's namespace, with the Application's namespace in applierNamespaces. Its identity needs
// get, update and patch on the Application. Immutable.
// +required
// +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.kubeProviderRef.name must not be empty"
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec.kubeProviderRef is immutable; delete and recreate the ArgoCDClaim to hold an Application elsewhere"
Expand Down Expand Up @@ -65,9 +66,12 @@ type ArgoCDClaimStatus struct {

// Conditions are Configured (an eligible GitTarget names the applier claim; the Application exists,
// syncs a single source, has automated sync off or a switch the applier claim may hold, may be
// written, and no other ArgoCDClaim holds it), Held (AutomatedSyncOff: automated sync is off,
// held by GitOps Reverser or kept off by the switch's owner; Releasing: being handed back;
// NotReferenced; NotConfigured), and the kstatus Ready/Reconciling/Stalled trio. An applier claim acts only while an
// written, and no other ArgoCDClaim holds it), InControl (whether GitOps Reverser decides, right
// now, when the Application syncs. True: AutomatedSyncOff, automated sync is off, held by GitOps
// Reverser or kept off by the switch's owner. False: Releasing, being handed back; Revoking, being
// handed back because a grant no longer admits it; NotReferenced. NotConfigured: the applier claim
// is not Configured, or automated sync was switched on since it was), and the kstatus
// Ready/Reconciling/Stalled trio. An applier claim acts only while an
// eligible GitTarget references it: one in its namespace that names it, is admitted by its
// KubeProviders, and writes the repository and branch the Application syncs. An applier claim no
// GitTarget names reads nothing in the Application's cluster and is Configured=Unknown,
Expand Down Expand Up @@ -125,7 +129,7 @@ type ArgoCDClaimSource struct {
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
// +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason`
// +kubebuilder:printcolumn:name="Held",type=string,JSONPath=`.status.conditions[?(@.type=="Held")].reason`
// +kubebuilder:printcolumn:name="Control",type=string,JSONPath=`.status.conditions[?(@.type=="InControl")].reason`
// +kubebuilder:printcolumn:name="Application",type=string,JSONPath=`.spec.applicationRef.name`
// +kubebuilder:printcolumn:name="Operation",type=string,JSONPath=`.status.operation.phase`
// +kubebuilder:printcolumn:name="Commit",type=string,JSONPath=`.status.operation.commit`,priority=1
Expand Down
8 changes: 5 additions & 3 deletions api/v1alpha3/audit_route_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ func TestKubeProvider_AuditRoute_SharedBySeveralProviders(t *testing.T) {
delegating := KubeProvider{
ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "srcns-delegating"},
Spec: KubeProviderSpec{
InCluster: &KubeProviderInCluster{},
AllowCrossNamespace: &KubeProviderCrossNamespace{Sources: true},
Attribution: &KubeProviderAttribution{AuditRoute: "platform.local"},
InCluster: &KubeProviderInCluster{},
AllowedGitTargets: []KubeProviderGitTargetGrant{
{ConsumerNamespaces: ConsumerNamespaces{From: GrantFromSame}, SourceNamespaces: []ScopeNamespace{"*"}},
},
Attribution: &KubeProviderAttribution{AuditRoute: "platform.local"},
},
}

Expand Down
Loading
Loading