diff --git a/Taskfile-build.yml b/Taskfile-build.yml index 9d34849e4..67aad2108 100644 --- a/Taskfile-build.yml +++ b/Taskfile-build.yml @@ -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: diff --git a/api/v1alpha3/access_grants.go b/api/v1alpha3/access_grants.go new file mode 100644 index 000000000..b6ba70c86 --- /dev/null +++ b/api/v1alpha3/access_grants.go @@ -0,0 +1,553 @@ +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha3 + +import ( + "errors" + "fmt" + "slices" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" +) + +// Access between objects (docs/configuration.md): consent lives on the object +// that lends something, in a list named "allowed" plus the referring kind. Every grant says where +// it admits from with an enum borrowed from Gateway API's allowedRoutes.namespaces.from, required +// here so that no grant admits anyone by omission. Nothing is automatic, the object's own namespace +// included: an omitted or empty list admits nobody, and grants combine by union. The object +// asking for access is the consumer, and a scope or branch pattern can name it with a variable. + +// GrantFrom is where a grant admits referring objects from. +// +kubebuilder:validation:Enum=Same;Names;Selector;All +type GrantFrom string + +const ( + // GrantFromSame admits the namespace of the object that holds the grant. + GrantFromSame GrantFrom = "Same" + // GrantFromNames admits exactly the namespaces the grant lists in names. + GrantFromNames GrantFrom = "Names" + // GrantFromSelector admits the namespaces whose labels match the grant's selector. + GrantFromSelector GrantFrom = "Selector" + // GrantFromAll admits every namespace. + GrantFromAll GrantFrom = "All" +) + +// ConsumerNamespaceVariable stands, in a grant's scope or branch pattern, for the consumer's +// namespace: the control-cluster namespace of the object asking for access. +const ConsumerNamespaceVariable = "{consumer.namespace}" + +// ConsumerNameVariable stands, in a branch pattern that also holds ConsumerNamespaceVariable, for the +// consumer's name: the name of the object asking for access. The consumer picks its own name, so the +// name bounds nothing on its own; beside the namespace it is a naming convention. +const ConsumerNameVariable = "{consumer.name}" + +// GrantNamespace is one control-cluster namespace a grant admits by name. +// +kubebuilder:validation:MaxLength=63 +// +kubebuilder:validation:XValidation:rule="self != '{consumer.namespace}' && self != '{consumer.name}'",message="names: a variable is refused, because names are matched against the consumer's namespace and '{consumer.namespace}' would admit every consumer; use from: All to admit every namespace, or from: Same for the namespace of the object holding the grant" +// +kubebuilder:validation:XValidation:rule="self != '*'",message="names: '*' is refused; use from: All to admit every namespace" +// +kubebuilder:validation:XValidation:rule="self == '{consumer.namespace}' || self == '{consumer.name}' || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')",message="names holds namespace names (DNS labels)" +type GrantNamespace string + +// ScopeNamespace is one namespace in a source or applier scope: a namespace name, +// "{consumer.namespace}" for the namespace of the same name as the consumer's, or "*" for every +// namespace. +// +kubebuilder:validation:MaxLength=63 +// +kubebuilder:validation:XValidation:rule="self != '{consumer.name}'",message="'{consumer.name}' is refused in a namespace scope: the consumer picks its own name, so any namespace could be reached by naming an object after it; use '{consumer.namespace}', namespace names, or '*'" +// +kubebuilder:validation:XValidation:rule="self == '*' || self == '{consumer.namespace}' || self == '{consumer.name}' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')",message="a namespace scope holds a namespace name, '{consumer.namespace}' alone, or '*' alone" +type ScopeNamespace string + +// ClusterResourceIdentity names a cluster-scoped resource type: resource.group, a bare resource for +// the core group, or "*" for every one. +// +kubebuilder:validation:MaxLength=317 +// +kubebuilder:validation:Pattern=`^(\*|[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*)$` +type ClusterResourceIdentity string + +// The pattern grammar is checked in CEL so a refusal names the mistake. The character set is an +// OpenAPI pattern: git check-ref-format refuses all of these in a branch name, so a pattern holding +// one could never match, and with no '?' or '[' nothing in a pattern ever needs escaping. + +// BranchPattern is a pattern matched against a whole branch name: literal text, '*' for any +// sequence of characters ('/' included), and '{consumer.namespace}' and '{consumer.name}' as whole +// path segments. '{consumer.name}' needs '{consumer.namespace}' in the same pattern. +// +kubebuilder:validation:MinLength=1 +// +kubebuilder:validation:MaxLength=255 +// +kubebuilder:validation:Pattern=`^[^\s~^:?\[\\]*$` +// +kubebuilder:validation:XValidation:rule="!self.replace('{consumer.namespace}', 'x').replace('{consumer.name}', 'x').matches('[{][^{}]*[}]')",messageExpression="'branch pattern ' + self + ' references unknown variable ' + self.replace('{consumer.namespace}', 'x').replace('{consumer.name}', 'x').find('[{][^{}]*[}]') + '; the variables are {consumer.namespace} and {consumer.name}'" +// +kubebuilder:validation:XValidation:rule="self.replace('{consumer.namespace}', 'x').replace('{consumer.name}', 'x').matches('[{][^{}]*[}]') || !self.replace('{consumer.namespace}', 'x').replace('{consumer.name}', 'x').matches('[{}]')",messageExpression="'branch pattern ' + self + ' has an unmatched brace; the variables are {consumer.namespace} and {consumer.name}'" +// +kubebuilder:validation:XValidation:rule="!self.matches('[^/][{]consumer[.]name(space)?[}]') && !self.matches('[{]consumer[.]name(space)?[}][^/]')",messageExpression="'branch pattern ' + self + ': {consumer.namespace} and {consumer.name} must each be a whole path segment, at the start or after a /, and at the end or before a /'" +// +kubebuilder:validation:XValidation:rule="!self.contains('{consumer.name}') || self.contains('{consumer.namespace}')",messageExpression="'branch pattern ' + self + ': {consumer.name} needs {consumer.namespace} in the same pattern; the consumer picks its own name, so on its own it would let any admitted namespace claim any branch the pattern leaves open'" +// +kubebuilder:validation:XValidation:rule="!self.contains('..') && !self.contains('@{') && !self.startsWith('/') && !self.endsWith('/') && !self.contains('//')",messageExpression="'branch pattern ' + self + ' can match no branch name: git refuses .., @{, a leading or trailing / and //'" +type BranchPattern string + +// Each grant type holds ConsumerNamespaces as a named field, never inline-embedded: the CEL rules +// sit on the type, and an inline-embedded struct's type-level rules do not carry over to the struct +// embedding it. + +// ConsumerNamespaces says which control-cluster namespaces a grant admits consumers from, for the act +// the list holding the grant names. +// +kubebuilder:validation:XValidation:rule="self.from != 'Names' || has(self.names)",message="from: Names needs names" +// +kubebuilder:validation:XValidation:rule="!has(self.names) || self.from == 'Names'",message="names goes only with from: Names" +// +kubebuilder:validation:XValidation:rule="self.from != 'Selector' || has(self.selector)",message="from: Selector needs a selector; to admit every namespace, use from: All" +// +kubebuilder:validation:XValidation:rule="!has(self.selector) || self.from == 'Selector'",message="selector goes only with from: Selector" +// +kubebuilder:validation:XValidation:rule="!has(self.selector) || (has(self.selector.matchLabels) && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) && size(self.selector.matchExpressions) > 0)",message="selector: {} is refused; to admit every namespace, use from: All" +type ConsumerNamespaces struct { + // From is where this grant admits from: Same (the namespace of the object holding the grant, not + // the consumer's), Names (the namespaces in names), Selector (the namespaces whose labels match + // selector) or All (every namespace). + // +required + From GrantFrom `json:"from"` + + // Names are the control-cluster namespaces admitted with from: Names, by exact name. + // +optional + // +listType=set + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=64 + Names []GrantNamespace `json:"names,omitempty"` + + // Selector matches labels on control-cluster Namespaces, with from: Selector. It must select + // something: to admit every namespace, use from: All. + // +optional + Selector *metav1.LabelSelector `json:"selector,omitempty"` +} + +// GitProviderGitBranchGrant admits GitBranches in some control-cluster namespaces to claim the +// branches its patterns match through a GitProvider. +type GitProviderGitBranchGrant struct { + // ConsumerNamespaces are the control-cluster namespaces whose GitBranches this grant admits. + // from: Same is this GitProvider's namespace. + // +required + ConsumerNamespaces ConsumerNamespaces `json:"consumerNamespaces"` + + // BranchPatterns are the branches an admitted GitBranch may claim, as patterns matched against + // the whole branch name. '*' matches any sequence of characters, '/' included, so "*" alone is + // every branch, and a name with no '*', such as "main", matches only itself. + // '{consumer.namespace}' and '{consumer.name}' are the claiming GitBranch's namespace and name, + // each a whole path segment: "teams/{consumer.namespace}/*". '{consumer.name}' needs + // '{consumer.namespace}' in the same pattern, "teams/{consumer.namespace}/{consumer.name}": it is + // a naming convention, not a bound, since a GitBranch picks its own name. There is no escaping, + // '?' or character class. + // +required + // +listType=set + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=64 + BranchPatterns []BranchPattern `json:"branchPatterns"` +} + +// GitBranchGitTargetGrant admits GitTargets in some control-cluster namespaces to write through a +// GitBranch. +type GitBranchGitTargetGrant struct { + // ConsumerNamespaces are the control-cluster namespaces whose GitTargets this grant admits. + // from: Same is this GitBranch's namespace. + // +required + ConsumerNamespaces ConsumerNamespaces `json:"consumerNamespaces"` +} + +// KubeProviderGitTargetGrant admits GitTargets in some control-cluster namespaces to mirror through a +// KubeProvider, and bounds what they may mirror. +// +kubebuilder:validation:XValidation:rule="has(self.sourceNamespaces) || has(self.clusterResources)",message="a grant in allowedGitTargets sets sourceNamespaces, clusterResources, or both; one that grants neither grants nothing" +type KubeProviderGitTargetGrant struct { + // ConsumerNamespaces are the control-cluster namespaces whose GitTargets this grant admits. + // from: Same is this KubeProvider's namespace. + // +required + ConsumerNamespaces ConsumerNamespaces `json:"consumerNamespaces"` + + // SourceNamespaces are source-cluster namespaces that a WatchRule feeding an admitted GitTarget may + // name in rules[].sourceNamespace. Entries are exact names, '{consumer.namespace}' for the + // namespace named like the GitTarget's, or '*'. Omitted, this grant gives no namespace. + // + // The effective scope is the union of sourceNamespaces from all grants admitting the GitTarget, + // after expanding variables. Every WatchRule item must be allowed by that union, an omitted + // sourceNamespace included (it names the WatchRule's own namespace). This is a permission, not a + // selection: listing a namespace watches nothing until a WatchRule names it. + // + // It is not a filter on "*". A rule item with sourceNamespace "*" needs '*' in at least one + // admitting grant; otherwise it is refused, never narrowed to the namespaces listed. With '*' + // granted, such an item is one cluster-wide watch, bounded only by this KubeProvider credential's + // RBAC. + // + // The check runs when the WatchRule reconciles, and again when a grant changes, never at + // admission. A WatchRule with any item outside the union is refused as a whole, in its status + // (SourceNamespaceNotAllowed), and watches nothing. The scope bounds what is read, never where + // objects are written in Git, and the credential's RBAC still applies inside it. + // +optional + // +listType=set + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=64 + SourceNamespaces []ScopeNamespace `json:"sourceNamespaces,omitempty"` + + // ClusterResources are the cluster-scoped resource types a ClusterWatchRule may mirror through an + // admitted GitTarget: resource.group (clusterroles.rbac.authorization.k8s.io), a bare resource for + // the core group only (nodes), or '*' for every one, including CRDs installed later. An identity + // covers every served version. Omitted, this grant gives none. + // + // The effective scope is the union of clusterResources from all grants admitting the GitTarget. + // This is a permission, not a selection: listing a type mirrors nothing until a ClusterWatchRule + // asks for it. A ClusterWatchRule item with '*' in apiGroups or resources needs '*' in at least + // one admitting grant; otherwise it is refused, never narrowed to the types listed. The check runs + // when the ClusterWatchRule reconciles, never at admission, and a rule asking for any type outside + // the union is refused as a whole, in its status (ClusterResourceNotAllowed). + // +optional + // +listType=set + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=64 + ClusterResources []ClusterResourceIdentity `json:"clusterResources,omitempty"` +} + +// KubeProviderApplierClaimGrant admits applier claims in some control-cluster namespaces to hold an +// applier through a KubeProvider, and bounds where that applier may live. +type KubeProviderApplierClaimGrant struct { + // ConsumerNamespaces are the control-cluster namespaces whose applier claims this grant admits. + // from: Same is this KubeProvider's namespace. + // +required + ConsumerNamespaces ConsumerNamespaces `json:"consumerNamespaces"` + + // ApplierNamespaces are namespaces in this provider's cluster in which an admitted applier claim's + // Kustomization or Application may live: exact names, '{consumer.namespace}' for the namespace + // named like the applier claim's, or '*' for every namespace. + // + // The effective scope is the union of applierNamespaces from all grants admitting the applier + // claim, after expanding variables. This is a permission, not a selection: it is checked against + // one namespace, that of the applier claim's kustomizationRef or applicationRef. The check runs + // when the applier claim reconciles, and again when a grant changes, never at admission. An + // applier outside the union is refused, in the applier claim's status + // (ApplierNamespaceNotAllowed), and an applier claim holding it hands it back. + // +required + // +listType=set + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=64 + ApplierNamespaces []ScopeNamespace `json:"applierNamespaces"` +} + +// consumers is the admitting half of a GitProviderGitBranchGrant. +func (g *GitProviderGitBranchGrant) consumers() ConsumerNamespaces { return g.ConsumerNamespaces } + +// consumers is the admitting half of a GitBranchGitTargetGrant. +func (g *GitBranchGitTargetGrant) consumers() ConsumerNamespaces { return g.ConsumerNamespaces } + +// consumers is the admitting half of a KubeProviderGitTargetGrant. +func (g *KubeProviderGitTargetGrant) consumers() ConsumerNamespaces { return g.ConsumerNamespaces } + +// consumers is the admitting half of a KubeProviderApplierClaimGrant. +func (g *KubeProviderApplierClaimGrant) consumers() ConsumerNamespaces { return g.ConsumerNamespaces } + +// consumersOf is the admitting half of each grant in a list. +func consumersOf[G any, PG interface { + *G + consumers() ConsumerNamespaces +}](grants []G) []ConsumerNamespaces { + consumers := make([]ConsumerNamespaces, len(grants)) + for i := range grants { + consumers[i] = PG(&grants[i]).consumers() + } + return consumers +} + +// Admits reports whether this grant, held by an object in holderNamespace, admits a referring +// object in namespace, by the labels that namespace carries in the control cluster. A selector that +// does not convert is an error, never a silent allow; an unknown from admits nothing. +func (g *ConsumerNamespaces) Admits(holderNamespace, namespace string, nsLabels map[string]string) (bool, error) { + switch g.From { + case GrantFromSame: + return namespace == holderNamespace, nil + case GrantFromNames: + for _, name := range g.Names { + if string(name) == namespace { + return true, nil + } + } + return false, nil + case GrantFromSelector: + if g.Selector == nil { + return false, nil + } + selector, err := metav1.LabelSelectorAsSelector(g.Selector) + if err != nil { + return false, fmt.Errorf("selector %s: %w", metav1.FormatLabelSelector(g.Selector), err) + } + if selector.Empty() { + // selector: {} is refused at admission; a stored one admits nobody rather than everyone. + return false, nil + } + return selector.Matches(labels.Set(nsLabels)), nil + case GrantFromAll: + return true, nil + } + return false, nil +} + +// Describe names the namespaces this grant admits, for a refusal message. +func (g *ConsumerNamespaces) Describe(holderNamespace string) string { + switch g.From { + case GrantFromSame: + return holderNamespace + case GrantFromNames: + names := make([]string, 0, len(g.Names)) + for _, name := range g.Names { + names = append(names, string(name)) + } + return strings.Join(names, ", ") + case GrantFromSelector: + if g.Selector == nil { + return "no namespace" + } + return "namespaces labelled " + metav1.FormatLabelSelector(g.Selector) + case GrantFromAll: + return "every namespace" + } + return fmt.Sprintf("no namespace (from %q is unknown)", g.From) +} + +// admitAll evaluates every grant's consumerNamespaces for namespace and returns which admit it. A grant whose +// selector does not convert admits nothing; its error is returned only when no grant admits, so +// one malformed grant cannot widen anything, and an admitted namespace is not refused over a grant +// that did not apply to it. +func admitAll( + consumers []ConsumerNamespaces, holderNamespace, namespace string, nsLabels map[string]string, +) ([]int, error) { + var admitting []int + var errs []error + for i := range consumers { + admitted, err := consumers[i].Admits(holderNamespace, namespace, nsLabels) + if err != nil { + errs = append(errs, fmt.Errorf("grant %d: %w", i, err)) + continue + } + if admitted { + admitting = append(admitting, i) + } + } + if len(admitting) == 0 && len(errs) > 0 { + return nil, errors.Join(errs...) + } + return admitting, nil +} + +// describeAdmitted names the namespaces a list of grants admits, for a refusal message: "admits +// team-a, team-b" or "admits no namespace". +func describeAdmitted(consumers []ConsumerNamespaces, holderNamespace string) string { + if len(consumers) == 0 { + return "admits no namespace" + } + parts := make([]string, 0, len(consumers)) + for i := range consumers { + parts = append(parts, consumers[i].Describe(holderNamespace)) + } + return "admits " + strings.Join(parts, ", ") +} + +// expandConsumer replaces the consumer variables in s with consumer's namespace and name. +func expandConsumer(s string, consumer types.NamespacedName) string { + return strings.NewReplacer(ConsumerNamespaceVariable, consumer.Namespace, ConsumerNameVariable, consumer.Name). + Replace(s) +} + +// expandScope expands the consumer variables in each scope entry, and returns the entries sorted and +// without duplicates. +func expandScope[S ~string](entries []S, consumer types.NamespacedName) []string { + out := make([]string, 0, len(entries)) + for _, entry := range entries { + out = append(out, expandConsumer(string(entry), consumer)) + } + slices.Sort(out) + return slices.Compact(out) +} + +// MatchBranchPattern reports whether branch matches pattern for the GitBranch consumer: the pattern +// is matched against the whole branch name, '*' matches any sequence of characters, '/' included, +// and '{consumer.namespace}' and '{consumer.name}' are consumer's namespace and name. Nothing else is +// special. +func MatchBranchPattern(pattern BranchPattern, consumer types.NamespacedName, branch string) bool { + return globMatch(expandConsumer(string(pattern), consumer), branch) +} + +// globMatch matches name against a pattern in which '*' is the only special character, and matches +// any sequence, the empty one included. +func globMatch(pattern, name string) bool { + parts := strings.Split(pattern, "*") + if len(parts) == 1 { + return pattern == name + } + if !strings.HasPrefix(name, parts[0]) { + return false + } + name = name[len(parts[0]):] + last := parts[len(parts)-1] + for _, part := range parts[1 : len(parts)-1] { + index := strings.Index(name, part) + if index < 0 { + return false + } + name = name[index+len(part):] + } + return strings.HasSuffix(name, last) && len(name) >= len(last) +} + +// ClusterResourceIdentityFor is the identity of a resource in group: resource.group, or the bare +// resource for the core group. +func ClusterResourceIdentityFor(group, resource string) string { + if group == "" { + return resource + } + return resource + "." + group +} + +// GitBranchAdmission is what a GitProvider's allowedGitBranches give a GitBranch. +// +kubebuilder:object:generate=false +type GitBranchAdmission struct { + // Admitted reports whether some grant admits the GitBranch's namespace. + Admitted bool + // BranchPatterns are the patterns the admitting grants give the GitBranch, consumer variables + // expanded. + BranchPatterns []string + // Allowed reports whether the namespace is admitted and the branch matches one of BranchPatterns. + Allowed bool +} + +// AdmitsGitBranch reports what spec.allowedGitBranches gives the GitBranch consumer, by the labels +// its namespace carries in the control cluster, and whether it may claim branch. +func (p *GitProvider) AdmitsGitBranch( + consumer types.NamespacedName, nsLabels map[string]string, branch string, +) (GitBranchAdmission, error) { + grants := p.Spec.AllowedGitBranches + admitting, err := admitAll(consumersOf(grants), p.Namespace, consumer.Namespace, nsLabels) + if err != nil || len(admitting) == 0 { + return GitBranchAdmission{}, err + } + out := GitBranchAdmission{Admitted: true} + var patterns []BranchPattern + for _, i := range admitting { + for _, pattern := range grants[i].BranchPatterns { + patterns = append(patterns, pattern) + if MatchBranchPattern(pattern, consumer, branch) { + out.Allowed = true + } + } + } + out.BranchPatterns = expandScope(patterns, consumer) + return out, nil +} + +// DescribeAllowedGitBranches names the namespaces spec.allowedGitBranches admits. +func (p *GitProvider) DescribeAllowedGitBranches() string { + return describeAdmitted(consumersOf(p.Spec.AllowedGitBranches), p.Namespace) +} + +// 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) + 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) +} + +// KubeProviderGitTargetScope is what a KubeProvider's allowedGitTargets give a GitTarget: whether it may mirror at +// all, and what. +// +kubebuilder:object:generate=false +type KubeProviderGitTargetScope struct { + // Admitted reports whether some grant admits the GitTarget's namespace. + Admitted bool + // SourceNamespaces is the union of the admitting grants' sourceNamespaces, consumer variables + // expanded. "*" is every namespace. + SourceNamespaces []string + // ClusterResources is the union of the admitting grants' clusterResources. "*" is every + // cluster-scoped resource. + ClusterResources []string +} + +// AllowsSourceNamespace reports whether a WatchRule may watch namespace; "*" asks for every +// namespace, which only a "*" in the scope allows. +func (s KubeProviderGitTargetScope) AllowsSourceNamespace(namespace string) bool { + return scopeAllows(s.SourceNamespaces, namespace) +} + +// AllowsAllClusterResources reports whether the scope holds "*" in clusterResources. +func (s KubeProviderGitTargetScope) AllowsAllClusterResources() bool { + return scopeAllows(s.ClusterResources, "*") +} + +// AllowsClusterResource reports whether a ClusterWatchRule may mirror resource in group. +func (s KubeProviderGitTargetScope) AllowsClusterResource(group, resource string) bool { + return scopeAllows(s.ClusterResources, ClusterResourceIdentityFor(group, resource)) +} + +// scopeAllows reports whether scope holds value or "*". +func scopeAllows(scope []string, value string) bool { + for _, entry := range scope { + if entry == "*" || entry == value { + return true + } + } + return false +} + +// AdmitsGitTarget reports what spec.allowedGitTargets gives the GitTarget consumer, by the labels its +// namespace carries in the control cluster. +func (p *KubeProvider) AdmitsGitTarget( + consumer types.NamespacedName, nsLabels map[string]string, +) (KubeProviderGitTargetScope, error) { + grants := p.Spec.AllowedGitTargets + admitting, err := admitAll(consumersOf(grants), p.Namespace, consumer.Namespace, nsLabels) + if err != nil || len(admitting) == 0 { + return KubeProviderGitTargetScope{}, err + } + var namespaces []ScopeNamespace + var resources []ClusterResourceIdentity + for _, i := range admitting { + namespaces = append(namespaces, grants[i].SourceNamespaces...) + resources = append(resources, grants[i].ClusterResources...) + } + return KubeProviderGitTargetScope{ + Admitted: true, + SourceNamespaces: expandScope(namespaces, consumer), + ClusterResources: expandScope(resources, consumer), + }, nil +} + +// DescribeAllowedGitTargets names the namespaces spec.allowedGitTargets admits. +func (p *KubeProvider) DescribeAllowedGitTargets() string { + return describeAdmitted(consumersOf(p.Spec.AllowedGitTargets), p.Namespace) +} + +// KubeProviderApplierClaimScope is what a KubeProvider's allowedApplierClaims give an applier claim. +// +kubebuilder:object:generate=false +type KubeProviderApplierClaimScope struct { + // Admitted reports whether some grant admits the applier claim's namespace. + Admitted bool + // ApplierNamespaces is the union of the admitting grants' applierNamespaces, consumer variables + // expanded. "*" is every namespace. + ApplierNamespaces []string +} + +// AllowsApplierNamespace reports whether an applier in namespace may be held. +func (s KubeProviderApplierClaimScope) AllowsApplierNamespace(namespace string) bool { + return scopeAllows(s.ApplierNamespaces, namespace) +} + +// AdmitsApplierClaim reports what spec.allowedApplierClaims gives the applier claim consumer, by the +// labels its namespace carries in the control cluster. +func (p *KubeProvider) AdmitsApplierClaim( + consumer types.NamespacedName, nsLabels map[string]string, +) (KubeProviderApplierClaimScope, error) { + grants := p.Spec.AllowedApplierClaims + admitting, err := admitAll(consumersOf(grants), p.Namespace, consumer.Namespace, nsLabels) + if err != nil || len(admitting) == 0 { + return KubeProviderApplierClaimScope{}, err + } + var namespaces []ScopeNamespace + for _, i := range admitting { + namespaces = append(namespaces, grants[i].ApplierNamespaces...) + } + return KubeProviderApplierClaimScope{Admitted: true, ApplierNamespaces: expandScope(namespaces, consumer)}, nil +} + +// DescribeAllowedApplierClaims names the namespaces spec.allowedApplierClaims admits. +func (p *KubeProvider) DescribeAllowedApplierClaims() string { + return describeAdmitted(consumersOf(p.Spec.AllowedApplierClaims), p.Namespace) +} diff --git a/api/v1alpha3/access_grants_test.go b/api/v1alpha3/access_grants_test.go new file mode 100644 index 000000000..58663bd17 --- /dev/null +++ b/api/v1alpha3/access_grants_test.go @@ -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()) +} diff --git a/api/v1alpha3/argocdclaim_types.go b/api/v1alpha3/argocdclaim_types.go index 7fad93119..56abedc70 100644 --- a/api/v1alpha3/argocdclaim_types.go +++ b/api/v1alpha3/argocdclaim_types.go @@ -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" @@ -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, @@ -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 diff --git a/api/v1alpha3/audit_route_test.go b/api/v1alpha3/audit_route_test.go index 868f1cf34..94675a538 100644 --- a/api/v1alpha3/audit_route_test.go +++ b/api/v1alpha3/audit_route_test.go @@ -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"}, }, } diff --git a/api/v1alpha3/fluxclaim_types.go b/api/v1alpha3/fluxclaim_types.go index 783e1d950..dfe3b82c3 100644 --- a/api/v1alpha3/fluxclaim_types.go +++ b/api/v1alpha3/fluxclaim_types.go @@ -30,8 +30,9 @@ type FluxClaimSpec struct { // KubeProviderRef names the KubeProvider whose cluster holds the Kustomization, such as a // management cluster running Flux 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 and patch on the Kustomization and its GitRepository. Immutable. + // applier claim's own namespace. The provider's spec.allowedApplierClaims must admit this applier + // claim's namespace, with the Kustomization's namespace in applierNamespaces. Its identity needs + // get and patch on the Kustomization and its GitRepository. 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 FluxClaim to hold a Kustomization elsewhere" @@ -59,9 +60,11 @@ type FluxClaimStatus struct { // Conditions are Configured (an eligible GitTarget names the applier claim; the Kustomization exists, // applies a GitRepository that follows a branch, may be patched, and no other FluxClaim holds - // it), Held (Suspended: suspended under GitOps Reverser with the last suspend handled; Applying: - // resumed for an apply GitOps Reverser started; Suspending: a suspend is not handled yet; - // Releasing: being handed back; NotReferenced; NotConfigured), and the kstatus + // it), InControl (whether GitOps Reverser decides, right now, when the Kustomization applies. True: + // Suspended, suspended under GitOps Reverser with the last suspend handled; Applying, resumed for + // an apply GitOps Reverser started. False: Suspending, a suspend is not handled yet; Releasing, + // being handed back; Revoking, being handed back because a grant no longer admits it; + // NotReferenced. Unknown: NotConfigured), 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 GitRepository fetches. An applier claim no GitTarget names reads nothing @@ -117,22 +120,22 @@ type FluxClaimSource struct { Branch string `json:"branch,omitempty"` // Why information only (decision 2 of 2026-10-17 in docs/design/apply-trigger.md): one - // GitRepository for several Kustomizations is a normal setup, and a consumer no applier claim names + // GitRepository for several Kustomizations is a normal setup, and an object no applier claim names // applies a folder that has no promise of protection anyway. - // OtherConsumers are the other objects in the cluster that apply this GitRepository and that no + // AlsoAppliedBy are the other objects in the cluster that apply this GitRepository and that no // FluxClaim holds: Kustomizations, and HelmReleases whose chart comes from it. Each applies a // new artifact the moment it arrives. Information only: it never affects Ready. // +optional // +listType=atomic - OtherConsumers []meta.NamespacedObjectKindReference `json:"otherConsumers,omitempty"` + AlsoAppliedBy []meta.NamespacedObjectKindReference `json:"alsoAppliedBy,omitempty"` } // +kubebuilder:object:root=true // +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="Kustomization",type=string,JSONPath=`.spec.kustomizationRef.name` // +kubebuilder:printcolumn:name="Operation",type=string,JSONPath=`.status.operation.phase` // +kubebuilder:printcolumn:name="Commit",type=string,JSONPath=`.status.operation.commit`,priority=1 diff --git a/api/v1alpha3/gitbranch_types.go b/api/v1alpha3/gitbranch_types.go index 9c2e998c1..018346d87 100644 --- a/api/v1alpha3/gitbranch_types.go +++ b/api/v1alpha3/gitbranch_types.go @@ -10,27 +10,28 @@ import ( // GitBranchSpec defines the desired state of GitBranch. // -// Every field is immutable: together they are the repository branch this object claims, and a -// claim that could be repointed would move a branch worker's checkout, credential and pending -// writes to another branch under the GitTargets that reference it. Delete and recreate instead. +// gitProviderRef, branch and parentBranch are immutable: together they are the repository branch +// this object claims, and a claim that could be repointed would move a branch worker's checkout, +// credential and pending writes to another branch under the GitTargets that reference it. Delete +// and recreate instead. allowedGitTargets is the branch owner's policy and stays mutable. +// +// CEL cannot read metadata.namespace, so the reference is compared as written: spelling out the +// GitBranch's own namespace where it was omitted is refused too. // +kubebuilder:validation:XValidation:rule="self.gitProviderRef == oldSelf.gitProviderRef",message="spec.gitProviderRef is immutable; delete and recreate the GitBranch to change it" // +kubebuilder:validation:XValidation:rule="self.branch == oldSelf.branch",message="spec.branch is immutable; delete and recreate the GitBranch to change it" // +kubebuilder:validation:XValidation:rule="has(self.parentBranch) == has(oldSelf.parentBranch) && (!has(self.parentBranch) || self.parentBranch == oldSelf.parentBranch)",message="spec.parentBranch is immutable; delete and recreate the GitBranch to change it" type GitBranchSpec struct { - // A LocalObjectReference on purpose: creating a GitBranch then needs the right to create objects - // in the namespace that holds the credential, which is what makes a branch claim an act of the - // credential's owner. Other namespaces reach the branch through the GitProvider's accessFrom. - - // GitProviderRef names the GitProvider, in this GitBranch's namespace, whose repository and - // credential this branch is written with. GitTargets in other namespaces may reference this - // GitBranch while that GitProvider's spec.accessFrom admits their namespace. + // GitProviderRef names the GitProvider whose repository and credential this branch is written + // with. Namespace omitted means this GitBranch's own namespace. The GitBranch claims its branch + // only while that GitProvider's spec.allowedGitBranches admits this namespace and the branch. // Immutable: delete and recreate the GitBranch to change it. // +required // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.gitProviderRef.name must not be empty" - GitProviderRef meta.LocalObjectReference `json:"gitProviderRef"` + GitProviderRef meta.NamespacedObjectReference `json:"gitProviderRef"` // Branch is the branch on the remote that this GitBranch writes. It must match one of the - // GitProvider's allowedBranches. Only one GitBranch writes a repository branch: the oldest one + // patterns the GitProvider's spec.allowedGitBranches give this GitBranch's namespace. Only one + // GitBranch writes a repository branch: the oldest admitted one // whose credential proves it can open a push session holds it, and any other GitBranch for the // same repository and branch reports Stalled with reason BranchConflict. // Immutable: delete and recreate the GitBranch to change it. @@ -48,12 +49,21 @@ type GitBranchSpec struct { // default branch), or a branch with no history when the repository is empty. When set, it must // exist on the remote for spec.branch to be created; otherwise this GitBranch reports // ParentBranchNotFound and nothing is written. Once spec.branch exists, it is used as it is and - // the parent is not followed. It is only read, so it does not need to be in the GitProvider's - // allowedBranches. + // the parent is not followed. It is only read, so the GitProvider's spec.allowedGitBranches need + // not allow it. // Immutable, including adding or removing it: delete and recreate the GitBranch to change it. // +optional // +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. + // +optional + // +listType=atomic + // +kubebuilder:validation:MaxItems=64 + AllowedGitTargets []GitBranchGitTargetGrant `json:"allowedGitTargets,omitempty"` } // GitBranchStatus defines the observed state of GitBranch. @@ -171,6 +181,7 @@ type GitBranchParentStatus struct { // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:printcolumn:name="Provider",type=string,JSONPath=`.spec.gitProviderRef.name` +// +kubebuilder:printcolumn:name="ProviderNamespace",type=string,JSONPath=`.spec.gitProviderRef.namespace` // +kubebuilder:printcolumn:name="Branch",type=string,JSONPath=`.spec.branch` // +kubebuilder:printcolumn:name="Commit",type=string,JSONPath=`.status.remote.commit` // +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` @@ -201,9 +212,13 @@ type GitBranch struct { } // GitProviderKey is the GitProvider this GitBranch writes through: spec.gitProviderRef, in this -// GitBranch's own namespace. +// GitBranch's own namespace unless the reference names another one. func (b *GitBranch) GitProviderKey() types.NamespacedName { - return types.NamespacedName{Namespace: b.Namespace, Name: b.Spec.GitProviderRef.Name} + namespace := b.Spec.GitProviderRef.Namespace + if namespace == "" { + namespace = b.Namespace + } + return types.NamespacedName{Namespace: namespace, Name: b.Spec.GitProviderRef.Name} } // +kubebuilder:object:root=true diff --git a/api/v1alpha3/gitprovider_types.go b/api/v1alpha3/gitprovider_types.go index 5c4f7f8a3..0525e2a7a 100644 --- a/api/v1alpha3/gitprovider_types.go +++ b/api/v1alpha3/gitprovider_types.go @@ -14,8 +14,8 @@ import ( // targets at a different repository and orphan their existing materialization (the same // reason a GitTarget's destination is immutable). To repoint, delete and recreate the // GitProvider. Everything else here is operational and deliberately stays mutable — -// notably allowedBranches (widening or narrowing the writable set is a normal change -// that must not require tearing down every GitTarget), plus auth, push tuning, and +// notably allowedGitBranches (widening or narrowing who may claim which branches is a +// normal change that must not require tearing down every GitBranch), plus auth and // commit identity/signing. // // +kubebuilder:validation:XValidation:rule="self.url == oldSelf.url",message="spec.url is immutable; delete and recreate the GitProvider to point at a different repository" @@ -51,18 +51,16 @@ type GitProviderSpec struct { // +optional KnownHostsRef *KnownHostsReference `json:"knownHostsRef,omitempty"` - // AccessFrom is the policy for which OTHER namespaces may reference this GitProvider from a - // GitTarget. This GitProvider's own namespace is always admitted. Empty (or omitted) admits no - // other namespace. A GitTarget admitted from another namespace writes with this provider's - // credential, commit identity and allowedBranches, as one in its own namespace does. + // 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 + // spec.allowedGitTargets. // +optional - AccessFrom *NamespaceMatcher `json:"accessFrom,omitempty"` - - // AllowedBranches restricts which branches can be written to. - // +required - // +kubebuilder:validation:MinItems=1 - // +kubebuilder:validation:items:MinLength=1 - AllowedBranches []string `json:"allowedBranches"` + // +listType=atomic + // +kubebuilder:validation:MaxItems=64 + AllowedGitBranches []GitProviderGitBranchGrant `json:"allowedGitBranches,omitempty"` // Commit configures the commit identity and signing behavior this connection uses. Message // formatting moved to GitTarget.spec.commit.message. @@ -311,16 +309,6 @@ type GitProviderList struct { Items []GitProvider `json:"items"` } -// AdmitsNamespace reports whether a GitTarget in namespace (by name and labels) may write through -// this GitProvider: its own namespace always, another one only as spec.accessFrom admits it. A -// malformed selector is an error, never a silent allow. -func (p *GitProvider) AdmitsNamespace(nsName string, nsLabels map[string]string) (bool, error) { - if nsName == p.Namespace { - return true, nil - } - return p.Spec.AccessFrom.Matches(nsName, nsLabels) -} - func init() { SchemeBuilder.Register(&GitProvider{}, &GitProviderList{}) } diff --git a/api/v1alpha3/gittarget_types.go b/api/v1alpha3/gittarget_types.go index 9e05e92ab..aff291809 100644 --- a/api/v1alpha3/gittarget_types.go +++ b/api/v1alpha3/gittarget_types.go @@ -36,9 +36,9 @@ import ( // +kubebuilder:validation:XValidation:rule="!has(self.onRefusal) || self.onRefusal != 'Reapply' || has(self.applierClaimRef)",message="spec.onRefusal: Reapply needs spec.applierClaimRef, the applier claim asked to reapply" type GitTargetSpec struct { // GitBranchRef names the GitBranch this target writes: the repository branch, and the credential - // it is written with. Namespace omitted means this GitTarget's own namespace. A GitBranch in - // another namespace is used only while its GitProvider's spec.accessFrom admits this GitTarget's - // namespace. Many GitTargets may reference one GitBranch; they share its branch worker. + // it is written with. Namespace omitted means this GitTarget's own namespace. The GitBranch is + // used only while its spec.allowedGitTargets admit this GitTarget's namespace, its own included. + // Many GitTargets may reference one GitBranch; they share its branch worker. // Immutable: delete and recreate the GitTarget to change its destination. // +required // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.gitBranchRef.name must not be empty" @@ -102,8 +102,8 @@ type GitTargetSpec struct { // apply the current tip after publishing the edits pending on the branch, which reverts the // refused edit and nothing that was waiting to be published. Only this // target's applier is asked. If its GitRepository has to fetch to reach the tip, the - // Kustomizations a FluxClaim holds stay suspended and ignore the new artifact; only the - // consumers no applier claim holds, listed in the applier claim's status.source.otherConsumers, apply it, as + // Kustomizations a FluxClaim holds stay suspended and ignore the new artifact; only the objects + // no applier claim holds, listed in the applier claim's status.source.alsoAppliedBy, apply it, as // they would for any push. // // It fires only for an edit to a document the folder ALREADY holds, in a write that removes no @@ -203,9 +203,9 @@ type GitTargetSpec struct { SerializeNamespace *bool `json:"serializeNamespace,omitempty"` // KubeProviderRef names the KubeProvider this GitTarget mirrors FROM: the source cluster, and the - // credential it is read with. Namespace omitted means this GitTarget's own namespace. A - // KubeProvider in another namespace is used only while its spec.accessFrom admits this - // GitTarget's namespace. Immutable: a folder's source cluster is part of what the folder means; + // credential it is read with. Namespace omitted means this GitTarget's own namespace. The + // KubeProvider is used only while its spec.allowedGitTargets admit this GitTarget's namespace, + // its own included. Immutable: a folder's source cluster is part of what the folder means; // delete and recreate the GitTarget to change it. // +required // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.kubeProviderRef.name must not be empty" diff --git a/api/v1alpha3/helpers_test.go b/api/v1alpha3/helpers_test.go index f16a5c4f8..43c1739d7 100644 --- a/api/v1alpha3/helpers_test.go +++ b/api/v1alpha3/helpers_test.go @@ -7,7 +7,6 @@ import ( meta "github.com/fluxcd/pkg/apis/meta" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" ) @@ -69,196 +68,6 @@ func TestIsInCluster_FollowsInClusterNotName(t *testing.T) { } } -// AdmitsNamespace is the authorization predicate behind the reconcile-time refusal, and there is -// no admission webhook backstopping it — that reconcile call site is the ENTIRE boundary. So every -// contract below is a security boundary rather than a routing convenience: a provider holds a -// credential that can read a whole remote cluster, and referencing it from a GitTarget mirrors that -// cluster into the target's Git destination. Every namespace below is another than the provider's -// own ("platform"), which is always admitted and is covered separately. -func TestAdmitsNamespace_Authorization(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - policy *NamespaceMatcher - nsName string - labels map[string]string - want bool - wantErr bool - }{ - { - // Deny-by-default: a provider whose author never wrote a policy grants nothing. - // The dangerous inversion would be treating "no policy" as "no restriction", which - // would silently open a freshly created provider to every namespace in the cluster. - name: "nil policy denies", - policy: nil, - nsName: "team-a", - want: false, - }, - { - // A policy object that exists but says nothing is the same statement as no policy: - // it enumerates zero namespaces, so it admits zero namespaces. - name: "empty policy denies", - policy: &NamespaceMatcher{}, - nsName: "team-a", - want: false, - }, - { - name: "listed name is allowed", - policy: &NamespaceMatcher{Names: []string{"team-a", "team-b"}}, - nsName: "team-b", - want: true, - }, - { - // Names is an exact allow-list, never a prefix or substring match; otherwise an - // attacker could create "team-a-evil" and inherit "team-a"'s grant. - name: "unlisted name is denied", - policy: &NamespaceMatcher{Names: []string{"team-a"}}, - nsName: "team-a-evil", - want: false, - }, - { - name: "name match is case-sensitive and exact", - policy: &NamespaceMatcher{Names: []string{"team-a"}}, - nsName: "Team-A", - want: false, - }, - { - name: "selector match is allowed", - policy: &NamespaceMatcher{ - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "prod"}}, - }, - nsName: "anything", - labels: map[string]string{"tier": "prod"}, - want: true, - }, - { - name: "selector miss is denied", - policy: &NamespaceMatcher{ - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "prod"}}, - }, - nsName: "anything", - labels: map[string]string{"tier": "dev"}, - want: false, - }, - { - // Names and Selector are ORed, so a listed namespace stays allowed even when its - // labels do not match. Requiring both would be a silent tightening that breaks - // existing grants. - name: "name allows even when the selector misses", - policy: &NamespaceMatcher{ - Names: []string{"team-a"}, - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "prod"}}, - }, - nsName: "team-a", - labels: map[string]string{"tier": "dev"}, - want: true, - }, - { - name: "selector allows even when the name is unlisted", - policy: &NamespaceMatcher{ - Names: []string{"team-a"}, - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "prod"}}, - }, - nsName: "team-z", - labels: map[string]string{"tier": "prod"}, - want: true, - }, - { - // GOTCHA worth pinning loudly: an EMPTY selector is the Kubernetes "match everything" - // selector, not "match nothing". `selector: {}` therefore grants every namespace in - // the cluster. It is deliberate (it is how a platform admin says "any namespace"), - // but it looks identical to an accidentally blank field, so the behavior must be - // asserted rather than rediscovered in production. - name: "empty selector matches every namespace", - policy: &NamespaceMatcher{Selector: &metav1.LabelSelector{}}, - nsName: "any-namespace-at-all", - want: true, - }, - { - // ...including a namespace that carries no labels at all. - name: "empty selector matches a namespace with no labels", - policy: &NamespaceMatcher{Selector: &metav1.LabelSelector{}}, - nsName: "bare", - labels: nil, - want: true, - }, - { - // A malformed selector must FAIL CLOSED: the error is surfaced to the caller AND the - // allow answer is false. Returning true on a parse failure would turn a typo in a - // provider's policy into a cluster-wide grant. - name: "invalid selector fails closed", - policy: &NamespaceMatcher{ - Selector: &metav1.LabelSelector{ - MatchExpressions: []metav1.LabelSelectorRequirement{ - {Key: "tier", Operator: "NotAnOperator", Values: []string{"prod"}}, - }, - }, - }, - nsName: "team-a", - labels: map[string]string{"tier": "prod"}, - want: false, - wantErr: true, - }, - { - // The name allow-list short-circuits before the selector is parsed, so a listed - // namespace is admitted even when the selector alongside it is malformed. Pinned - // because it is the one path where a broken policy does not surface an error. - name: "listed name short-circuits an invalid selector", - policy: &NamespaceMatcher{ - Names: []string{"team-a"}, - Selector: &metav1.LabelSelector{ - MatchExpressions: []metav1.LabelSelectorRequirement{ - {Key: "tier", Operator: "NotAnOperator"}, - }, - }, - }, - nsName: "team-a", - labels: map[string]string{"tier": "prod"}, - want: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - provider := &KubeProvider{ - ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "remote"}, - Spec: KubeProviderSpec{AccessFrom: tc.policy}, - } - - allowed, err := provider.AdmitsNamespace(tc.nsName, tc.labels) - if tc.wantErr { - require.Error(t, err) - } else { - require.NoError(t, err) - } - assert.Equal(t, tc.want, allowed) - }) - } -} - -// AdmitsNamespace admits the provider's own namespace before accessFrom is read at all: a tenant's -// provider serves its own namespace with no policy written, and even a malformed policy (which fails -// closed for every other namespace) cannot lock the owner out. -func TestAdmitsNamespace_OwnNamespaceAlwaysAdmitted(t *testing.T) { - t.Parallel() - - invalid := &NamespaceMatcher{Selector: &metav1.LabelSelector{ - MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "tier", Operator: "NotAnOperator"}}, - }} - for _, policy := range []*NamespaceMatcher{nil, {}, invalid} { - provider := &KubeProvider{ - ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "remote"}, - Spec: KubeProviderSpec{AccessFrom: policy}, - } - admitted, err := provider.AdmitsNamespace("platform", nil) - require.NoError(t, err) - assert.True(t, admitted, "policy %+v", policy) - } -} - // SourceCluster is the id the watch data plane keys a source cluster on: the referenced // KubeProvider's namespace/name, its namespace defaulted to the GitTarget's own. It keys the GVK→GVR // registry and the watch context, so two providers sharing a name in two namespaces must yield two diff --git a/api/v1alpha3/kubeprovider_types.go b/api/v1alpha3/kubeprovider_types.go index 0924006bb..716b6dbc1 100644 --- a/api/v1alpha3/kubeprovider_types.go +++ b/api/v1alpha3/kubeprovider_types.go @@ -43,20 +43,24 @@ type KubeProviderSpec struct { // +optional InCluster *KubeProviderInCluster `json:"inCluster,omitempty"` - // The policy that decides who may WIELD the credential: source-cluster RBAC bounds what it may - // read, and cannot say which control-cluster tenant may use it. The selector reads control-cluster - // labels locally, so it has no degradation path. - - // AccessFrom admits other control-cluster namespaces to reference this provider. Its own - // namespace is always admitted; omitted, no other namespace is. Its selector matches labels on - // Namespaces in the control cluster, never on the source cluster this provider names. + // 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. // +optional - AccessFrom *NamespaceMatcher `json:"accessFrom,omitempty"` - - // AllowCrossNamespace grants what a namespace this provider admits may reach outside itself, - // through this provider's credential. Omitted, it may reach nothing outside itself. + // +listType=atomic + // +kubebuilder:validation:MaxItems=64 + AllowedGitTargets []KubeProviderGitTargetGrant `json:"allowedGitTargets,omitempty"` + + // 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. // +optional - AllowCrossNamespace *KubeProviderCrossNamespace `json:"allowCrossNamespace,omitempty"` + // +listType=atomic + // +kubebuilder:validation:MaxItems=64 + AllowedApplierClaims []KubeProviderApplierClaimGrant `json:"allowedApplierClaims,omitempty"` // 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. @@ -75,33 +79,6 @@ type KubeProviderSpec struct { // is the whole statement. type KubeProviderInCluster struct{} -// Each field is a power of its own, granted on purpose: sources lets a namespace READ other -// namespaces' objects into its own Git, appliers lets it suspend and apply other namespaces' -// deployments. On an inCluster provider same-name coupling WAS the boundary, so either one -// bypasses live namespace RBAC: the owner of an admitted object acts on another namespace through -// the operator's own credential. It is a delegation, not owner consent: it does not prove that the -// namespace owns the objects it reaches, or the repository it writes. - -// KubeProviderCrossNamespace is what a namespace a KubeProvider admits may reach outside itself. -// The trust is in every admitted namespace, for everything in reach of the provider's identity; it -// does not prove that the namespace owns what it reaches. -type KubeProviderCrossNamespace struct { - // Sources lets a WatchRule mirroring through this provider watch namespaces other than its own, - // including a rules[].sourceNamespace of "*", which reaches every namespace the credential can - // read. False (the default): a WatchRule watches only its own namespace. It grants no access by - // itself: the source credential's RBAC remains the hard maximum. It does not apply to - // ClusterWatchRule, which selects no namespaces at all. - // +optional - Sources bool `json:"sources,omitempty"` - - // Appliers lets a FluxClaim or ArgoCDClaim reaching its applier through this provider name a - // Kustomization or Application in another namespace than its own. False (the default): only - // the applier claim's own namespace, which in a remote cluster is the namespace of the same name; an - // applier claim naming another is Configured=False (ApplierNamespaceNotAllowed). - // +optional - Appliers bool `json:"appliers,omitempty"` -} - // KubeProviderClient holds the per-cluster kube-client throttles. type KubeProviderClient struct { // QPS overrides the operator's outgoing kube-client query-per-second throttle for this @@ -194,10 +171,10 @@ type KubeProviderStatus struct { // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // KubeProvider is a connection to a Kubernetes cluster and the identity used there. A GitTarget -// mirrors from it; an applier claim reaches its applier through it. It holds the credential that reaches it (a kubeconfig Secret in its own -// namespace, or the operator's own identity where the installation allows it), which control-cluster -// namespaces may use it (its own, and those spec.accessFrom admits), and the audit route its -// attribution facts are partitioned under. +// mirrors from it; an applier claim reaches its applier through it. It holds the credential that +// reaches it (a kubeconfig Secret in its own namespace, or the operator's own identity where the +// installation allows it), who may use it and for what (spec.allowedGitTargets and +// spec.allowedApplierClaims), and the audit route its attribution facts are partitioned under. type KubeProvider struct { metav1.TypeMeta `json:",inline"` @@ -253,29 +230,6 @@ func DefaultAuditRoute(provider types.NamespacedName) string { return provider.Namespace + "." + provider.Name } -// AdmitsNamespace reports whether a consumer in namespace (by name and labels) may use this -// provider: its own namespace always, another one only as spec.accessFrom admits it. A malformed -// selector is an error, never a silent allow. The labels are always CONTROL-cluster Namespace -// labels. -func (p *KubeProvider) AdmitsNamespace(nsName string, nsLabels map[string]string) (bool, error) { - if nsName == p.Namespace { - return true, nil - } - return p.Spec.AccessFrom.Matches(nsName, nsLabels) -} - -// AllowsCrossNamespaceSources reports whether a WatchRule mirroring through this provider may -// watch namespaces other than its own (spec.allowCrossNamespace.sources). -func (p *KubeProvider) AllowsCrossNamespaceSources() bool { - return p.Spec.AllowCrossNamespace != nil && p.Spec.AllowCrossNamespace.Sources -} - -// AllowsCrossNamespaceAppliers reports whether an applier claim reaching its applier through this provider -// may name one in another namespace than its own (spec.allowCrossNamespace.appliers). -func (p *KubeProvider) AllowsCrossNamespaceAppliers() bool { - return p.Spec.AllowCrossNamespace != nil && p.Spec.AllowCrossNamespace.Appliers -} - // AuditRouteHolder returns the namespace that holds route among providers, and false when none of // them carries it. A route is held by one namespace, so a provider in another namespace cannot read // facts that belong to a tenant it was never admitted to: diff --git a/api/v1alpha3/kubeprovider_types_test.go b/api/v1alpha3/kubeprovider_types_test.go index 8c6672a71..a093db19e 100644 --- a/api/v1alpha3/kubeprovider_types_test.go +++ b/api/v1alpha3/kubeprovider_types_test.go @@ -8,7 +8,6 @@ import ( meta "github.com/fluxcd/pkg/apis/meta" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" ) @@ -37,34 +36,12 @@ func TestKubeProvider_AuditRoute(t *testing.T) { assert.Equal(t, "prod-eu-1", new(kubeProvider("tenant-a", "prod", "prod-eu-1", t0)).AuditRoute()) } -func TestKubeProvider_AdmitsNamespace(t *testing.T) { - p := kubeProvider("platform", "remote", "", time.Now()) - - admitted, err := p.AdmitsNamespace("platform", nil) - require.NoError(t, err) - assert.True(t, admitted, "a provider admits its own namespace without accessFrom") - - admitted, err = p.AdmitsNamespace("tenant-a", nil) - require.NoError(t, err) - assert.False(t, admitted, "omitted accessFrom admits no other namespace") - - p.Spec.AccessFrom = &NamespaceMatcher{Names: []string{"tenant-a"}} - admitted, err = p.AdmitsNamespace("tenant-a", nil) - require.NoError(t, err) - assert.True(t, admitted) -} - -func TestKubeProvider_CredentialAndGrants(t *testing.T) { +func TestKubeProvider_CredentialAndKey(t *testing.T) { p := kubeProvider("platform", "local", "", time.Now()) assert.False(t, p.IsInCluster()) - assert.False(t, p.AllowsCrossNamespaceSources()) - assert.False(t, p.AllowsCrossNamespaceAppliers()) p.Spec.InCluster = &KubeProviderInCluster{} - p.Spec.AllowCrossNamespace = &KubeProviderCrossNamespace{Sources: true, Appliers: true} assert.True(t, p.IsInCluster()) - assert.True(t, p.AllowsCrossNamespaceSources()) - assert.True(t, p.AllowsCrossNamespaceAppliers()) assert.Equal(t, types.NamespacedName{Namespace: "platform", Name: "local"}, p.Key()) } diff --git a/api/v1alpha3/namespace_matcher.go b/api/v1alpha3/namespace_matcher.go deleted file mode 100644 index 6b06f9a89..000000000 --- a/api/v1alpha3/namespace_matcher.go +++ /dev/null @@ -1,81 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package v1alpha3 - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/labels" -) - -// NamespaceMatcher is the deny-by-default namespace-policy SHAPE this API uses where a field bounds -// "which namespaces". It carries an explicit name allow-list and a label selector, ORed: a -// namespace is admitted if it is listed OR its labels match. -// -// It is deny-by-default and the empty matcher is NOT "unrestricted": a matcher with neither names -// nor selector admits NOTHING. Its one use is authorization, and the fail-open reading is the -// catastrophic one, so an absent field means "no policy declared" (which admits nothing here) while -// a declared-but-empty one also admits nothing. -// -// One field uses it: KubeProvider.spec.accessFrom, whose selector matches labels on Namespaces -// in the CONTROL cluster. It had a source-side twin, GitTarget.spec.allowedSourceNamespaces, -// evaluated against Namespace labels in another cluster; that twin and everything it forced (a -// three-valued verdict, a degradation path, source-cluster Namespace get/list/watch) were deleted. -// Matches therefore still takes the labels rather than fetching them, which is now merely -// convenient rather than load-bearing. -type NamespaceMatcher struct { - // `*` is rejected because Kubernetes treats it as a LITERAL name: `names: ["*"]` would admit - // nothing while reading as if it admitted everything. `selector: {}` is the "every namespace" - // form, because it resolves live. - - // Names is an explicit allow-list of namespace names. Entries are namespace names (DNS-1123 - // labels), never patterns — `*` is rejected. To admit every namespace, declare `selector: {}`. - // +optional - // +listType=set - // +kubebuilder:validation:items:MinLength=1 - // +kubebuilder:validation:items:MaxLength=63 - // +kubebuilder:validation:items:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$` - Names []string `json:"names,omitempty"` - - // Selector is a label selector matched against Namespace labels; a namespace whose labels - // match is admitted. ORed with Names. - // +optional - Selector *metav1.LabelSelector `json:"selector,omitempty"` -} - -// MatchesName reports whether nsName is in the matcher's explicit Names allow-list. -// -// It is separate from Matches so the answer never depends on the labels when a name already -// admits. A nil matcher matches nothing. -func (m *NamespaceMatcher) MatchesName(nsName string) bool { - if m == nil { - return false - } - for _, n := range m.Names { - if n == nsName { - return true - } - } - return false -} - -// Matches reports whether a namespace (by name and by the labels it carries IN THE CLUSTER THIS -// FIELD DESCRIBES) is admitted. Names are checked before the selector, so the answer never depends -// on the labels when a name already admits. A malformed selector is returned as an error rather -// than a silent allow or a silent deny — it is a configuration mistake the operator must see. -// A nil matcher admits nothing. -func (m *NamespaceMatcher) Matches(nsName string, nsLabels map[string]string) (bool, error) { - if m == nil { - return false, nil - } - if m.MatchesName(nsName) { - return true, nil - } - if m.Selector == nil { - return false, nil - } - sel, err := metav1.LabelSelectorAsSelector(m.Selector) - if err != nil { - return false, err - } - return sel.Matches(labels.Set(nsLabels)), nil -} diff --git a/api/v1alpha3/namespace_matcher_test.go b/api/v1alpha3/namespace_matcher_test.go deleted file mode 100644 index 4e2c848db..000000000 --- a/api/v1alpha3/namespace_matcher_test.go +++ /dev/null @@ -1,138 +0,0 @@ -// 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" -) - -// TestNamespaceMatcher_DenyByDefault pins the semantics accessFrom depends on. The fail-open -// reading is the catastrophic one, so the nil and empty cases get their own assertions rather than -// riding along with a general case. -func TestNamespaceMatcher_DenyByDefault(t *testing.T) { - var nilMatcher *NamespaceMatcher - - allowed, err := nilMatcher.Matches("anything", nil) - require.NoError(t, err) - assert.False(t, allowed, "a nil matcher admits nothing") - - empty := &NamespaceMatcher{} - allowed, err = empty.Matches("anything", map[string]string{"a": "b"}) - require.NoError(t, err) - assert.False(t, allowed, "an EMPTY declared policy admits nothing: empty is not unrestricted") -} - -// TestNamespaceMatcher_NamesAndSelectorAreOred covers the OR contract and that the NAME half never -// consults labels, so a listed namespace is admitted whatever it is labelled. -func TestNamespaceMatcher_NamesAndSelectorAreOred(t *testing.T) { - matcher := &NamespaceMatcher{ - Names: []string{"repo-config"}, - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirrorable": "true"}}, - } - - tests := []struct { - name string - nsName string - labels map[string]string - allowed bool - }{ - {"listed by name, no labels at all", "repo-config", nil, true}, - {"matched by selector", "other", map[string]string{"mirrorable": "true"}, true}, - {"neither", "other", map[string]string{"mirrorable": "false"}, false}, - {"listed by name despite non-matching labels", "repo-config", - map[string]string{"mirrorable": "false"}, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - allowed, err := matcher.Matches(tt.nsName, tt.labels) - require.NoError(t, err) - assert.Equal(t, tt.allowed, allowed) - }) - } - - assert.True(t, matcher.MatchesName("repo-config")) - assert.False(t, matcher.MatchesName("other")) -} - -// TestNamespaceMatcher_InvalidSelectorIsAnError: a malformed selector must surface, not silently -// allow or silently deny. Both silent outcomes are configuration bugs an operator never sees. -func TestNamespaceMatcher_InvalidSelectorIsAnError(t *testing.T) { - matcher := &NamespaceMatcher{ - Selector: &metav1.LabelSelector{ - MatchExpressions: []metav1.LabelSelectorRequirement{{ - Key: "x", Operator: "NotARealOperator", Values: []string{"y"}, - }}, - }, - } - - _, err := matcher.Matches("any", map[string]string{"x": "y"}) - - require.Error(t, err) -} - -// A present-but-EMPTY selector admits EVERY namespace, while a nil one admits none. -// LabelSelectorAsSelector returns labels.Everything() for the first and labels.Nothing() for the -// second, and a provider shared with every namespace says so with `accessFrom: {selector: {}}`, so -// if this ever flipped every such install would stop admitting anything. -func TestNamespaceMatcher_EmptySelectorMatchesEverything(t *testing.T) { - declared := &NamespaceMatcher{Selector: &metav1.LabelSelector{}} - admits, err := declared.Matches("any", nil) - require.NoError(t, err) - assert.True(t, admits, "a present-but-empty selector admits EVERY namespace, labels or not") - - admits, err = declared.Matches("any", map[string]string{"anything": "at-all"}) - require.NoError(t, err) - assert.True(t, admits) - - absent := &NamespaceMatcher{Names: []string{"repo-config"}} - admits, err = absent.Matches("other", map[string]string{"anything": "at-all"}) - require.NoError(t, err) - assert.False(t, admits, "a nil selector admits nothing beyond the listed names") -} - -// TestResourceRule_EffectiveSourceNamespace pins the per-item defaulting every consumer keys on. -// Getting this wrong produces a stale watch, not a visible failure. -func TestResourceRule_EffectiveSourceNamespace(t *testing.T) { - const own = "tenant-acme" - item := &ResourceRule{Resources: []string{"configmaps"}} - - assert.Equal(t, own, item.EffectiveSourceNamespace(own), "omitted means the rule's own") - assert.False(t, item.OverridesSourceNamespace(own)) - assert.False(t, item.IsSourceNamespaceWildcard()) - - item.SourceNamespace = "repo-config" - assert.Equal(t, "repo-config", item.EffectiveSourceNamespace(own)) - assert.True(t, item.OverridesSourceNamespace(own)) - assert.False(t, item.IsSourceNamespaceWildcard()) - - // Restating the rule's own namespace is NOT an override: it needs no delegation flag. - item.SourceNamespace = own - assert.Equal(t, own, item.EffectiveSourceNamespace(own)) - assert.False(t, item.OverridesSourceNamespace(own), - "naming your own namespace explicitly must behave exactly like omitting it") - - // "*" is ALWAYS an override: it reaches every namespace the source credential can read, which - // is the widest request in the API and the last one that should slip past the delegation flag. - item.SourceNamespace = SourceNamespaceWildcard - assert.True(t, item.IsSourceNamespaceWildcard()) - assert.True(t, item.OverridesSourceNamespace(own)) -} - -// The security default in one line: the delegation flag must be false on a provider that never -// mentions it, so a WatchRule may watch only its own namespace until a platform admin says -// otherwise. -func TestKubeProvider_DelegationFlagDefaultsClosed(t *testing.T) { - provider := &KubeProvider{} - assert.False(t, provider.AllowsCrossNamespaceSources(), - "source-namespace delegation must never be on by default") - assert.False(t, provider.AllowsCrossNamespaceAppliers(), - "nor may an applier claim reach another namespace's applier by default") - provider.Spec.AllowCrossNamespace = &KubeProviderCrossNamespace{Appliers: true} - assert.False(t, provider.AllowsCrossNamespaceSources(), "each power is granted on its own") - assert.True(t, provider.AllowsCrossNamespaceAppliers()) -} diff --git a/api/v1alpha3/watchrule_types.go b/api/v1alpha3/watchrule_types.go index 440b85aac..0dc03d0df 100644 --- a/api/v1alpha3/watchrule_types.go +++ b/api/v1alpha3/watchrule_types.go @@ -12,8 +12,8 @@ import ( // WatchRuleSpec defines the desired state of WatchRule. // WatchRule selects NAMESPACED resources on its GitTarget's source cluster. Each rules[] item // carries its own source namespace: omitted for this WatchRule's own namespace, an explicit name, -// or "*" for every namespace the source credential can read (refused unless the KubeProvider -// sets allowCrossNamespace.sources). +// or "*" for every namespace the source credential can read. Each is watched only while the +// KubeProvider's spec.allowedGitTargets give the GitTarget's namespace that source namespace. type WatchRuleSpec struct { // GitTargetRef names the GitTarget this rule feeds, in this WatchRule's own namespace. // +required @@ -109,9 +109,14 @@ type ResourceRule struct { // "*" for every namespace the source credential can read. // // "*" is one cluster-wide list and watch rather than a set of per-namespace ones, so its bound - // is the credential's own RBAC and nothing else. Naming any namespace other than this rule's - // own, "*" included, requires the GitTarget's KubeProvider to admit the target's namespace - // AND to set spec.allowCrossNamespace.sources; while that is false, "*" is refused outright. + // is the credential's own RBAC and nothing else. + // + // Every value, this rule's own namespace included, must be in the union of sourceNamespaces from + // all grants in the GitTarget's KubeProvider spec.allowedGitTargets that admit the GitTarget + // ("{consumer.namespace}" there is the target's own namespace). "*" needs "*" in at least one of + // them; a union without it refuses the rule, it does not narrow the watch. One item outside the + // union refuses the whole WatchRule (SourceNamespaceNotAllowed). The check runs when the rule + // reconciles, never at admission. // // This changes only which namespace is WATCHED, never where objects are written: Git placement // follows each mirrored object's own namespace. @@ -126,8 +131,8 @@ type ResourceRule struct { // // It kept its spelling and changed its meaning: it used to resolve live through // GitTarget.spec.allowedSourceNamespaces into a concrete set. That field is gone, so the token is -// bounded by source-cluster RBAC alone and is refused while the KubeProvider does not set -// spec.allowCrossNamespace.sources. See docs/design/source-scope-simplification.md, which is the +// bounded by source-cluster RBAC alone and is refused unless the KubeProvider's grant gives +// sourceNamespaces "*". See docs/design/source-scope-simplification.md, which is the // definition of record. const SourceNamespaceWildcard = "*" @@ -151,14 +156,6 @@ func (r *ResourceRule) IsSourceNamespaceWildcard() bool { return r.SourceNamespace == SourceNamespaceWildcard } -// OverridesSourceNamespace reports whether this item asks for a source namespace OTHER than the -// WatchRule's own — the case that needs the KubeProvider's delegation flag. A sourceNamespace -// that merely restates the rule's own namespace is not an override and stays the legacy case; "*" -// always is one, since it reaches every namespace the credential can read. -func (r *ResourceRule) OverridesSourceNamespace(ruleNamespace string) bool { - return r.IsSourceNamespaceWildcard() || r.EffectiveSourceNamespace(ruleNamespace) != ruleNamespace -} - // DescribeSourceNamespace renders this item's requested source namespace for an operator-facing // message. An omitted value is spelled out rather than shown as an empty string. func (r *ResourceRule) DescribeSourceNamespace(ruleNamespace string) string { diff --git a/api/v1alpha3/zz_generated.deepcopy.go b/api/v1alpha3/zz_generated.deepcopy.go index f4af1e505..057072f67 100644 --- a/api/v1alpha3/zz_generated.deepcopy.go +++ b/api/v1alpha3/zz_generated.deepcopy.go @@ -583,6 +583,31 @@ func (in *CommitterSpec) DeepCopy() *CommitterSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConsumerNamespaces) DeepCopyInto(out *ConsumerNamespaces) { + *out = *in + if in.Names != nil { + in, out := &in.Names, &out.Names + *out = make([]GrantNamespace, len(*in)) + copy(*out, *in) + } + if in.Selector != nil { + in, out := &in.Selector, &out.Selector + *out = new(v1.LabelSelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConsumerNamespaces. +func (in *ConsumerNamespaces) DeepCopy() *ConsumerNamespaces { + if in == nil { + return nil + } + out := new(ConsumerNamespaces) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *EncryptionSpec) DeepCopyInto(out *EncryptionSpec) { *out = *in @@ -667,8 +692,8 @@ func (in *FluxClaimList) DeepCopyObject() runtime.Object { func (in *FluxClaimSource) DeepCopyInto(out *FluxClaimSource) { *out = *in out.GitRepository = in.GitRepository - if in.OtherConsumers != nil { - in, out := &in.OtherConsumers, &out.OtherConsumers + if in.AlsoAppliedBy != nil { + in, out := &in.AlsoAppliedBy, &out.AlsoAppliedBy *out = make([]meta.NamespacedObjectKindReference, len(*in)) copy(*out, *in) } @@ -743,7 +768,7 @@ func (in *GitBranch) DeepCopyInto(out *GitBranch) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) } @@ -765,6 +790,22 @@ func (in *GitBranch) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitBranchGitTargetGrant) DeepCopyInto(out *GitBranchGitTargetGrant) { + *out = *in + in.ConsumerNamespaces.DeepCopyInto(&out.ConsumerNamespaces) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitBranchGitTargetGrant. +func (in *GitBranchGitTargetGrant) DeepCopy() *GitBranchGitTargetGrant { + if in == nil { + return nil + } + out := new(GitBranchGitTargetGrant) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GitBranchList) DeepCopyInto(out *GitBranchList) { *out = *in @@ -840,6 +881,13 @@ func (in *GitBranchRemoteStatus) DeepCopy() *GitBranchRemoteStatus { func (in *GitBranchSpec) DeepCopyInto(out *GitBranchSpec) { *out = *in out.GitProviderRef = in.GitProviderRef + if in.AllowedGitTargets != nil { + in, out := &in.AllowedGitTargets, &out.AllowedGitTargets + *out = make([]GitBranchGitTargetGrant, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitBranchSpec. @@ -910,6 +958,27 @@ func (in *GitProvider) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitProviderGitBranchGrant) DeepCopyInto(out *GitProviderGitBranchGrant) { + *out = *in + in.ConsumerNamespaces.DeepCopyInto(&out.ConsumerNamespaces) + if in.BranchPatterns != nil { + in, out := &in.BranchPatterns, &out.BranchPatterns + *out = make([]BranchPattern, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitProviderGitBranchGrant. +func (in *GitProviderGitBranchGrant) DeepCopy() *GitProviderGitBranchGrant { + if in == nil { + return nil + } + out := new(GitProviderGitBranchGrant) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GitProviderList) DeepCopyInto(out *GitProviderList) { *out = *in @@ -955,15 +1024,12 @@ func (in *GitProviderSpec) DeepCopyInto(out *GitProviderSpec) { *out = new(KnownHostsReference) **out = **in } - if in.AccessFrom != nil { - in, out := &in.AccessFrom, &out.AccessFrom - *out = new(NamespaceMatcher) - (*in).DeepCopyInto(*out) - } - if in.AllowedBranches != nil { - in, out := &in.AllowedBranches, &out.AllowedBranches - *out = make([]string, len(*in)) - copy(*out, *in) + if in.AllowedGitBranches != nil { + in, out := &in.AllowedGitBranches, &out.AllowedGitBranches + *out = make([]GitProviderGitBranchGrant, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } if in.Commit != nil { in, out := &in.Commit, &out.Commit @@ -1395,6 +1461,27 @@ func (in *KubeProvider) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubeProviderApplierClaimGrant) DeepCopyInto(out *KubeProviderApplierClaimGrant) { + *out = *in + in.ConsumerNamespaces.DeepCopyInto(&out.ConsumerNamespaces) + if in.ApplierNamespaces != nil { + in, out := &in.ApplierNamespaces, &out.ApplierNamespaces + *out = make([]ScopeNamespace, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubeProviderApplierClaimGrant. +func (in *KubeProviderApplierClaimGrant) DeepCopy() *KubeProviderApplierClaimGrant { + if in == nil { + return nil + } + out := new(KubeProviderApplierClaimGrant) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *KubeProviderAttribution) DeepCopyInto(out *KubeProviderAttribution) { *out = *in @@ -1436,16 +1523,27 @@ func (in *KubeProviderClient) DeepCopy() *KubeProviderClient { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *KubeProviderCrossNamespace) DeepCopyInto(out *KubeProviderCrossNamespace) { +func (in *KubeProviderGitTargetGrant) DeepCopyInto(out *KubeProviderGitTargetGrant) { *out = *in + in.ConsumerNamespaces.DeepCopyInto(&out.ConsumerNamespaces) + if in.SourceNamespaces != nil { + in, out := &in.SourceNamespaces, &out.SourceNamespaces + *out = make([]ScopeNamespace, len(*in)) + copy(*out, *in) + } + if in.ClusterResources != nil { + in, out := &in.ClusterResources, &out.ClusterResources + *out = make([]ClusterResourceIdentity, len(*in)) + copy(*out, *in) + } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubeProviderCrossNamespace. -func (in *KubeProviderCrossNamespace) DeepCopy() *KubeProviderCrossNamespace { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubeProviderGitTargetGrant. +func (in *KubeProviderGitTargetGrant) DeepCopy() *KubeProviderGitTargetGrant { if in == nil { return nil } - out := new(KubeProviderCrossNamespace) + out := new(KubeProviderGitTargetGrant) in.DeepCopyInto(out) return out } @@ -1510,15 +1608,19 @@ func (in *KubeProviderSpec) DeepCopyInto(out *KubeProviderSpec) { *out = new(KubeProviderInCluster) **out = **in } - if in.AccessFrom != nil { - in, out := &in.AccessFrom, &out.AccessFrom - *out = new(NamespaceMatcher) - (*in).DeepCopyInto(*out) + if in.AllowedGitTargets != nil { + in, out := &in.AllowedGitTargets, &out.AllowedGitTargets + *out = make([]KubeProviderGitTargetGrant, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } - if in.AllowCrossNamespace != nil { - in, out := &in.AllowCrossNamespace, &out.AllowCrossNamespace - *out = new(KubeProviderCrossNamespace) - **out = **in + if in.AllowedApplierClaims != nil { + in, out := &in.AllowedApplierClaims, &out.AllowedApplierClaims + *out = make([]KubeProviderApplierClaimGrant, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } if in.Client != nil { in, out := &in.Client, &out.Client @@ -1564,31 +1666,6 @@ func (in *KubeProviderStatus) DeepCopy() *KubeProviderStatus { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *NamespaceMatcher) DeepCopyInto(out *NamespaceMatcher) { - *out = *in - if in.Names != nil { - in, out := &in.Names, &out.Names - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.Selector != nil { - in, out := &in.Selector, &out.Selector - *out = new(v1.LabelSelector) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceMatcher. -func (in *NamespaceMatcher) DeepCopy() *NamespaceMatcher { - if in == nil { - return nil - } - out := new(NamespaceMatcher) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PrunePolicy) DeepCopyInto(out *PrunePolicy) { *out = *in diff --git a/charts/gitops-reverser/README.md b/charts/gitops-reverser/README.md index ccf98fcf0..047b8866b 100644 --- a/charts/gitops-reverser/README.md +++ b/charts/gitops-reverser/README.md @@ -184,17 +184,18 @@ nodeSelector: | `controllerManager.branchBufferMaxSize` | Maximum in-memory event buffer per branch worker. Bounds the open commit window plus the writes retained for replay until a push succeeds. While the remote cannot be reached, retained writes that reach it close the worker to new writes, saves and resyncs until they are published; the watch delivers a refused event again | `8Mi` | | `controllerManager.commitRequestTTL` | How long a `CommitRequest` is kept once it finishes. Written onto the request as the `configbutler.ai/delete-after` annotation; remove or edit that to keep one. `"0"` writes none | `48h` | | `git.refreshInterval` | How often an idle `GitTarget` re-proves where its branch is on the Git remote. `"0"` disables it | `10m` | -| `applierAccess.flux` | Grant the operator `get`, `list` and `patch` on Flux `Kustomizations`, `get` and `patch` on `GitRepositories`, `list` on `HelmReleases` and `HelmCharts` (to find a source's other consumers), and `create` on `SelfSubjectAccessReviews`, so it can hold the `Kustomization` a `FluxClaim` names suspended and resume it for each apply | `false` | +| `applierAccess.flux` | Grant the operator `get`, `list` and `patch` on Flux `Kustomizations`, `get` and `patch` on `GitRepositories`, `list` on `HelmReleases` and `HelmCharts` (to find what else applies a source), and `create` on `SelfSubjectAccessReviews`, so it can hold the `Kustomization` a `FluxClaim` names suspended and resume it for each apply | `false` | | `applierAccess.argocd` | Grant the operator `get`, `update` and `patch` on Argo CD `Applications`, and `create` on `SelfSubjectAccessReviews`, so it can hold the automated sync of the `Application` an `ArgoCDClaim` names off and start its syncs | `false` | | `auditService.type` | Service type for the dedicated audit Service | `NodePort` | | `auditService.nodePort` | Fixed NodePort for the audit Service when `auditService.type=NodePort` | `30444` | | `auditService.clusterIP` | Optional fixed ClusterIP for the dedicated audit Service | `""` | -| `quickstart.enabled` | Create starter `GitProvider`, `GitBranch`, `GitTarget`, and `WatchRule` resources. The `GitTarget` mirrors through the `KubeProvider` the chart renders, so it requires `kubeProvider.create` | `false` | +| `quickstart.enabled` | Create starter `GitProvider`, `GitBranch`, `GitTarget`, and `WatchRule` resources, and grant exactly `quickstart.namespace` its own namespace on the `KubeProvider` the chart renders. The `GitTarget` mirrors through that provider, so it requires `kubeProvider.create` | `false` | | `quickstart.namespace` | Namespace for the starter quickstart resources (create it, and the git-creds Secret, before install) | `gitops-reverser-quickstart-demo` | | `quickstart.createNamespace` | Let the chart create `quickstart.namespace` (Helm then owns it) | `false` | | `quickstart.gitProvider.url` | Repository URL used by the starter `GitProvider` | `""` | | `quickstart.gitProvider.secretRef.name` | Existing Secret name used by the starter `GitProvider` | `git-creds` | -| `quickstart.gitBranch.branch` | Branch the starter `GitBranch` writes. Must match `quickstart.gitProvider.allowedBranches` | `main` | +| `quickstart.gitProvider.branchPatterns` | Branch patterns the quickstart namespace may claim through the starter `GitProvider` (the `branchPatterns` of a `from: Same` grant) | `["*"]` | +| `quickstart.gitBranch.branch` | Branch the starter `GitBranch` writes. Must match `quickstart.gitProvider.branchPatterns` | `main` | | `quickstart.gitTarget.path` | Repository path used by the starter `GitTarget`; set `.` only to deliberately target the repo root | `live-cluster` | | `quickstart.watchRule.rules` | Rules used by the starter `WatchRule` | `configmaps create/update/delete` | | `queue.redis.addr` | Redis/Valkey endpoint (`host:port`). Optional but advised: empty means cold-replay on restart. Set it for warm-restart cursors and for the admission webhook to actually record CommitRequest authors (admission runs as a no-op without it); **required** when `attribution.enabled=true` unless `attribution.transport=memory` | `""` | @@ -217,9 +218,8 @@ nodeSelector: | `kubeProvider.name` | Name of the rendered provider. Its audit route is `.`, the `/audit-webhook/` path the API server posts to | `local` | | `kubeProvider.secretRef.name` | Secret in the release namespace holding a kubeconfig for a **remote** cluster. Empty renders `spec.inCluster`, the operator's **own** identity, and lists the provider in `--in-cluster-kube-providers`. Immutable once created: delete and recreate to repoint | `""` | | `kubeProvider.secretRef.key` | Key within that Secret. Empty reads `value` then `value.yaml` | `""` | -| `kubeProvider.accessFrom` | Policy (`names` and/or `selector`) for which **other** control-cluster namespaces may reference this provider; the release namespace always may. The default empty selector admits every namespace | `{selector: {}}` | -| `kubeProvider.allowCrossNamespace.sources` | Let a `WatchRule` in an admitted namespace watch other namespaces, `"*"` included. A delegation to every admitted namespace, not proof that it owns what it reads | `false` | -| `kubeProvider.allowCrossNamespace.appliers` | Let a `FluxClaim` or `ArgoCDClaim` in an admitted namespace suspend and apply a `Kustomization` or `Application` in another namespace, such as `flux-system`. A delegation to every admitted namespace, not proof that it owns the applier or the repository | `false` | +| `kubeProvider.allowedGitTargets` | Rendered as the provider's `spec.allowedGitTargets`: which namespaces' `GitTarget`s may mirror through it, and what. Each grant has a `consumerNamespaces` block (`from`: `Same`, `Names`, `Selector`, `All`), and `sourceNamespaces`, `clusterResources` or both beside it. **Empty grants nothing, the release namespace included**: a `GitTarget` through `local` is refused with a message naming `spec.allowedGitTargets`. `quickstart.enabled` adds a grant for the quickstart namespace, and [`values-playground.yaml`](values-playground.yaml) grants every namespace its own. With an `inCluster` provider a grant bypasses live namespace RBAC through the operator's identity | `[]` | +| `kubeProvider.allowedApplierClaims` | Rendered as the provider's `spec.allowedApplierClaims`: which namespaces' `FluxClaim`s and `ArgoCDClaim`s may hold an applier through it (`consumerNamespaces`), and in which `applierNamespaces` it may live, such as `flux-system`. Admission to mirror is no admission to apply. Empty grants nothing | `[]` | | `servers.admission.enabled` | Install the validate-operator-types admission webhook that captures CommitRequest authors (a form of author attribution). Enabled by default; a no-op until `queue.redis.addr` is set | `true` | | `rbac.create` | Create the manager ClusterRole and its binding | `true` | | `rbac.watchTypes.mode` | Which types a `WatchRule` may read. `any` grants cluster-wide read on everything, including every Secret in the cluster. `selected` grants read on `rbac.watchTypes.selected` only, so the reverser cannot list or watch Secrets (it keeps `get` on named Secrets it is pointed at). See [`docs/rbac.md`](../../docs/rbac.md) | `any` | diff --git a/charts/gitops-reverser/templates/applier-rbac.yaml b/charts/gitops-reverser/templates/applier-rbac.yaml index 14c476920..9d895128b 100644 --- a/charts/gitops-reverser/templates/applier-rbac.yaml +++ b/charts/gitops-reverser/templates/applier-rbac.yaml @@ -2,7 +2,7 @@ # What GitOps Reverser needs to drive a Kustomization a FluxClaim holds: read and patch the # Kustomization and its GitRepository (it suspends and resumes the one and asks the other to fetch), # list the GitRepository's consumers (Kustomizations, and HelmReleases with the HelmCharts built -# from it; without it the applier claim lists none in status.source.otherConsumers), and create SelfSubjectAccessReviews so the operator can check it holds that +# from it; without it the applier claim lists none in status.source.alsoAppliedBy), and create SelfSubjectAccessReviews so the operator can check it holds that # access before relying on it. Separate from the manager role because it is optional and # names another project's API: an install without Flux grants nothing on it. See # docs/design/apply-trigger.md. diff --git a/charts/gitops-reverser/templates/kubeprovider.yaml b/charts/gitops-reverser/templates/kubeprovider.yaml index 83fbb5254..b720a3a18 100644 --- a/charts/gitops-reverser/templates/kubeprovider.yaml +++ b/charts/gitops-reverser/templates/kubeprovider.yaml @@ -4,9 +4,10 @@ # makes Helm delete it again. Every GitTarget names its KubeProvider explicitly # (spec.kubeProviderRef); nothing falls back to this one. With kubeProvider.secretRef.name empty it # uses the operator's own identity (spec.inCluster), and the chart lists it in -# --in-cluster-kube-providers, which is the only way an inCluster provider is honored. accessFrom -# admits other namespaces; the chart default (an empty selector) admits every namespace — tighten -# it via kubeProvider.accessFrom. +# --in-cluster-kube-providers, which is the only way an inCluster provider is honored. Nothing may +# use it without a grant: by default it admits no namespace. kubeProvider.allowedGitTargets and +# kubeProvider.allowedApplierClaims grant it; quickstart.enabled adds a grant for exactly the +# quickstart namespace, and values-playground.yaml grants every namespace its own. apiVersion: configbutler.ai/v1alpha3 kind: KubeProvider metadata: @@ -26,9 +27,19 @@ spec: inCluster: {} {{- end }} {{- end }} - accessFrom: - {{- toYaml .Values.kubeProvider.accessFrom | nindent 4 }} - allowCrossNamespace: - sources: {{ .Values.kubeProvider.allowCrossNamespace.sources }} - appliers: {{ .Values.kubeProvider.allowCrossNamespace.appliers }} + {{- $gitTargets := .Values.kubeProvider.allowedGitTargets }} + {{- $applierClaims := .Values.kubeProvider.allowedApplierClaims }} + {{- if .Values.quickstart.enabled }} + {{- $names := list .Values.quickstart.namespace }} + {{- $gitTargets = append $gitTargets (dict "consumerNamespaces" (dict "from" "Names" "names" $names) "sourceNamespaces" (list "{consumer.namespace}")) }} + {{- $applierClaims = append $applierClaims (dict "consumerNamespaces" (dict "from" "Names" "names" $names) "applierNamespaces" (list "{consumer.namespace}")) }} + {{- end }} + {{- with $gitTargets }} + allowedGitTargets: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with $applierClaims }} + allowedApplierClaims: + {{- toYaml . | nindent 4 }} + {{- end }} {{- end }} diff --git a/charts/gitops-reverser/templates/quickstart.yaml b/charts/gitops-reverser/templates/quickstart.yaml index a0700bff9..e058dbee2 100644 --- a/charts/gitops-reverser/templates/quickstart.yaml +++ b/charts/gitops-reverser/templates/quickstart.yaml @@ -20,8 +20,11 @@ metadata: app.kubernetes.io/component: quickstart spec: url: {{ .Values.quickstart.gitProvider.url | quote }} - allowedBranches: - {{- toYaml .Values.quickstart.gitProvider.allowedBranches | nindent 4 }} + allowedGitBranches: + - consumerNamespaces: + from: Same + branchPatterns: + {{- toYaml .Values.quickstart.gitProvider.branchPatterns | nindent 8 }} secretRef: name: {{ .Values.quickstart.gitProvider.secretRef.name }} --- @@ -37,6 +40,9 @@ spec: gitProviderRef: name: {{ .Values.quickstart.gitProvider.name }} branch: {{ .Values.quickstart.gitBranch.branch | quote }} + allowedGitTargets: + - consumerNamespaces: + from: Same --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/charts/gitops-reverser/values-playground.yaml b/charts/gitops-reverser/values-playground.yaml new file mode 100644 index 000000000..8ff9bd09c --- /dev/null +++ b/charts/gitops-reverser/values-playground.yaml @@ -0,0 +1,18 @@ +# A playground install: every namespace may mirror its own namespace, and hold an applier in its own +# namespace, through the chart's `local` KubeProvider, which uses the operator's own identity. That +# is the reach of a single-cluster demo, chosen visibly: +# +# helm install gitops-reverser oci://ghcr.io/configbutler/charts/gitops-reverser \ +# -n gitops-reverser --create-namespace -f values-playground.yaml +# +# Every namespace then gains use of the operator's identity for its own namespace, bypassing live +# namespace RBAC. No grant here reaches another namespace or a cluster-scoped resource: a +# ClusterWatchRule through `local` needs a grant with clusterResources. Do not use it where tenants +# share a cluster. +kubeProvider: + allowedGitTargets: + - consumerNamespaces: {from: All} + sourceNamespaces: ["{consumer.namespace}"] + allowedApplierClaims: + - consumerNamespaces: {from: All} + applierNamespaces: ["{consumer.namespace}"] diff --git a/charts/gitops-reverser/values.schema.json b/charts/gitops-reverser/values.schema.json index 10412ad91..3220c4fc7 100644 --- a/charts/gitops-reverser/values.schema.json +++ b/charts/gitops-reverser/values.schema.json @@ -400,25 +400,15 @@ } } }, - "accessFrom": { - "type": "object", - "description": "Control-cluster namespace policy (names and/or selector) for which OTHER namespaces may reference this provider; the release namespace always may.", - "additionalProperties": true + "allowedGitTargets": { + "type": "array", + "description": "Rendered as the provider's spec.allowedGitTargets: which namespaces' GitTargets may mirror through it (consumerNamespaces), and what (sourceNamespaces, clusterResources). Empty grants nothing. The CRD validates each grant.", + "items": { "type": "object" } }, - "allowCrossNamespace": { - "type": "object", - "additionalProperties": false, - "description": "What an admitted namespace may reach outside itself through this provider.", - "properties": { - "sources": { - "type": "boolean", - "description": "A WatchRule may watch other namespaces, \"*\" included." - }, - "appliers": { - "type": "boolean", - "description": "A FluxClaim or ArgoCDClaim may name a Kustomization or Application in another namespace." - } - } + "allowedApplierClaims": { + "type": "array", + "description": "Rendered as the provider's spec.allowedApplierClaims: which namespaces' applier claims may hold an applier through it (consumerNamespaces), and where it may live (applierNamespaces). Empty grants nothing. The CRD validates each grant.", + "items": { "type": "object" } } } }, @@ -565,7 +555,7 @@ "properties": { "name": { "type": "string" }, "url": { "type": "string" }, - "allowedBranches": { "type": "array", "items": { "type": "string" } }, + "branchPatterns": { "type": "array", "items": { "type": "string" } }, "secretRef": { "type": "object", "additionalProperties": false, diff --git a/charts/gitops-reverser/values.yaml b/charts/gitops-reverser/values.yaml index e16bbf555..bbc26ed7c 100644 --- a/charts/gitops-reverser/values.yaml +++ b/charts/gitops-reverser/values.yaml @@ -125,8 +125,8 @@ git: # Application, synced for each apply. Off unless asked for, since one-way mirroring needs neither. applierAccess: # Grant get/patch on kustomize.toolkit.fluxcd.io Kustomizations and source.toolkit.fluxcd.io - # GitRepositories, list on Kustomizations, HelmReleases and HelmCharts (to find a - # GitRepository's other consumers), plus create on SelfSubjectAccessReviews (to check that access + # GitRepositories, list on Kustomizations, HelmReleases and HelmCharts (to find what else applies + # a GitRepository), plus create on SelfSubjectAccessReviews (to check that access # before relying on it). Cluster-wide, because an applier claim's applier can live in any namespace. flux: false # Grant get/update/patch on argoproj.io Applications (a sync is started by updating the @@ -363,20 +363,39 @@ kubeProvider: secretRef: name: "" key: "" - # Which OTHER namespaces may reference this provider; the release namespace always may. It is - # authorization in the CONTROL cluster (names OR selector), not a filter on the source cluster. - # The chart default is an empty selector, which matches EVERY namespace, so a single-cluster - # install works out of the box; tighten it to a names list or a label selector in a real install. - accessFrom: - selector: {} - # What a namespace this provider admits may reach OUTSIDE itself through the provider's - # identity. Each is a power of its own: sources lets a WatchRule read other namespaces' objects - # into its own Git ("*" included); appliers lets a FluxClaim or ArgoCDClaim suspend and apply - # a Kustomization or Application in another namespace. It is a delegation to every admitted - # namespace, not proof that a namespace owns what it reaches. - allowCrossNamespace: - sources: false - appliers: false + # Who may use the operator's identity, and for what: rendered as the provider's + # spec.allowedGitTargets and spec.allowedApplierClaims. Empty by default, so NOTHING mirrors or + # holds an applier through this provider until you grant it, the release namespace included; a + # refusal names spec.allowedGitTargets. The installer decides, never the chart. + # quickstart.enabled=true adds a grant for exactly the quickstart namespace, and + # values-playground.yaml grants every namespace its own namespace. + # + # Each grant has a consumerNamespaces block saying which namespaces' objects it admits (from: + # Same, Names with names, Selector with a non-empty selector, or All), and a scope beside it. A + # scope is a permission, not a selection, and an object's scope is the union across the grants + # admitting it: + # allowedGitTargets[].sourceNamespaces which namespaces the WatchRules feeding an admitted + # GitTarget may name: names, "{consumer.namespace}" for + # the GitTarget's own, or "*" (which a "*" rule item needs) + # allowedGitTargets[].clusterResources which cluster-scoped types a ClusterWatchRule may mirror: + # resource.group, a bare core resource, or "*" + # allowedApplierClaims[].applierNamespaces where an admitted applier claim's Kustomization or + # Application may live + # With an inCluster provider a grant bypasses live namespace RBAC through the operator's own + # identity: the owner of an admitted object acts on the namespaces in its scope. + # + # allowedGitTargets: + # - consumerNamespaces: + # from: Selector + # selector: {matchLabels: {platform.example.com/tenant: "true"}} + # sourceNamespaces: ["{consumer.namespace}"] + # allowedApplierClaims: + # - consumerNamespaces: + # from: Names + # names: [team-a] + # applierNamespaces: [flux-system] + allowedGitTargets: [] + allowedApplierClaims: [] # RBAC configuration rbac: @@ -491,7 +510,9 @@ quickstart: name: example-provider # Required when quickstart.enabled=true. url: "" - allowedBranches: + # The branch patterns the quickstart namespace may claim through this provider ("*" is every + # branch); rendered as the branchPatterns of a from: Same grant in spec.allowedGitBranches. + branchPatterns: - "*" secretRef: # Required when quickstart.enabled=true. diff --git a/cmd/main.go b/cmd/main.go index 0a7a37bb0..0c964e41f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -329,6 +329,11 @@ func main() { fatalIfErr(watchMgr.SetupWithManager(mgr), "unable to setup watch ingestion manager") fatalIfErr(mgr.Add(watchMgr), "unable to add watch ingestion manager") + // The GitBranch, GitTarget and applier claim controllers find a GitProvider's GitBranches, in any + // namespace, through this index. + fatalIfErr(controller.IndexGitBranches(context.Background(), mgr.GetFieldIndexer()), + "unable to index GitBranches by their GitProvider") + if err := (&controller.GitProviderReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), diff --git a/cmd/main_chart_kubeprovider_test.go b/cmd/main_chart_kubeprovider_test.go new file mode 100644 index 000000000..e9c782922 --- /dev/null +++ b/cmd/main_chart_kubeprovider_test.go @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "os" + "os/exec" + "testing" + + meta "github.com/fluxcd/pkg/apis/meta" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/yaml" + + configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/authz" +) + +// kubeProviderChartInputs are read in-process so `go test` treats them as cache inputs. +var kubeProviderChartInputs = []string{ + "../charts/gitops-reverser/values.yaml", + "../charts/gitops-reverser/values-playground.yaml", + "../charts/gitops-reverser/values.schema.json", + "../charts/gitops-reverser/templates/kubeprovider.yaml", +} + +// renderLocalKubeProvider renders the chart's `local` KubeProvider in namespace gitops-reverser. +func renderLocalKubeProvider(t *testing.T, args ...string) *configbutleraiv1alpha3.KubeProvider { + t.Helper() + for _, path := range kubeProviderChartInputs { + _, err := os.ReadFile(path) + require.NoError(t, err) + } + command := append([]string{"template", "gitops-reverser", "../charts/gitops-reverser", + "--namespace", "gitops-reverser", "--show-only", "templates/kubeprovider.yaml"}, args...) + out, err := exec.Command("helm", command...).CombinedOutput() + require.NoErrorf(t, err, "helm template failed: %s", out) + provider := &configbutleraiv1alpha3.KubeProvider{} + require.NoError(t, yaml.UnmarshalStrict(out, provider)) + return provider +} + +// mirrors reports whether a GitTarget in namespace may mirror through provider, and the refusal. +func mirrors(t *testing.T, provider *configbutleraiv1alpha3.KubeProvider, namespace string) authz.Decision { + t.Helper() + scheme := runtime.NewScheme() + require.NoError(t, configbutleraiv1alpha3.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + reader := fake.NewClientBuilder().WithScheme(scheme).WithObjects(provider).Build() + target := &configbutleraiv1alpha3.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "apps"}, + Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: meta.NamespacedObjectReference{Name: provider.Name, Namespace: provider.Namespace}, + }, + } + decision, err := authz.GitTargetAdmitted(context.Background(), reader, target) + require.NoError(t, err) + return decision +} + +// TestChartKubeProvider_ADefaultInstallGrantsNothing: the installer chooses who may use the +// operator's identity; the chart never chooses for them, the release namespace included. +func TestChartKubeProvider_ADefaultInstallGrantsNothing(t *testing.T) { + provider := renderLocalKubeProvider(t) + assert.Empty(t, provider.Spec.AllowedGitTargets) + assert.Empty(t, provider.Spec.AllowedApplierClaims) + for _, namespace := range []string{"gitops-reverser", "team-a"} { + decision := mirrors(t, provider, namespace) + assert.False(t, decision.Allowed) + assert.Contains(t, decision.Message, + "KubeProvider gitops-reverser/local admits no namespace in spec.allowedGitTargets") + } +} + +// TestChartKubeProvider_TheQuickstartGrantsItsNamespaceOnly: quickstart.enabled renders a grant for +// exactly the quickstart namespace, to mirror and to hold appliers in its own namespace. +func TestChartKubeProvider_TheQuickstartGrantsItsNamespaceOnly(t *testing.T) { + provider := renderLocalKubeProvider(t, "--set", "quickstart.enabled=true", + "--set", "quickstart.gitProvider.url=https://example.com/repo.git") + + scope, err := provider.AdmitsGitTarget( + types.NamespacedName{Namespace: "gitops-reverser-quickstart-demo", Name: "mirror"}, + nil, + ) + require.NoError(t, err) + assert.True(t, scope.AllowsSourceNamespace("gitops-reverser-quickstart-demo")) + assert.False(t, scope.AllowsSourceNamespace("kube-system")) + assert.Empty(t, scope.ClusterResources) + appliers, err := provider.AdmitsApplierClaim( + types.NamespacedName{Namespace: "gitops-reverser-quickstart-demo", Name: "checkout"}, + nil, + ) + require.NoError(t, err) + assert.True(t, appliers.AllowsApplierNamespace("gitops-reverser-quickstart-demo")) + assert.False(t, appliers.AllowsApplierNamespace("flux-system")) + + assert.True(t, mirrors(t, provider, "gitops-reverser-quickstart-demo").Allowed) + assert.False(t, mirrors(t, provider, "team-a").Allowed, "and nothing else") +} + +// TestChartKubeProvider_ThePlaygroundGrantsEveryNamespaceItsOwn: values-playground.yaml is today's +// open default, chosen visibly: every namespace mirrors its own namespace, no other, and no +// cluster-scoped resource. +func TestChartKubeProvider_ThePlaygroundGrantsEveryNamespaceItsOwn(t *testing.T) { + provider := renderLocalKubeProvider(t, "-f", "../charts/gitops-reverser/values-playground.yaml") + for _, namespace := range []string{"team-a", "gitops-reverser"} { + scope, err := provider.AdmitsGitTarget(types.NamespacedName{Namespace: namespace, Name: "mirror"}, nil) + require.NoError(t, err) + assert.True(t, scope.AllowsSourceNamespace(namespace)) + assert.False(t, scope.AllowsSourceNamespace("kube-system"), "no other namespace") + assert.False(t, scope.AllowsSourceNamespace("*")) + assert.Empty(t, scope.ClusterResources, "no default grants clusterResources") + appliers, err := provider.AdmitsApplierClaim(types.NamespacedName{Namespace: namespace, Name: "checkout"}, nil) + require.NoError(t, err) + assert.Equal(t, []string{namespace}, appliers.ApplierNamespaces) + } +} + +// TestChartKubeProvider_GrantsRenderAsWritten: values grants reach the provider unchanged, and the +// quickstart grant is added beside them. +func TestChartKubeProvider_GrantsRenderAsWritten(t *testing.T) { + values := t.TempDir() + "/values.yaml" + require.NoError(t, os.WriteFile(values, []byte(`kubeProvider: + allowedGitTargets: + - consumerNamespaces: + from: Selector + selector: {matchLabels: {platform.example.com/tenant: "true"}} + sourceNamespaces: ["{consumer.namespace}"] + allowedApplierClaims: + - consumerNamespaces: + from: Names + names: [team-a] + applierNamespaces: [flux-system] +quickstart: + enabled: true + gitProvider: {url: "https://example.com/repo.git"} +`), 0o600)) + provider := renderLocalKubeProvider(t, "-f", values) + require.Len(t, provider.Spec.AllowedGitTargets, 2) + assert.Equal( + t, + configbutleraiv1alpha3.GrantFromSelector, + provider.Spec.AllowedGitTargets[0].ConsumerNamespaces.From, + ) + assert.Equal(t, configbutleraiv1alpha3.GrantFromNames, provider.Spec.AllowedGitTargets[1].ConsumerNamespaces.From) + require.Len(t, provider.Spec.AllowedApplierClaims, 2) + assert.Equal(t, []configbutleraiv1alpha3.ScopeNamespace{"flux-system"}, + provider.Spec.AllowedApplierClaims[0].ApplierNamespaces) +} diff --git a/cmd/main_chart_receiver_test.go b/cmd/main_chart_receiver_test.go index f06964d89..452a175c5 100644 --- a/cmd/main_chart_receiver_test.go +++ b/cmd/main_chart_receiver_test.go @@ -164,7 +164,7 @@ func TestChartReceiver_MalformedBindAddressFailsTheRender(t *testing.T) { } // applierAccess.flux is off by default, and on it grants exactly what holding a Flux Kustomization -// needs (suspend and resume it, fetch its source, list the source's consumers), to the operator's own +// needs (suspend and resume it, fetch its source, list what else applies the source), to the operator's own // ServiceAccount. func TestChartApplierAccess_Flux(t *testing.T) { assert.Empty(t, helmTemplateDocs(t, "templates/applier-rbac.yaml"), "off unless asked for") diff --git a/config/crd/bases/configbutler.ai_argocdclaims.yaml b/config/crd/bases/configbutler.ai_argocdclaims.yaml index 2e5dc4ce5..ffbc38ce0 100644 --- a/config/crd/bases/configbutler.ai_argocdclaims.yaml +++ b/config/crd/bases/configbutler.ai_argocdclaims.yaml @@ -21,8 +21,8 @@ spec: - jsonPath: .status.conditions[?(@.type=="Ready")].reason name: Reason type: string - - jsonPath: .status.conditions[?(@.type=="Held")].reason - name: Held + - jsonPath: .status.conditions[?(@.type=="InControl")].reason + name: Control type: string - jsonPath: .spec.applicationRef.name name: Application @@ -126,8 +126,9 @@ spec: description: |- 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. properties: name: description: Name of the referent. @@ -160,9 +161,12 @@ spec: description: |- 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, diff --git a/config/crd/bases/configbutler.ai_fluxclaims.yaml b/config/crd/bases/configbutler.ai_fluxclaims.yaml index 3c704f139..caf0015fe 100644 --- a/config/crd/bases/configbutler.ai_fluxclaims.yaml +++ b/config/crd/bases/configbutler.ai_fluxclaims.yaml @@ -21,8 +21,8 @@ spec: - jsonPath: .status.conditions[?(@.type=="Ready")].reason name: Reason type: string - - jsonPath: .status.conditions[?(@.type=="Held")].reason - name: Held + - jsonPath: .status.conditions[?(@.type=="InControl")].reason + name: Control type: string - jsonPath: .spec.kustomizationRef.name name: Kustomization @@ -97,8 +97,9 @@ spec: description: |- KubeProviderRef names the KubeProvider whose cluster holds the Kustomization, such as a management cluster running Flux 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 and patch on the Kustomization and its GitRepository. Immutable. + applier claim's own namespace. The provider's spec.allowedApplierClaims must admit this applier + claim's namespace, with the Kustomization's namespace in applierNamespaces. Its identity needs + get and patch on the Kustomization and its GitRepository. Immutable. properties: name: description: Name of the referent. @@ -153,9 +154,11 @@ spec: description: |- Conditions are Configured (an eligible GitTarget names the applier claim; the Kustomization exists, applies a GitRepository that follows a branch, may be patched, and no other FluxClaim holds - it), Held (Suspended: suspended under GitOps Reverser with the last suspend handled; Applying: - resumed for an apply GitOps Reverser started; Suspending: a suspend is not handled yet; - Releasing: being handed back; NotReferenced; NotConfigured), and the kstatus + it), InControl (whether GitOps Reverser decides, right now, when the Kustomization applies. True: + Suspended, suspended under GitOps Reverser with the last suspend handled; Applying, resumed for + an apply GitOps Reverser started. False: Suspending, a suspend is not handled yet; Releasing, + being handed back; Revoking, being handed back because a grant no longer admits it; + NotReferenced. Unknown: NotConfigured), 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 GitRepository fetches. An applier claim no GitTarget names reads nothing @@ -314,26 +317,9 @@ spec: eligible GitTarget references the applier claim. A GitTarget naming this applier claim must write the repository and branch it fetches. properties: - branch: - description: Branch is the GitRepository's spec.ref.branch. - type: string - gitRepository: - description: GitRepository is the Kustomization's spec.sourceRef, - its namespace defaulted. - properties: - name: - description: Name of the referent. - type: string - namespace: - description: Namespace of the referent, when not specified - it acts as LocalObjectReference. - type: string - required: - - name - type: object - otherConsumers: + alsoAppliedBy: description: |- - OtherConsumers are the other objects in the cluster that apply this GitRepository and that no + AlsoAppliedBy are the other objects in the cluster that apply this GitRepository and that no FluxClaim holds: Kustomizations, and HelmReleases whose chart comes from it. Each applies a new artifact the moment it arrives. Information only: it never affects Ready. items: @@ -361,6 +347,23 @@ spec: type: object type: array x-kubernetes-list-type: atomic + branch: + description: Branch is the GitRepository's spec.ref.branch. + type: string + gitRepository: + description: GitRepository is the Kustomization's spec.sourceRef, + its namespace defaulted. + properties: + name: + description: Name of the referent. + type: string + namespace: + description: Namespace of the referent, when not specified + it acts as LocalObjectReference. + type: string + required: + - name + type: object url: description: URL is the GitRepository's spec.url. type: string diff --git a/config/crd/bases/configbutler.ai_gitbranches.yaml b/config/crd/bases/configbutler.ai_gitbranches.yaml index 936a27bcb..e8ad26078 100644 --- a/config/crd/bases/configbutler.ai_gitbranches.yaml +++ b/config/crd/bases/configbutler.ai_gitbranches.yaml @@ -18,6 +18,9 @@ spec: - jsonPath: .spec.gitProviderRef.name name: Provider type: string + - jsonPath: .spec.gitProviderRef.namespace + name: ProviderNamespace + type: string - jsonPath: .spec.branch name: Branch type: string @@ -78,10 +81,135 @@ spec: spec: description: spec defines the desired state of GitBranch properties: + allowedGitTargets: + description: |- + 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. + items: + description: |- + GitBranchGitTargetGrant admits GitTargets in some control-cluster namespaces to write through a + GitBranch. + properties: + consumerNamespaces: + description: |- + ConsumerNamespaces are the control-cluster namespaces whose GitTargets this grant admits. + from: Same is this GitBranch's namespace. + properties: + from: + description: |- + From is where this grant admits from: Same (the namespace of the object holding the grant, not + the consumer's), Names (the namespaces in names), Selector (the namespaces whose labels match + selector) or All (every namespace). + enum: + - Same + - Names + - Selector + - All + type: string + names: + description: 'Names are the control-cluster namespaces admitted + with from: Names, by exact name.' + items: + description: GrantNamespace is one control-cluster namespace + a grant admits by name. + maxLength: 63 + type: string + x-kubernetes-validations: + - message: 'names: a variable is refused, because names + are matched against the consumer''s namespace and + ''{consumer.namespace}'' would admit every consumer; + use from: All to admit every namespace, or from: Same + for the namespace of the object holding the grant' + rule: self != '{consumer.namespace}' && self != '{consumer.name}' + - message: 'names: ''*'' is refused; use from: All to + admit every namespace' + rule: self != '*' + - message: names holds namespace names (DNS labels) + rule: self == '{consumer.namespace}' || self == '{consumer.name}' + || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') + maxItems: 64 + minItems: 1 + type: array + x-kubernetes-list-type: set + selector: + description: |- + Selector matches labels on control-cluster Namespaces, with from: Selector. It must select + something: to admit every namespace, use from: All. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + 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 + x-kubernetes-map-type: atomic + required: + - from + type: object + x-kubernetes-validations: + - message: 'from: Names needs names' + rule: self.from != 'Names' || has(self.names) + - message: 'names goes only with from: Names' + rule: '!has(self.names) || self.from == ''Names''' + - message: 'from: Selector needs a selector; to admit every + namespace, use from: All' + rule: self.from != 'Selector' || has(self.selector) + - message: 'selector goes only with from: Selector' + rule: '!has(self.selector) || self.from == ''Selector''' + - message: 'selector: {} is refused; to admit every namespace, + use from: All' + rule: '!has(self.selector) || (has(self.selector.matchLabels) + && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) + && size(self.selector.matchExpressions) > 0)' + required: + - consumerNamespaces + type: object + maxItems: 64 + type: array + x-kubernetes-list-type: atomic branch: description: |- Branch is the branch on the remote that this GitBranch writes. It must match one of the - GitProvider's allowedBranches. Only one GitBranch writes a repository branch: the oldest one + patterns the GitProvider's spec.allowedGitBranches give this GitBranch's namespace. Only one + GitBranch writes a repository branch: the oldest admitted one whose credential proves it can open a push session holds it, and any other GitBranch for the same repository and branch reports Stalled with reason BranchConflict. Immutable: delete and recreate the GitBranch to change it. @@ -89,14 +217,18 @@ spec: type: string gitProviderRef: description: |- - GitProviderRef names the GitProvider, in this GitBranch's namespace, whose repository and - credential this branch is written with. GitTargets in other namespaces may reference this - GitBranch while that GitProvider's spec.accessFrom admits their namespace. + GitProviderRef names the GitProvider whose repository and credential this branch is written + with. Namespace omitted means this GitBranch's own namespace. The GitBranch claims its branch + only while that GitProvider's spec.allowedGitBranches admits this namespace and the branch. Immutable: delete and recreate the GitBranch to change it. properties: name: description: Name of the referent. type: string + namespace: + description: Namespace of the referent, when not specified it + acts as LocalObjectReference. + type: string required: - name type: object @@ -110,8 +242,8 @@ spec: default branch), or a branch with no history when the repository is empty. When set, it must exist on the remote for spec.branch to be created; otherwise this GitBranch reports ParentBranchNotFound and nothing is written. Once spec.branch exists, it is used as it is and - the parent is not followed. It is only read, so it does not need to be in the GitProvider's - allowedBranches. + the parent is not followed. It is only read, so the GitProvider's spec.allowedGitBranches need + not allow it. Immutable, including adding or removing it: delete and recreate the GitBranch to change it. minLength: 1 type: string diff --git a/config/crd/bases/configbutler.ai_gitproviders.yaml b/config/crd/bases/configbutler.ai_gitproviders.yaml index ea751c8fe..7bf535b71 100644 --- a/config/crd/bases/configbutler.ai_gitproviders.yaml +++ b/config/crd/bases/configbutler.ai_gitproviders.yaml @@ -60,81 +60,186 @@ spec: spec: description: spec defines the desired state of GitProvider properties: - accessFrom: + allowedGitBranches: description: |- - AccessFrom is the policy for which OTHER namespaces may reference this GitProvider from a - GitTarget. This GitProvider's own namespace is always admitted. Empty (or omitted) admits no - other namespace. A GitTarget admitted from another namespace writes with this provider's - credential, commit identity and allowedBranches, as one in its own namespace does. - properties: - names: - description: |- - Names is an explicit allow-list of namespace names. Entries are namespace names (DNS-1123 - labels), never patterns — `*` is rejected. To admit every namespace, declare `selector: {}`. - items: - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - type: array - x-kubernetes-list-type: set - selector: - description: |- - Selector is a label selector matched against Namespace labels; a namespace whose labels - match is admitted. ORed with Names. - properties: - matchExpressions: - description: matchExpressions is a list of label selector - requirements. The requirements are ANDed. - items: + 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 + spec.allowedGitTargets. + items: + description: |- + GitProviderGitBranchGrant admits GitBranches in some control-cluster namespaces to claim the + branches its patterns match through a GitProvider. + properties: + branchPatterns: + description: |- + BranchPatterns are the branches an admitted GitBranch may claim, as patterns matched against + the whole branch name. '*' matches any sequence of characters, '/' included, so "*" alone is + every branch, and a name with no '*', such as "main", matches only itself. + '{consumer.namespace}' and '{consumer.name}' are the claiming GitBranch's namespace and name, + each a whole path segment: "teams/{consumer.namespace}/*". '{consumer.name}' needs + '{consumer.namespace}' in the same pattern, "teams/{consumer.namespace}/{consumer.name}": it is + a naming convention, not a bound, since a GitBranch picks its own name. There is no escaping, + '?' or character class. + items: + description: |- + BranchPattern is a pattern matched against a whole branch name: literal text, '*' for any + sequence of characters ('/' included), and '{consumer.namespace}' and '{consumer.name}' as whole + path segments. '{consumer.name}' needs '{consumer.namespace}' in the same pattern. + maxLength: 255 + minLength: 1 + pattern: ^[^\s~^:?\[\\]*$ + type: string + x-kubernetes-validations: + - messageExpression: '''branch pattern '' + self + '' references + unknown variable '' + self.replace(''{consumer.namespace}'', + ''x'').replace(''{consumer.name}'', ''x'').find(''[{][^{}]*[}]'') + + ''; the variables are {consumer.namespace} and {consumer.name}''' + rule: '!self.replace(''{consumer.namespace}'', ''x'').replace(''{consumer.name}'', + ''x'').matches(''[{][^{}]*[}]'')' + - messageExpression: '''branch pattern '' + self + '' has + an unmatched brace; the variables are {consumer.namespace} + and {consumer.name}''' + rule: self.replace('{consumer.namespace}', 'x').replace('{consumer.name}', + 'x').matches('[{][^{}]*[}]') || !self.replace('{consumer.namespace}', + 'x').replace('{consumer.name}', 'x').matches('[{}]') + - messageExpression: '''branch pattern '' + self + '': {consumer.namespace} + and {consumer.name} must each be a whole path segment, + at the start or after a /, and at the end or before a + /''' + rule: '!self.matches(''[^/][{]consumer[.]name(space)?[}]'') + && !self.matches(''[{]consumer[.]name(space)?[}][^/]'')' + - messageExpression: '''branch pattern '' + self + '': {consumer.name} + needs {consumer.namespace} in the same pattern; the consumer + picks its own name, so on its own it would let any admitted + namespace claim any branch the pattern leaves open''' + rule: '!self.contains(''{consumer.name}'') || self.contains(''{consumer.namespace}'')' + - messageExpression: '''branch pattern '' + self + '' can + match no branch name: git refuses .., @{, a leading or + trailing / and //''' + rule: '!self.contains(''..'') && !self.contains(''@{'') + && !self.startsWith(''/'') && !self.endsWith(''/'') && + !self.contains(''//'')' + maxItems: 64 + minItems: 1 + type: array + x-kubernetes-list-type: set + consumerNamespaces: + description: |- + ConsumerNamespaces are the control-cluster namespaces whose GitBranches this grant admits. + from: Same is this GitProvider's namespace. + properties: + from: description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. + From is where this grant admits from: Same (the namespace of the object holding the grant, not + the consumer's), Names (the namespaces in names), Selector (the namespaces whose labels match + selector) or All (every namespace). + enum: + - Same + - Names + - Selector + - All + type: string + names: + description: 'Names are the control-cluster namespaces admitted + with from: Names, by exact name.' + items: + description: GrantNamespace is one control-cluster namespace + a grant admits by name. + maxLength: 63 + type: string + x-kubernetes-validations: + - message: 'names: a variable is refused, because names + are matched against the consumer''s namespace and + ''{consumer.namespace}'' would admit every consumer; + use from: All to admit every namespace, or from: Same + for the namespace of the object holding the grant' + rule: self != '{consumer.namespace}' && self != '{consumer.name}' + - message: 'names: ''*'' is refused; use from: All to + admit every namespace' + rule: self != '*' + - message: names holds namespace names (DNS labels) + rule: self == '{consumer.namespace}' || self == '{consumer.name}' + || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') + maxItems: 64 + minItems: 1 + type: array + x-kubernetes-list-type: set + selector: + description: |- + Selector matches labels on control-cluster Namespaces, with from: Selector. It must select + something: to admit every namespace, use from: All. properties: - key: - description: key is the label key that the selector - applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. items: - type: string + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object type: array x-kubernetes-list-type: atomic - required: - - key - - operator + 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 - x-kubernetes-list-type: atomic - 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 - x-kubernetes-map-type: atomic - type: object - allowedBranches: - description: AllowedBranches restricts which branches can be written - to. - items: - minLength: 1 - type: string - minItems: 1 + x-kubernetes-map-type: atomic + required: + - from + type: object + x-kubernetes-validations: + - message: 'from: Names needs names' + rule: self.from != 'Names' || has(self.names) + - message: 'names goes only with from: Names' + rule: '!has(self.names) || self.from == ''Names''' + - message: 'from: Selector needs a selector; to admit every + namespace, use from: All' + rule: self.from != 'Selector' || has(self.selector) + - message: 'selector goes only with from: Selector' + rule: '!has(self.selector) || self.from == ''Selector''' + - message: 'selector: {} is refused; to admit every namespace, + use from: All' + rule: '!has(self.selector) || (has(self.selector.matchLabels) + && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) + && size(self.selector.matchExpressions) > 0)' + required: + - branchPatterns + - consumerNamespaces + type: object + maxItems: 64 type: array + x-kubernetes-list-type: atomic commit: description: |- Commit configures the commit identity and signing behavior this connection uses. Message @@ -237,7 +342,6 @@ spec: minLength: 1 type: string required: - - allowedBranches - url type: object x-kubernetes-validations: diff --git a/config/crd/bases/configbutler.ai_gittargets.yaml b/config/crd/bases/configbutler.ai_gittargets.yaml index d4c6bd3d5..96f0082cc 100644 --- a/config/crd/bases/configbutler.ai_gittargets.yaml +++ b/config/crd/bases/configbutler.ai_gittargets.yaml @@ -307,9 +307,9 @@ spec: gitBranchRef: description: |- GitBranchRef names the GitBranch this target writes: the repository branch, and the credential - it is written with. Namespace omitted means this GitTarget's own namespace. A GitBranch in - another namespace is used only while its GitProvider's spec.accessFrom admits this GitTarget's - namespace. Many GitTargets may reference one GitBranch; they share its branch worker. + it is written with. Namespace omitted means this GitTarget's own namespace. The GitBranch is + used only while its spec.allowedGitTargets admit this GitTarget's namespace, its own included. + Many GitTargets may reference one GitBranch; they share its branch worker. Immutable: delete and recreate the GitTarget to change its destination. properties: name: @@ -343,9 +343,9 @@ spec: kubeProviderRef: description: |- KubeProviderRef names the KubeProvider this GitTarget mirrors FROM: the source cluster, and the - credential it is read with. Namespace omitted means this GitTarget's own namespace. A - KubeProvider in another namespace is used only while its spec.accessFrom admits this - GitTarget's namespace. Immutable: a folder's source cluster is part of what the folder means; + credential it is read with. Namespace omitted means this GitTarget's own namespace. The + KubeProvider is used only while its spec.allowedGitTargets admit this GitTarget's namespace, + its own included. Immutable: a folder's source cluster is part of what the folder means; delete and recreate the GitTarget to change it. properties: name: @@ -377,8 +377,8 @@ spec: apply the current tip after publishing the edits pending on the branch, which reverts the refused edit and nothing that was waiting to be published. Only this target's applier is asked. If its GitRepository has to fetch to reach the tip, the - Kustomizations a FluxClaim holds stay suspended and ignore the new artifact; only the - consumers no applier claim holds, listed in the applier claim's status.source.otherConsumers, apply it, as + Kustomizations a FluxClaim holds stay suspended and ignore the new artifact; only the objects + no applier claim holds, listed in the applier claim's status.source.alsoAppliedBy, apply it, as they would for any push. It fires only for an edit to a document the folder ALREADY holds, in a write that removes no diff --git a/config/crd/bases/configbutler.ai_kubeproviders.yaml b/config/crd/bases/configbutler.ai_kubeproviders.yaml index 9570a54d3..ed83d30a2 100644 --- a/config/crd/bases/configbutler.ai_kubeproviders.yaml +++ b/config/crd/bases/configbutler.ai_kubeproviders.yaml @@ -44,10 +44,10 @@ spec: openAPIV3Schema: description: |- KubeProvider is a connection to a Kubernetes cluster and the identity used there. A GitTarget - mirrors from it; an applier claim reaches its applier through it. It holds the credential that reaches it (a kubeconfig Secret in its own - namespace, or the operator's own identity where the installation allows it), which control-cluster - namespaces may use it (its own, and those spec.accessFrom admits), and the audit route its - attribution facts are partitioned under. + mirrors from it; an applier claim reaches its applier through it. It holds the credential that + reaches it (a kubeconfig Secret in its own namespace, or the operator's own identity where the + installation allows it), who may use it and for what (spec.allowedGitTargets and + spec.allowedApplierClaims), and the audit route its attribution facts are partitioned under. properties: apiVersion: description: |- @@ -69,93 +69,358 @@ spec: spec: description: spec defines the desired state of KubeProvider. properties: - accessFrom: + allowedApplierClaims: description: |- - AccessFrom admits other control-cluster namespaces to reference this provider. Its own - namespace is always admitted; omitted, no other namespace is. Its selector matches labels on - Namespaces in the control cluster, never on the source cluster this provider names. - properties: - names: - description: |- - Names is an explicit allow-list of namespace names. Entries are namespace names (DNS-1123 - labels), never patterns — `*` is rejected. To admit every namespace, declare `selector: {}`. - items: - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - type: array - x-kubernetes-list-type: set - selector: - description: |- - Selector is a label selector matched against Namespace labels; a namespace whose labels - match is admitted. ORed with Names. - properties: - matchExpressions: - description: matchExpressions is a list of label selector - requirements. The requirements are ANDed. - items: + 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. + items: + description: |- + KubeProviderApplierClaimGrant admits applier claims in some control-cluster namespaces to hold an + applier through a KubeProvider, and bounds where that applier may live. + properties: + applierNamespaces: + description: |- + ApplierNamespaces are namespaces in this provider's cluster in which an admitted applier claim's + Kustomization or Application may live: exact names, '{consumer.namespace}' for the namespace + named like the applier claim's, or '*' for every namespace. + + The effective scope is the union of applierNamespaces from all grants admitting the applier + claim, after expanding variables. This is a permission, not a selection: it is checked against + one namespace, that of the applier claim's kustomizationRef or applicationRef. The check runs + when the applier claim reconciles, and again when a grant changes, never at admission. An + applier outside the union is refused, in the applier claim's status + (ApplierNamespaceNotAllowed), and an applier claim holding it hands it back. + items: + description: |- + ScopeNamespace is one namespace in a source or applier scope: a namespace name, + "{consumer.namespace}" for the namespace of the same name as the consumer's, or "*" for every + namespace. + maxLength: 63 + type: string + x-kubernetes-validations: + - message: '''{consumer.name}'' is refused in a namespace + scope: the consumer picks its own name, so any namespace + could be reached by naming an object after it; use ''{consumer.namespace}'', + namespace names, or ''*''' + rule: self != '{consumer.name}' + - message: a namespace scope holds a namespace name, '{consumer.namespace}' + alone, or '*' alone + rule: self == '*' || self == '{consumer.namespace}' || self + == '{consumer.name}' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') + maxItems: 64 + minItems: 1 + type: array + x-kubernetes-list-type: set + consumerNamespaces: + description: |- + ConsumerNamespaces are the control-cluster namespaces whose applier claims this grant admits. + from: Same is this KubeProvider's namespace. + properties: + from: + description: |- + From is where this grant admits from: Same (the namespace of the object holding the grant, not + the consumer's), Names (the namespaces in names), Selector (the namespaces whose labels match + selector) or All (every namespace). + enum: + - Same + - Names + - Selector + - All + type: string + names: + description: 'Names are the control-cluster namespaces admitted + with from: Names, by exact name.' + items: + description: GrantNamespace is one control-cluster namespace + a grant admits by name. + maxLength: 63 + type: string + x-kubernetes-validations: + - message: 'names: a variable is refused, because names + are matched against the consumer''s namespace and + ''{consumer.namespace}'' would admit every consumer; + use from: All to admit every namespace, or from: Same + for the namespace of the object holding the grant' + rule: self != '{consumer.namespace}' && self != '{consumer.name}' + - message: 'names: ''*'' is refused; use from: All to + admit every namespace' + rule: self != '*' + - message: names holds namespace names (DNS labels) + rule: self == '{consumer.namespace}' || self == '{consumer.name}' + || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') + maxItems: 64 + minItems: 1 + type: array + x-kubernetes-list-type: set + selector: description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. + Selector matches labels on control-cluster Namespaces, with from: Selector. It must select + something: to admit every namespace, use from: All. properties: - key: - description: key is the label key that the selector - applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. items: - type: string + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object type: array x-kubernetes-list-type: atomic - required: - - key - - operator + 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 - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: + x-kubernetes-map-type: atomic + required: + - from + type: object + x-kubernetes-validations: + - message: 'from: Names needs names' + rule: self.from != 'Names' || has(self.names) + - message: 'names goes only with from: Names' + rule: '!has(self.names) || self.from == ''Names''' + - message: 'from: Selector needs a selector; to admit every + namespace, use from: All' + rule: self.from != 'Selector' || has(self.selector) + - message: 'selector goes only with from: Selector' + rule: '!has(self.selector) || self.from == ''Selector''' + - message: 'selector: {} is refused; to admit every namespace, + use from: All' + rule: '!has(self.selector) || (has(self.selector.matchLabels) + && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) + && size(self.selector.matchExpressions) > 0)' + required: + - applierNamespaces + - consumerNamespaces + type: object + maxItems: 64 + type: array + x-kubernetes-list-type: atomic + allowedGitTargets: + description: |- + 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. + items: + description: |- + KubeProviderGitTargetGrant admits GitTargets in some control-cluster namespaces to mirror through a + KubeProvider, and bounds what they may mirror. + properties: + clusterResources: + description: |- + ClusterResources are the cluster-scoped resource types a ClusterWatchRule may mirror through an + admitted GitTarget: resource.group (clusterroles.rbac.authorization.k8s.io), a bare resource for + the core group only (nodes), or '*' for every one, including CRDs installed later. An identity + covers every served version. Omitted, this grant gives none. + + The effective scope is the union of clusterResources from all grants admitting the GitTarget. + This is a permission, not a selection: listing a type mirrors nothing until a ClusterWatchRule + asks for it. A ClusterWatchRule item with '*' in apiGroups or resources needs '*' in at least + one admitting grant; otherwise it is refused, never narrowed to the types listed. The check runs + when the ClusterWatchRule reconciles, never at admission, and a rule asking for any type outside + the union is refused as a whole, in its status (ClusterResourceNotAllowed). + items: + description: |- + ClusterResourceIdentity names a cluster-scoped resource type: resource.group, a bare resource for + the core group, or "*" for every one. + maxLength: 317 + pattern: ^(\*|[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*)$ + type: string + maxItems: 64 + minItems: 1 + type: array + x-kubernetes-list-type: set + consumerNamespaces: + description: |- + ConsumerNamespaces are the control-cluster namespaces whose GitTargets this grant admits. + from: Same is this KubeProvider's namespace. + properties: + from: + description: |- + From is where this grant admits from: Same (the namespace of the object holding the grant, not + the consumer's), Names (the namespaces in names), Selector (the namespaces whose labels match + selector) or All (every namespace). + enum: + - Same + - Names + - Selector + - All type: string + names: + description: 'Names are the control-cluster namespaces admitted + with from: Names, by exact name.' + items: + description: GrantNamespace is one control-cluster namespace + a grant admits by name. + maxLength: 63 + type: string + x-kubernetes-validations: + - message: 'names: a variable is refused, because names + are matched against the consumer''s namespace and + ''{consumer.namespace}'' would admit every consumer; + use from: All to admit every namespace, or from: Same + for the namespace of the object holding the grant' + rule: self != '{consumer.namespace}' && self != '{consumer.name}' + - message: 'names: ''*'' is refused; use from: All to + admit every namespace' + rule: self != '*' + - message: names holds namespace names (DNS labels) + rule: self == '{consumer.namespace}' || self == '{consumer.name}' + || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') + maxItems: 64 + minItems: 1 + type: array + x-kubernetes-list-type: set + selector: + description: |- + Selector matches labels on control-cluster Namespaces, with from: Selector. It must select + something: to admit every namespace, use from: All. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + 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 + x-kubernetes-map-type: atomic + required: + - from + type: object + x-kubernetes-validations: + - message: 'from: Names needs names' + rule: self.from != 'Names' || has(self.names) + - message: 'names goes only with from: Names' + rule: '!has(self.names) || self.from == ''Names''' + - message: 'from: Selector needs a selector; to admit every + namespace, use from: All' + rule: self.from != 'Selector' || has(self.selector) + - message: 'selector goes only with from: Selector' + rule: '!has(self.selector) || self.from == ''Selector''' + - message: 'selector: {} is refused; to admit every namespace, + use from: All' + rule: '!has(self.selector) || (has(self.selector.matchLabels) + && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) + && size(self.selector.matchExpressions) > 0)' + sourceNamespaces: + description: |- + SourceNamespaces are source-cluster namespaces that a WatchRule feeding an admitted GitTarget may + name in rules[].sourceNamespace. Entries are exact names, '{consumer.namespace}' for the + namespace named like the GitTarget's, or '*'. Omitted, this grant gives no namespace. + + The effective scope is the union of sourceNamespaces from all grants admitting the GitTarget, + after expanding variables. Every WatchRule item must be allowed by that union, an omitted + sourceNamespace included (it names the WatchRule's own namespace). This is a permission, not a + selection: listing a namespace watches nothing until a WatchRule names it. + + It is not a filter on "*". A rule item with sourceNamespace "*" needs '*' in at least one + admitting grant; otherwise it is refused, never narrowed to the namespaces listed. With '*' + granted, such an item is one cluster-wide watch, bounded only by this KubeProvider credential's + RBAC. + + The check runs when the WatchRule reconciles, and again when a grant changes, never at + admission. A WatchRule with any item outside the union is refused as a whole, in its status + (SourceNamespaceNotAllowed), and watches nothing. The scope bounds what is read, never where + objects are written in Git, and the credential's RBAC still applies inside it. + items: 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 - x-kubernetes-map-type: atomic - type: object - allowCrossNamespace: - description: |- - AllowCrossNamespace grants what a namespace this provider admits may reach outside itself, - through this provider's credential. Omitted, it may reach nothing outside itself. - properties: - appliers: - description: |- - Appliers lets a FluxClaim or ArgoCDClaim reaching its applier through this provider name a - Kustomization or Application in another namespace than its own. False (the default): only - the applier claim's own namespace, which in a remote cluster is the namespace of the same name; an - applier claim naming another is Configured=False (ApplierNamespaceNotAllowed). - type: boolean - sources: - description: |- - Sources lets a WatchRule mirroring through this provider watch namespaces other than its own, - including a rules[].sourceNamespace of "*", which reaches every namespace the credential can - read. False (the default): a WatchRule watches only its own namespace. It grants no access by - itself: the source credential's RBAC remains the hard maximum. It does not apply to - ClusterWatchRule, which selects no namespaces at all. - type: boolean - type: object + ScopeNamespace is one namespace in a source or applier scope: a namespace name, + "{consumer.namespace}" for the namespace of the same name as the consumer's, or "*" for every + namespace. + maxLength: 63 + type: string + x-kubernetes-validations: + - message: '''{consumer.name}'' is refused in a namespace + scope: the consumer picks its own name, so any namespace + could be reached by naming an object after it; use ''{consumer.namespace}'', + namespace names, or ''*''' + rule: self != '{consumer.name}' + - message: a namespace scope holds a namespace name, '{consumer.namespace}' + alone, or '*' alone + rule: self == '*' || self == '{consumer.namespace}' || self + == '{consumer.name}' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') + maxItems: 64 + minItems: 1 + type: array + x-kubernetes-list-type: set + required: + - consumerNamespaces + type: object + x-kubernetes-validations: + - message: a grant in allowedGitTargets sets sourceNamespaces, clusterResources, + or both; one that grants neither grants nothing + rule: has(self.sourceNamespaces) || has(self.clusterResources) + maxItems: 64 + type: array + x-kubernetes-list-type: atomic attribution: description: |- Attribution groups this cluster's author-attribution settings. The block is spelled diff --git a/config/crd/bases/configbutler.ai_watchrules.yaml b/config/crd/bases/configbutler.ai_watchrules.yaml index 4efc2dd36..cda73c969 100644 --- a/config/crd/bases/configbutler.ai_watchrules.yaml +++ b/config/crd/bases/configbutler.ai_watchrules.yaml @@ -223,9 +223,14 @@ spec: "*" for every namespace the source credential can read. "*" is one cluster-wide list and watch rather than a set of per-namespace ones, so its bound - is the credential's own RBAC and nothing else. Naming any namespace other than this rule's - own, "*" included, requires the GitTarget's KubeProvider to admit the target's namespace - AND to set spec.allowCrossNamespace.sources; while that is false, "*" is refused outright. + is the credential's own RBAC and nothing else. + + Every value, this rule's own namespace included, must be in the union of sourceNamespaces from + all grants in the GitTarget's KubeProvider spec.allowedGitTargets that admit the GitTarget + ("{consumer.namespace}" there is the target's own namespace). "*" needs "*" in at least one of + them; a union without it refuses the rule, it does not narrow the watch. One item outside the + union refuses the whole WatchRule (SourceNamespaceNotAllowed). The check runs when the rule + reconciles, never at admission. This changes only which namespace is WATCHED, never where objects are written: Git placement follows each mirrored object's own namespace. diff --git a/config/kubeprovider-local.yaml b/config/kubeprovider-local.yaml index 17992190e..6171bd9f6 100644 --- a/config/kubeprovider-local.yaml +++ b/config/kubeprovider-local.yaml @@ -1,19 +1,23 @@ -# The KubeProvider for the SUT / config-dir install, mirroring what the Helm chart renders when -# kubeProvider.create is true: "local" in the operator's namespace, using the operator's own -# identity. Every GitTarget names its KubeProvider (spec.kubeProviderRef), and the reconcile-time -# hard gate REQUIRES it to exist, so local mirroring needs this object — the operator never creates -# it. spec.inCluster is honored only because deployment.yaml lists gitops-reverser/local in -# --in-cluster-kube-providers. The empty selector admits every other namespace (matching the chart -# default); tighten in a real install. +# The KubeProvider for the SUT / config-dir install: "local" in the operator's namespace, using the +# operator's own identity, as the Helm chart renders it when kubeProvider.create is true. Every +# GitTarget names its KubeProvider (spec.kubeProviderRef), and the reconcile-time hard gate REQUIRES +# it to exist, so local mirroring needs this object — the operator never creates it. +# spec.inCluster is honored only because deployment.yaml lists gitops-reverser/local in +# --in-cluster-kube-providers. +# +# Unlike the chart, whose provider grants nothing by default, this one grants what the e2e suite +# needs: every namespace mirrors its own namespace and any cluster-scoped resource, and holds an +# applier in its own namespace. Do not copy it into a shared cluster. apiVersion: configbutler.ai/v1alpha3 kind: KubeProvider metadata: name: local spec: inCluster: {} - accessFrom: - selector: {} - # Nothing outside the admitted namespaces, as the chart renders it. - allowCrossNamespace: - sources: false - appliers: false + allowedGitTargets: + - consumerNamespaces: {from: All} + sourceNamespaces: ["{consumer.namespace}"] + clusterResources: ["*"] + allowedApplierClaims: + - consumerNamespaces: {from: All} + applierNamespaces: ["{consumer.namespace}"] diff --git a/config/rbac/flux-applier-role.yaml b/config/rbac/flux-applier-role.yaml index 5d1f89c8d..6b83377bb 100644 --- a/config/rbac/flux-applier-role.yaml +++ b/config/rbac/flux-applier-role.yaml @@ -2,7 +2,7 @@ # Kustomization and the GitRepository it applies from, suspend and resume the Kustomization and # stamp reconcile.fluxcd.io/requestedAt on either, list the consumers of the GitRepository # (Kustomizations, and HelmReleases with the HelmCharts built from it; without it the applier claim lists -# none in status.source.otherConsumers), and check that it may. +# none in status.source.alsoAppliedBy), and check that it may. # # It is kept OUT of role.yaml because no GitTarget needs it until one names an applier, and an # install that does not use Flux should not hold patch rights on Flux objects. Drop this pair when diff --git a/config/samples/README.md b/config/samples/README.md index 3c903b18d..92a759d2f 100644 --- a/config/samples/README.md +++ b/config/samples/README.md @@ -10,7 +10,7 @@ These samples are quick starting points for common GitOps Reverser setups. - `gitreceiver.yaml`: A `GitReceiver` a Git host's push webhook points at, with its token Secret in its own namespace. It wakes every `GitProvider` whose `spec.url` a delivery names. - `kubeprovider.yaml`: A `KubeProvider` for a remote cluster, with its kubeconfig Secret in its own - namespace, showing `accessFrom` and `allowCrossNamespace`. + namespace, showing `allowedGitTargets` and `allowedApplierClaims`. - `clusterwatchrule.yaml`: Minimal `ClusterWatchRule` for cluster-scoped resources. - `fluxclaim.yaml`, `argocdclaim.yaml`: An applier claim holding the Flux `Kustomization` or the Argo CD `Application` that applies `example-target`'s folder, for its `spec.applierClaimRef`. diff --git a/config/samples/argocdclaim.yaml b/config/samples/argocdclaim.yaml index ff879d348..69a5c06e2 100644 --- a/config/samples/argocdclaim.yaml +++ b/config/samples/argocdclaim.yaml @@ -11,8 +11,8 @@ spec: kubeProviderRef: name: local namespace: gitops-reverser - # In another namespace than the applier claim's, so the KubeProvider must set - # allowCrossNamespace.appliers: true, or the applier claim reports ApplierNamespaceNotAllowed. + # The KubeProvider's spec.allowedApplierClaims must admit this namespace with this applier's + # namespace in applierNamespaces, or the applier claim reports ApplierNamespaceNotAllowed. applicationRef: namespace: argocd name: example-apps diff --git a/config/samples/fluxclaim.yaml b/config/samples/fluxclaim.yaml index 2921d769b..3dbfb454e 100644 --- a/config/samples/fluxclaim.yaml +++ b/config/samples/fluxclaim.yaml @@ -11,8 +11,8 @@ spec: kubeProviderRef: name: local namespace: gitops-reverser - # In another namespace than the applier claim's, so the KubeProvider must set - # allowCrossNamespace.appliers: true, or the applier claim reports ApplierNamespaceNotAllowed. + # The KubeProvider's spec.allowedApplierClaims must admit this namespace with this applier's + # namespace in applierNamespaces, or the applier claim reports ApplierNamespaceNotAllowed. kustomizationRef: namespace: flux-system name: example-apps diff --git a/config/samples/kubeprovider.yaml b/config/samples/kubeprovider.yaml index 7bb8c52f0..0c1e444d0 100644 --- a/config/samples/kubeprovider.yaml +++ b/config/samples/kubeprovider.yaml @@ -17,17 +17,24 @@ metadata: spec: secretRef: name: prod-eu-1-kubeconfig - # Consumers in this namespace are always admitted. accessFrom admits the others: here, every - # namespace labeled tier=trusted in the control cluster. - accessFrom: - selector: - matchLabels: - tier: trusted - # What those namespaces may reach outside themselves, each granted on purpose. sources: a - # WatchRule may choose which SOURCE namespaces to mirror; while false (the default) it watches - # only its own namespace, and `sourceNamespace: "*"` is refused outright. appliers: an applier claim may - # suspend and apply a Kustomization or Application in another namespace. Both delegate to every - # admitted namespace; neither proves that a namespace owns what it reaches. - allowCrossNamespace: - sources: true - appliers: false + # Nothing uses this provider without a grant, its own namespace included. Each grant says where it + # admits from (Same, Names, Selector or All) and what: the source namespaces a GitTarget's + # WatchRules may watch ("{consumer.namespace}" is the GitTarget's own; "*" is every namespace, + # which `sourceNamespace: "*"` needs), and the cluster-scoped resources a ClusterWatchRule may mirror. + # Here: every namespace labelled tier=trusted in the control cluster mirrors its own namespace, + # and this namespace mirrors every namespace and the CRDs. + allowedGitTargets: + - consumerNamespaces: + from: Selector + selector: + matchLabels: + tier: trusted + sourceNamespaces: ["{consumer.namespace}"] + - consumerNamespaces: {from: Same} + sourceNamespaces: ["*"] + clusterResources: [customresourcedefinitions.apiextensions.k8s.io] + # Which applier claims may hold a Flux Kustomization or an Argo CD Application through this + # provider, and where it may live. Admission to mirror is no admission to apply. + allowedApplierClaims: + - consumerNamespaces: {from: Same} + applierNamespaces: ["{consumer.namespace}"] diff --git a/config/samples/quickstart-gitbranch.yaml b/config/samples/quickstart-gitbranch.yaml index cc6e524e0..fe02f3120 100644 --- a/config/samples/quickstart-gitbranch.yaml +++ b/config/samples/quickstart-gitbranch.yaml @@ -4,11 +4,16 @@ metadata: name: example-branch namespace: default spec: - # A GitProvider in this GitBranch's namespace. Its credential has to be able to open a push - # session on the repository, or this GitBranch never holds the branch. + # The GitProvider whose credential writes this branch; add `namespace:` for one in another + # namespace. Its spec.allowedGitBranches must admit this namespace and the branch, and its + # credential has to be able to open a push session on the repository, or this GitBranch never + # holds the branch. gitProviderRef: name: example-provider - # Must match one of the GitProvider's allowedBranches. Only one GitBranch writes a repository branch. + # Only one GitBranch writes a repository branch. branch: main # Optional: the branch `main` is created from while the remote does not have it. # parentBranch: develop + # Which GitTargets may write through this branch. Its own namespace needs a grant too. + allowedGitTargets: + - consumerNamespaces: {from: Same} diff --git a/config/samples/quickstart-gitprovider.yaml b/config/samples/quickstart-gitprovider.yaml index 986464ae4..055d86802 100644 --- a/config/samples/quickstart-gitprovider.yaml +++ b/config/samples/quickstart-gitprovider.yaml @@ -6,7 +6,12 @@ metadata: spec: # Replace with your own repository URL url: "git@github.com:YOUR_USERNAME/my-k8s-audit.git" - allowedBranches: ["*"] + # Which GitBranches may claim which branches through this provider. Nothing is automatic, this + # namespace included. '*' matches any branch; "teams/{consumer.namespace}/*" would give each + # namespace its own prefix. + allowedGitBranches: + - consumerNamespaces: {from: Same} + branchPatterns: ["*"] secretRef: name: git-creds diff --git a/config/samples/quickstart-gittarget.yaml b/config/samples/quickstart-gittarget.yaml index 3c4bd9770..6d715ab1e 100644 --- a/config/samples/quickstart-gittarget.yaml +++ b/config/samples/quickstart-gittarget.yaml @@ -4,13 +4,13 @@ metadata: name: example-target namespace: default spec: - # The GitBranch this folder is written to. Add `namespace:` to share one from another namespace, - # which its GitProvider's accessFrom has to admit. + # The GitBranch this folder is written to. Add `namespace:` to use one from another namespace, + # whose spec.allowedGitTargets has to admit this one. gitBranchRef: name: example-branch # The KubeProvider this folder mirrors from: the chart's `local` provider in its release - # namespace, which admits every namespace by default. Omit `namespace:` for one in this - # GitTarget's own namespace. Immutable. + # namespace, whose spec.allowedGitTargets must admit this namespace (the chart's quickstart values + # do). Omit `namespace:` for one in this GitTarget's own namespace. Immutable. kubeProviderRef: name: local namespace: gitops-reverser diff --git a/docs/INDEX.md b/docs/INDEX.md index 42007fbd2..9793c9a19 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -153,7 +153,7 @@ so you never have to read a proposal to find out it already shipped. | [`metrics-observability-plan.md`](design/metrics-observability-plan.md) | **built.** the canonical metrics document, rewritten 2026-09 and shipped in the same wave. Reads the product as two ingestion paths — object state through watch, authorship through audit — joining at the resolver, and gives each stage one counter with a bounded `outcome` so the flow and its loss paths are drawable. Records what three review rounds corrected, including two reversals worth not re-litigating: the fact-loss counters must NOT be merged (an eviction counts a fact, a trim gap an occurrence, a decode error a whole batch), and a cumulative counter cannot replace a current count, which is why `status.streams` and `status.retainedDocuments` stayed. The instrument inventory lives in [`interpreting-metrics.md`](interpreting-metrics.md), not here. Open: the Grafana JSON and alert rules | | [`reconcile-triggering.md`](design/reconcile-triggering.md) | which controllers still fail to wake up | | [`applier-echoes.md`](design/applier-echoes.md) | **built 2026-10-20, released as one change.** The applier's own write, back through the watch, is mirrored and re-planned onto a newer tip on replay, reverting a Git-side push. Two ideas close every way a stale picture can reach a commit: a write that changes nothing does not exist, and a pending write carries the actions decided on its tree, not a picture. The applier's write in the apply window is not mirrored (F1); a write is upserts and deletes decided once, so a replay never sweeps again and a save keeps its objects (F2); a resync never decides from a snapshot collected while Git was ahead or across an apply, by an apply epoch (F3); another applier's object is not mirrored into a coordinated folder (F4); flush, then apply (S1); `CatchUp` only to adopt API creates and to converge, for Flux and Argo CD alike, and no apply before every initial snapshot is published (S2). Carries the collisions left open, the promises, the code plan, thirteen e2e scenarios, and the twelve decisions taken while building (among them: siblings do not count for F3, a 5-minute hold, initial snapshots exempt from F3, the prefetch removed, no heal resync, and the flush covering the sibling workers) | -| [`apply-trigger.md`](design/apply-trigger.md) | **built for Flux and Argo CD; a webhook applier is the aim.** Why GitOps Reverser decides when Git is applied, and why the applier never applies on its own: a Flux `Kustomization` is held suspended by its `FluxClaim` and resumed for each apply, an Argo CD `Application`'s automated sync is held off by its `ArgoCDClaim`. Carries what building it taught (timers are triggers, a long interval is not exclusive control, a decision must hold at the write, a retry is a decision so only the worker makes one, an apply is an operation whose end is observed), the kustomize-controller v1.9.6 facts the suspended lifecycle rests on, its operation (resume token, outcome, the suspend after the apply), who owns `spec.suspend` (an applier claim acts only while an eligible `GitTarget` references it, one applier claim per applier), restart recovery, shared sources, the measured ~50 ms cost, flush then apply, `CatchUp` as adopt and converge for Flux and Argo CD alike, the apply window with the applier's own writes kept out of Git, resyncs held off while Git is ahead, another applier's objects kept out of a coordinated folder, and the decisions of 2026-10-17, 2026-10-19, 2026-10-20 and 2026-10-23 (the rule, the `allowCrossNamespace.appliers` grant, `Held`, the reversals of 2026-10-20, Argo CD's automated sync held off like a suspend) | +| [`apply-trigger.md`](design/apply-trigger.md) | **built for Flux and Argo CD; a webhook applier is the aim.** Why GitOps Reverser decides when Git is applied, and why the applier never applies on its own: a Flux `Kustomization` is held suspended by its `FluxClaim` and resumed for each apply, an Argo CD `Application`'s automated sync is held off by its `ArgoCDClaim`. Carries what building it taught (timers are triggers, a long interval is not exclusive control, a decision must hold at the write, a retry is a decision so only the worker makes one, an apply is an operation whose end is observed), the kustomize-controller v1.9.6 facts the suspended lifecycle rests on, its operation (resume token, outcome, the suspend after the apply), who owns `spec.suspend` (an applier claim acts only while an eligible `GitTarget` references it, one applier claim per applier), restart recovery, shared sources, the measured ~50 ms cost, flush then apply, `CatchUp` as adopt and converge for Flux and Argo CD alike, the apply window with the applier's own writes kept out of Git, resyncs held off while Git is ahead, another applier's objects kept out of a coordinated folder, and the decisions of 2026-10-17, 2026-10-19, 2026-10-20 and 2026-10-23 (the rule, the `allowCrossNamespace.appliers` grant, `InControl`, the reversals of 2026-10-20, Argo CD's automated sync held off like a suspend) | | [`push-cooldown.md`](design/push-cooldown.md) | **partly built.** Whether the fixed `5s` push cooldown still earns its place now that the commit window exists. Ordinary timer closures already space one author writing one target, but identity changes, saves with shorter timers and buffer-limit closures commit sooner, and serialization does not batch queued commits, so the cooldown is what batches them. Option C, the failure backoff, is built in #412 and shares the push timer; removing the success wait would now remove only `lastPushAt` and its branch, so that decision waits on the event-loop measurements in §9 | | [`multi-source-audit-ingress-hardening.md`](design/multi-source-audit-ingress-hardening.md) | how independent sources authenticate to a named audit route, when annotation routing is trustworthy, and how multi-provider ingestion remains fair | | [`release-image-reuse-plan.md`](design/release-image-reuse-plan.md) | PRs 2–5 unstarted | @@ -228,11 +228,19 @@ Four more ideas sit beside them. ## History — [`finished/`](finished/) -Twenty-three shipped plans and closed investigations. **Nothing here binds.** Read one +Twenty-four shipped plans and closed investigations. **Nothing here binds.** Read one only when you want to know *why* something is the way it is; the answer to *what it is* always lives in `spec/`. The newest is +[`provider-access-rules.md`](finished/provider-access-rules.md): why every reference that lends a +credential, a branch or reach needs an explicit grant on the object it reaches (`allowedGitBranches`, +`allowedGitTargets`, `allowedApplierClaims`, each with a required `from`), with nothing automatic, +not even the own namespace. It replaced `accessFrom`, `allowedBranches` and `allowCrossNamespace` +before any of them shipped, and records what revocation promises, where the code departs from the +plan, and the two recovery tests still missing. Shipped as #441. + +Before that, [`attribution-fact-stream.md`](finished/attribution-fact-stream.md): why attribution facts stopped being a keyspace the watch side polls and became a per-type log it follows into a bounded in-memory index. It deleted the `SET`/`GET` fact keys, the 150ms poll loop, and the `deletecollection` @@ -244,7 +252,7 @@ collection delete the API server sent no response body for used to lose its auth watch resume cursors still live in Redis, so this frees attribution from it rather than the install). Shipped as #283, #284, #286 and #287. -Before that, +Earlier, [`analyzer-consumer-contract-asks.md`](finished/analyzer-consumer-contract-asks.md): why a refusal carries whether it can be solved and by whom, why the analyzer's report is a KRM document that names the build that produced it, and why `ResourceIdentifier.Key()` is a diff --git a/docs/TODO.md b/docs/TODO.md index 31bdce51b..8e5ad6831 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -115,8 +115,8 @@ rebuild fails is no longer dropped: it stays pending for that retry refusing rather than reporting when a governing root disappears, a CRD default for `placement.default` (legibility only, since #319 removed the correctness objection), and a namespace-local `GitProvider` so three targets in three namespaces need not copy one credential. - That last one is answered: a `GitTarget` may name a `GitProvider` in another namespace, which - its `spec.accessFrom` admits. + That last one is answered: a `GitBranch` may name a `GitProvider` in another namespace, whose + `spec.allowedGitBranches` admit it. - [ ] Reduce duplication between `WatchRule` and `ClusterWatchRule` code paths where it makes sense. diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index f391f9a0a..53d7a0edc 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -7,6 +7,83 @@ guidance that the changelog's breaking-change entries link to. We are pre-1.0, so breaking changes bump the **minor** version (release-please is configured with `bump-minor-pre-major`) rather than the major. Read the relevant entry before upgrading across it. +## Grants say who may use a provider or a branch; `allowedBranches` is `allowedGitBranches` + +**Breaking.** Who may use a `GitProvider`, a `GitBranch` or a `KubeProvider` is a list of grants on +that object, and **nothing is admitted automatically, the object's own namespace included**. An +omitted or empty list admits nobody. + +| Grant list | Admits | Replaces | +|---|---|---| +| `GitProvider.spec.allowedGitBranches` | `GitBranch`es claiming its `branchPatterns` | `spec.allowedBranches`, and `spec.accessFrom` | +| `GitBranch.spec.allowedGitTargets` | `GitTarget`s writing through it | the `GitProvider`'s `spec.accessFrom` | +| `KubeProvider.spec.allowedGitTargets` | `GitTarget`s mirroring its `sourceNamespaces` and `clusterResources` | `spec.accessFrom`, `spec.allowCrossNamespace.sources` | +| `KubeProvider.spec.allowedApplierClaims` | applier claims holding an applier in its `applierNamespaces` | `spec.accessFrom`, `spec.allowCrossNamespace.appliers` | + +Every grant has a required `consumerNamespaces` block with a required `from`: `Same` (the namespace +of the object holding the grant), `Names` with `names`, `Selector` with a non-empty `selector`, or +`All`. Beside the block sits the scope the grant gives. `spec.accessFrom`, +`spec.allowedBranches`, `spec.allowCrossNamespace` and their chart values are gone, and the API +server prunes them. The equivalents of the old defaults: + +```yaml +kind: GitProvider +spec: + allowedGitBranches: # was allowedBranches: [main], for this namespace's GitBranches + - consumerNamespaces: {from: Same} + branchPatterns: [main] +--- +kind: GitBranch +spec: + allowedGitTargets: # this namespace's GitTargets, as before; add from: Names to share + - consumerNamespaces: {from: Same} +--- +kind: KubeProvider +spec: + allowedGitTargets: # was its own namespace, plus accessFrom, without allowCrossNamespace + - consumerNamespaces: {from: Same} + sourceNamespaces: ["{consumer.namespace}"] + allowedApplierClaims: + - consumerNamespaces: {from: Same} + applierNamespaces: ["{consumer.namespace}"] +``` + +- **Every `GitBranch`, `GitTarget` and applier claim needs a grant, also in the provider's own + namespace.** Without one it reports `NamespaceNotAuthorized` and recovers once a grant admits it. +- **`allowedBranches` became `allowedGitBranches`, which also says who may claim.** A + `GitBranch.spec.gitProviderRef` may name a `GitProvider` in another namespace. Branch patterns match + the whole branch name, `*` crosses `/`, and `teams/{consumer.namespace}/*` gives each namespace its + own prefix. A branch outside the patterns is `BranchNotAllowed`. A grant's scope names the object + asking for access, the consumer, with `{consumer.namespace}`; a branch pattern may add + `{consumer.name}` beside it. See [the consumer variables](configuration.md#the-consumer-variables). +- **Who writes a branch is the `GitBranch`'s decision.** A `GitTarget` in another namespace writes + through it only while its `spec.allowedGitTargets` admit that namespace. +- **The chart's `local` provider grants nothing** unless `kubeProvider.allowedGitTargets` and + `kubeProvider.allowedApplierClaims` do, or `quickstart.enabled=true`, which grants exactly the + quickstart namespace. To keep the old open default, every namespace mirroring its own, install with + the chart's `values-playground.yaml`. +- **A `ClusterWatchRule` needs `clusterResources`.** It mirrors only the types the grant admitting + its `GitTarget` names (`clusterroles.rbac.authorization.k8s.io`, a bare `nodes`, or `"*"`), and + one type outside them refuses the whole rule (`ClusterResourceNotAllowed`). No default grants any, + the chart's included, so a `ClusterWatchRule` through `local` stops mirroring until a grant does. +- **A `WatchRule`'s own namespace needs a grant too:** `sourceNamespaces: ["{consumer.namespace}"]`. An + explicit list is the whole scope, the union across the admitting grants, and + `sourceNamespace: "*"` needs `"*"` in at least one of them: it is refused, never narrowed. +- **An applier claim refused by `allowedApplierClaims` hands its applier back**, as one no eligible + `GitTarget` references does. A `GitBranch` no grant admits any more gives up its branch after its + branch worker pushed or refused what it held, and keeps its finalizer. +- `SourceNamespaceAuthorized` is `True` with reason `SourceNamespaceAllowed` for every admitted + rule; `LegacySourceNamespace` is gone. + +Before upgrading, list what each provider admits today, and write the grants that keep it: + +```bash +kubectl get gitproviders,kubeproviders -A -o yaml | grep -E '^ name:|namespace:|accessFrom|allowedBranches|allowCrossNamespace' +``` + +See [configuration.md](configuration.md#sharing-connections-across-namespaces) for the full model, +including a platform and its tenants. + ## `FluxTrigger` and `ArgoCDTrigger` are now `FluxClaim` and `ArgoCDClaim` **Breaking, with no migration path.** Before upgrading, delete every `FluxTrigger` and @@ -14,8 +91,9 @@ We are pre-1.0, so breaking changes bump the **minor** version (release-please i `Application`'s automated sync), then delete their CRDs. After upgrading, recreate them under the new kinds with the same `spec`, and on each `GitTarget` replace `spec.applyTriggerRef` with `spec.applierClaimRef`. The `GitTarget` condition is `ApplierClaimReady` (reason -`ApplierClaimNotFound`), the reason `HeldByAnotherTrigger` is `HeldByAnotherClaim`, and the -finalizers are `configbutler.ai/flux-claim` and `configbutler.ai/argocd-claim`. The entries below +`ApplierClaimNotFound`), the reason `HeldByAnotherTrigger` is `HeldByAnotherClaim`, the +finalizers are `configbutler.ai/flux-claim` and `configbutler.ai/argocd-claim`, and a `FluxClaim`'s +`status.source.otherConsumers` is `status.source.alsoAppliedBy`. The entries below use the old names. See [applier claim](definitions.md#applying-back). ## An `ArgoCDTrigger` holds its `Application`'s automated sync off @@ -120,8 +198,11 @@ metadata: {name: prod-eu-1, namespace: platform} spec: secretRef: name: prod-eu-1-kubeconfig # a Secret in this namespace; key defaults to value, then value.yaml - accessFrom: - names: [team-a] # other namespaces; platform itself is always admitted + allowedGitTargets: # see "Grants say who may use a provider or a branch" + - consumerNamespaces: + from: Names + names: [team-a] + sourceNamespaces: ["{consumer.namespace}"] --- kind: GitTarget metadata: {name: apps, namespace: team-a} @@ -142,8 +223,8 @@ Migrate in this order: 2. Upgrade. The chart renders a `KubeProvider` named `local` in the release namespace, using the operator's own identity (see below). Its values are `kubeProvider.create`, `kubeProvider.name`, - `kubeProvider.secretRef`, `kubeProvider.accessFrom` and `kubeProvider.allowCrossNamespace`; the - `clusterProvider.*` values are gone. + `kubeProvider.secretRef`, `kubeProvider.allowedGitTargets` and `kubeProvider.allowedApplierClaims`; + the `clusterProvider.*` values are gone. 3. Create a `KubeProvider` for each other `ClusterProvider`, in the namespace that should own the credential. **The kubeconfig Secret is read from the provider's own namespace**, not from the operator's, so move or copy it there. `spec.kubeConfig.secretRef` is `spec.secretRef`. @@ -162,9 +243,9 @@ and `controllerManager.inClusterKubeProviders` adds others. Any other provider t `spec.inCluster` reports `Validated=False` and `Stalled=True` (`InClusterNotAuthorized`) and is never used. `spec.client` is refused beside `spec.inCluster`, where it was ignored. -**A provider admits its own namespace.** An empty `accessFrom` admitted nobody on a -`ClusterProvider`. On a `KubeProvider` it admits only the provider's own namespace, and `accessFrom` -admits the others. `allowCrossNamespace.sources` and `.appliers` keep their meaning. +**`accessFrom` and `allowCrossNamespace` are replaced by grants.** A `KubeProvider` admits nobody, +its own namespace included, until a grant in `spec.allowedGitTargets` or `spec.allowedApplierClaims` +does; see "Grants say who may use a provider or a branch". **Audit routes default to `.`, so the audit webhook URL changes.** The chart's provider posts under `/audit-webhook/.local`, `/audit-webhook/gitops-reverser.local` @@ -184,7 +265,7 @@ name, so two providers with one name in two namespaces are two series. ## `GitBranch` holds a repository branch; a `GitTarget` names one with `spec.gitBranchRef` -**Breaking.** A repository branch is its own object, `GitBranch`, in the namespace of the +**Breaking.** A repository branch is its own object, `GitBranch`, claiming the branch through the `GitProvider` whose credential writes it. A `GitTarget` names one with the required, immutable `spec.gitBranchRef`, and `spec.gitProviderRef`, `spec.branch` and `spec.parentBranch` are gone from `GitTarget`: setting one is refused at admission, with a message naming `spec.gitBranchRef`. @@ -197,9 +278,11 @@ Replace each GitTarget's three fields with a `GitBranch` and a reference to it: kind: GitBranch metadata: {name: platform-main, namespace: platform} spec: - gitProviderRef: {name: platform} # a GitProvider in this namespace + gitProviderRef: {name: platform} # namespace: omitted means this one branch: main # parentBranch: develop # was GitTarget.spec.parentBranch + allowedGitTargets: # who may write through it; see the entry on grants + - consumerNamespaces: {from: Same} --- kind: GitTarget metadata: {name: apps, namespace: platform} @@ -218,10 +301,11 @@ kubectl get gittargets -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{. Run it before upgrading the CRDs: the new schema no longer serves the old fields. -**One `GitBranch` holds a repository branch.** It holds it once its `GitProvider`'s credential -opens a push session on the remote; the oldest such `GitBranch` holds it, keeps it through a later -credential failure, and every other `GitBranch` for the same repository branch (compared as URLs, -so an `https://` and an SSH spelling of one repository are one) reports `Stalled=True` +**One `GitBranch` holds a repository branch.** It holds it once a grant on its `GitProvider` admits +it and the provider's credential opens a push session on the remote; the oldest such `GitBranch` +holds it, keeps it through a later credential failure, and every other `GitBranch` for the same +repository branch (compared as URLs, so an `https://` and an SSH spelling of one repository are one) +reports `Stalled=True` (`BranchConflict`) and writes nothing. Create one `GitBranch` per repository branch, and point every `GitTarget` on that branch at it. @@ -234,28 +318,15 @@ arrives. On a host that offers push sessions anonymously it proves only that the reachable. **Sharing a repository branch across namespaces.** A `GitTarget` names a `GitBranch` in another -namespace with `spec.gitBranchRef.namespace`; that `GitBranch`'s `GitProvider` must admit the -`GitTarget`'s namespace in `spec.accessFrom`, or the target reports `Validated=False` -(`NamespaceNotAuthorized`). The tenant namespace holds no copy of the credential: - -```yaml -kind: GitProvider -metadata: {name: monorepo, namespace: platform} -spec: - accessFrom: - names: [team-a, team-b] # or selector:, matched against Namespace labels ---- -kind: GitTarget -metadata: {name: apps, namespace: team-a} -spec: - gitBranchRef: {name: monorepo-main, namespace: platform} -``` +namespace with `spec.gitBranchRef.namespace`; that `GitBranch`'s `spec.allowedGitTargets` must admit +the `GitTarget`'s namespace, or the target reports `Validated=False` (`NamespaceNotAuthorized`). The +tenant namespace holds no copy of the credential. New reasons follow from it. A `GitTarget` whose `GitBranch` does not exist reports `Validated=False` (`GitBranchNotFound`); one whose `GitBranch` does not hold its branch, or is not `Ready`, reports `GitBranchReady=False` (`GitBranchNotReady`) and waits. That condition replaces -`GitProviderReady`. `BranchNotAllowed`, `ParentBranchNotFound` and `RecoveringParentBranch` are now -reported by the `GitBranch`, and `GitTarget`s on it show them in the `GitBranchReady` message. +`GitProviderReady`. `NamespaceNotAuthorized`, `BranchNotAllowed`, `ParentBranchNotFound` and +`RecoveringParentBranch` are now reported by the `GitBranch`, and `GitTarget`s on it show them in the `GitBranchReady` message. `TargetConflict` now means only overlapping paths on one `GitBranch`. A `WatchRule` or `ClusterWatchRule` no longer reads the GitProvider and no longer reports `GitProviderNotFound`; it follows its `GitTarget`'s readiness. The chart's quick start renders a `GitBranch` diff --git a/docs/architecture.md b/docs/architecture.md index 677997959..e0710f46d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,9 +20,9 @@ credential belongs in the namespace of whoever owns it. A namespaced `GitProvide write boundary: its credential, branch policy, and targets usually belong together. A namespaced `KubeProvider` is one **logical source identity**: one client, discovery surface, watch state, and attribution partition, shared by every target that names it. Its kubeconfig Secret lives in its own -namespace, so a tenant's connection keeps its credential with the tenant. A provider admits -consumers in its own namespace, and `accessFrom` admits other control-cluster namespaces; neither -grants source-cluster RBAC or selects source namespaces. The source identity is the provider's +namespace, so a tenant's connection keeps its credential with the tenant. Its grants +(`spec.allowedGitTargets`, `spec.allowedApplierClaims`) admit control-cluster namespaces, its own +included only by a grant, and bound what they may reach; none grants source-cluster RBAC. The source identity is the provider's namespace and name, with no API-server identity probe: two providers configured for the same server remain separate source partitions, and so do two providers with one name in two namespaces. @@ -34,13 +34,14 @@ or forbidden credential falls through to the operator's identity. Keeping a repository's write credential in the namespace of whoever owns that repository is what makes a team able to start mirroring without a platform admin. Each branch of that repository is a -`GitBranch` in the credential's namespace, and **one `GitBranch` holds a repository branch**, -because one branch worker writes the branch, with one credential: the oldest `GitBranch` whose -credential opens a push session on the remote holds it, and any other reports `BranchConflict`. A -repository written from several namespaces needs no copy of the credential in each: their -`GitTarget`s name the shared `GitBranch` with -`spec.gitBranchRef.namespace`, admitted by its `GitProvider`'s `spec.accessFrom`, the same pattern a -`KubeProvider` uses. Its own namespace is always admitted. Neither kind can change scope later: a +`GitBranch`, in the credential's namespace or in one the `GitProvider`'s `spec.allowedGitBranches` +admit for that branch, and **one `GitBranch` holds a repository branch**, because one branch worker +writes the branch, with one credential: the oldest admitted `GitBranch` whose credential opens a +push session on the remote holds it, and any other reports `BranchConflict`. A repository written +from several namespaces needs no copy of the credential in each: their `GitTarget`s name the shared +`GitBranch` with `spec.gitBranchRef.namespace`, admitted by that `GitBranch`'s +`spec.allowedGitTargets`. Nothing is admitted automatically, the own namespace included +([configuration.md](configuration.md#sharing-connections-across-namespaces)). Neither kind can change scope later: a CRD's `spec.scope` is immutable once the definition is `Established`, so the change would mean deleting the definition (and every object of the kind with it) and recreating both. @@ -384,14 +385,14 @@ Subresources are rejected in rule resources. Mirroring operates on top level res made through the `/scale` subresource reaches Git as the parent's own watch event, and its audit event serves only to name who scaled the parent. -The two namespace controls intentionally live in different planes: +One `KubeProvider` grant answers two questions in different planes: -- `KubeProvider.spec.accessFrom` authorizes **control-cluster** namespaces other than its own to - reference the provider. It is a tenant/export boundary; it neither selects nor grants access to source namespaces. -- `KubeProvider.spec.allowCrossNamespace.sources` delegates the CHOICE of source namespace to the - `GitTarget`s that provider admits. It grants nothing: what may actually be read from the source - cluster is bounded by that credential's own Kubernetes RBAC, which is the only bound there is. The - operator reads no `Namespace` objects in a source cluster for authorization. +- `from` (with `names` or `selector`) authorizes **control-cluster** namespaces to reference the + provider. It is a tenant/export boundary; it neither selects nor grants access to source namespaces. +- `sourceNamespaces` and `clusterResources` bound what the `GitTarget`s it admits may watch. They + grant nothing: what may actually be read from the source cluster is bounded by that credential's + own Kubernetes RBAC. The operator reads no `Namespace` objects in a source cluster for + authorization. ### CommitRequest @@ -431,10 +432,10 @@ One materialization from a source provider to a Git destination: `(KubeProvider, Key fields: - `spec.gitBranchRef`: required; a `GitBranch` by name, in the `GitTarget`'s own namespace unless - `namespace` names another one, whose `GitProvider`'s `spec.accessFrom` must then admit it. + `namespace` names another one. Its `spec.allowedGitTargets` must admit the `GitTarget`'s namespace. - `spec.kubeProviderRef`: required; the source `KubeProvider`, in the `GitTarget`'s own namespace - unless `namespace` names another one, whose `spec.accessFrom` must then admit it. There is no - default provider: a missing one reports `Validated=False` (`KubeProviderNotFound`). + unless `namespace` names another one. Its `spec.allowedGitTargets` must admit the `GitTarget`'s + namespace. There is no default provider: a missing one reports `Validated=False` (`KubeProviderNotFound`). - `spec.path`: immutable, required path under the repo (`MinLength=1`; `.` means repo root and must be chosen explicitly). - `spec.encryption`: optional SOPS/age encryption settings for sensitive resources. @@ -482,7 +483,7 @@ Status has a kstatus-compatible summary layer plus domain conditions: applier applies this repository and branch. It is a gate. - `status.streams` is a bounded count summary, not a per-type list. -An applier claim has `Configured` (a `FluxClaim` also `Held`) and the kstatus trio, `status.source` +An applier claim has `Configured`, `InControl` and the kstatus trio, `status.source` (what its applier applies, as last read), and `status.operation` and `status.repairPending`, which the branch worker writes at every move of the operation it started. @@ -499,12 +500,10 @@ Represents a Git repository and the credentials/configuration used to write it. - `spec.url`: immutable repository URL. - `spec.secretRef`: optional Secret in the same namespace for HTTP/SSH authentication. -- `spec.accessFrom`: which other namespaces' `GitTarget`s may write through this provider's - `GitBranch`es, by name or label selector, as on a `KubeProvider`. Its own namespace is always - admitted; omitted admits no other. A `GitTarget` admitted from another namespace writes with this - provider's credential and identity. Checked on every reconcile, so a tightened policy revokes. +- `spec.allowedGitBranches`: which namespaces' `GitBranch`es may claim which branches with this + credential, as grants with a `from` and branch patterns. Its own namespace needs a grant too. A + `GitBranch` it no longer admits gives its branch up, keeping its finalizer. - `spec.knownHostsRef`: optional SSH known hosts source. -- `spec.allowedBranches`: glob patterns that gate writable branches. - `spec.commit.committer`: committer identity (defaults to `GitOps Reverser` / `noreply@configbutler.ai`). - `spec.commit.signing`: SSH signing key reference and optional key generation. - `status.signingPublicKey`: populated when signing is configured and key material is available. @@ -521,15 +520,18 @@ Kubernetes native, Flux, and Argo CD Secret key dialects (see - **Controller**: [internal/controller/gitbranch_controller.go](../internal/controller/gitbranch_controller.go) One branch of the repository a `GitProvider` names, holding it for every `GitTarget` that references -it. Every field is immutable: +it. Every field but `spec.allowedGitTargets` is immutable: -- `spec.gitProviderRef`: a `GitProvider` in the `GitBranch`'s own namespace only, so creating one - takes the right to create objects where the credential lives. -- `spec.branch`: validated against the provider's `allowedBranches` (`BranchNotAllowed`). +- `spec.gitProviderRef`: the `GitProvider`, in the `GitBranch`'s own namespace unless `namespace` + names another. Its `spec.allowedGitBranches` must admit this namespace and the branch + (`NamespaceNotAuthorized`, `BranchNotAllowed`), checked before any push session. +- `spec.branch`: the branch it claims. - `spec.parentBranch`: optional branch the write branch is created from and compared against while it does not exist. Omitted means the remote's default branch. An explicit parent must exist when - creating the write branch (`ParentBranchNotFound`). It does not need to be in `allowedBranches` - because it is only read. + creating the write branch (`ParentBranchNotFound`). The grants need not allow it, because it is + only read. +- `spec.allowedGitTargets`: which namespaces' `GitTarget`s may write through it, its own included + only by a grant. - `status.repository`: the normalized repository URL, half of the key one `GitBranch` holds. - `status.lastPushNoticeAt`: when a `GitReceiver` last delivered a push to it. It records receipt, so it is published even when the look the delivery caused could not read the remote. @@ -591,11 +593,11 @@ immutable arms (delete and recreate the provider to repoint it): Several namespaces may mirror through one provider, but its source identity must not vary by target because it keys source clients, discovery, watches, and attribution. The identity is the provider's `namespace/name` rather than a deduplicated physical-cluster identity, so two providers pointing at the -same API server still have separate contexts and authorization boundaries. A provider admits -consumers in its own namespace; `spec.accessFrom` admits others, and an omitted one admits no other -namespace. The check runs on every reconcile before watches start (`NamespaceNotAuthorized`), so -tightening it also stops an already-existing `GitTarget`, which an admission-time check could not -do. It guards which tenant may cause the operator to export a source; it does not expand that source +same API server still have separate contexts and authorization boundaries. A provider admits only +the namespaces its grants admit, its own included only by a grant. The check runs on every reconcile +before watches start (`NamespaceNotAuthorized`), and a grant or Namespace label change triggers that +reconcile, so tightening it also stops an already-existing `GitTarget`, which an admission-time check +could not do. It guards which tenant may cause the operator to export a source; it does not expand that source credential's Kubernetes permissions. Its audit route, `spec.attribution.auditRoute`, defaults to `.` and is held by one @@ -1341,8 +1343,8 @@ Later attempts may resume a cursor; see There is no periodic object LIST or timer-driven object-drift sweep. Source-namespace selection uses delegated rule configuration and the source credential's permissions; it does not require -reading source Namespace labels. Control-cluster Namespace labels can govern `accessFrom`, which -is a separate provider-reference authorization boundary. +reading source Namespace labels. Control-cluster Namespace labels can govern a `Selector` grant, +which is a separate provider-reference authorization boundary. ### Rule change reconcile diff --git a/docs/attribution-setup-guide.md b/docs/attribution-setup-guide.md index a111ed0d6..e76a13ab7 100644 --- a/docs/attribution-setup-guide.md +++ b/docs/attribution-setup-guide.md @@ -29,9 +29,11 @@ and watches cold-replay. For a disposable single-pod setup, `attribution.transpo no Redis but loses facts whenever the reverser restarts. It also leaves Redis-backed features such as `CommitRequest` author capture inactive unless you configure Redis separately. -The chart's `KubeProvider`, `local` in the release namespace, admits targets from every namespace. -To restrict it, set `kubeProvider.accessFrom.names` and `selector: null`; Helm otherwise retains the -permissive `selector: {}`. Its own namespace is always admitted. See [RBAC](rbac.md) for the controller's read permissions. +The chart's `KubeProvider`, `local` in the release namespace, admits no namespace by default, its +own included. Grant the namespaces whose `GitTarget`s may mirror through it in +`kubeProvider.allowedGitTargets` (see [the grants](configuration.md#the-grants)), or install with +the chart's `values-playground.yaml` to let every namespace mirror its own. See [RBAC](rbac.md) for +the controller's read permissions. The chart creates the audit Service and cert-manager certificates. Wait for the release to be ready, then read its Secret names and kubeconfig generator: diff --git a/docs/azure-devops-getting-started.md b/docs/azure-devops-getting-started.md index d1e926be2..7a145ac1b 100644 --- a/docs/azure-devops-getting-started.md +++ b/docs/azure-devops-getting-started.md @@ -44,8 +44,9 @@ spec: url: https://dev.azure.com///_git/ secretRef: name: ado-creds - allowedBranches: - - + allowedGitBranches: + - consumerNamespaces: {from: Same} + branchPatterns: [] --- apiVersion: configbutler.ai/v1alpha3 kind: GitBranch diff --git a/docs/bi-directional-argocd.md b/docs/bi-directional-argocd.md index bcd33087a..fd1ae1f31 100644 --- a/docs/bi-directional-argocd.md +++ b/docs/bi-directional-argocd.md @@ -55,7 +55,7 @@ writes a switch that is somebody else's, and only checks it: `ServerSideApply=true`): it sets the switch on every apply. Set `enabled: false` in the manifest it applies, or leave `enabled` out of it and the applier claim holds the switch. -While that owner keeps automated sync off, the applier claim is `Held` and GitOps Reverser syncs as usual. +While that owner keeps automated sync off, the applier claim is `InControl` and GitOps Reverser syncs as usual. While it has it on, the applier claim reports `Configured=False` with `OwnedByApplicationSet` or `AutomatedSyncOwnedElsewhere`, and GitOps Reverser never syncs the `Application`. @@ -121,10 +121,12 @@ spec: The GitOps Reverser side. The `GitProvider` points at the same repository (any URL form of it), which is how the `GitReceiver` the Git host's push webhook calls finds it; an `ArgoCDClaim` holds the `Application`, in the cluster the chart's `local` `KubeProvider` names (the operator's own, -through `spec.inCluster`), and the `GitTarget` names the applier claim. The `Application` is in `argocd` -and the applier claim in `team-a`, so the provider lets `team-a`'s applier claims reach another namespace -(`allowCrossNamespace.appliers`); with the Helm chart, set -`kubeProvider.allowCrossNamespace.appliers: true` instead of declaring it. A `KubeProvider` with +through `spec.inCluster`), and the `GitTarget` names the applier claim. Nothing may use a provider +without a grant: the `KubeProvider` lets `team-a`'s `GitTarget`s mirror `team-a` +(`allowedGitTargets`), and its applier claims hold an `Application` in `argocd` +(`allowedApplierClaims`); with the Helm chart, set `kubeProvider.allowedGitTargets` and +`kubeProvider.allowedApplierClaims` instead of declaring it. The `GitProvider` lets `team-a` claim +`main`, and the `GitBranch` lets `team-a`'s `GitTarget`s write it. A `KubeProvider` with `inCluster` that you declare yourself is honored only once it is listed in `--in-cluster-kube-providers`, which the chart does for its own: @@ -136,10 +138,16 @@ metadata: namespace: gitops-reverser spec: inCluster: {} - accessFrom: - names: [team-a] - allowCrossNamespace: - appliers: true + allowedGitTargets: + - consumerNamespaces: + from: Names + names: [team-a] + sourceNamespaces: ["{consumer.namespace}"] + allowedApplierClaims: + - consumerNamespaces: + from: Names + names: [team-a] + applierNamespaces: [argocd] --- apiVersion: v1 kind: Secret @@ -166,7 +174,9 @@ metadata: namespace: team-a spec: url: git@github.com:acme/platform.git - allowedBranches: ["main"] + allowedGitBranches: + - consumerNamespaces: {from: Same} + branchPatterns: [main] secretRef: name: platform-git --- @@ -179,6 +189,8 @@ spec: gitProviderRef: name: platform branch: main + allowedGitTargets: + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget @@ -260,14 +272,14 @@ spec: `kubeProviderRef` names the cluster that holds the `Application`: the operator's own cluster for an `inCluster` provider such as the chart's `local`, or a management cluster for a hub Argo CD that -deploys to other clusters. The provider must admit the applier claim's namespace: its own, or one its -`accessFrom` admits. The `GitTarget`'s own `spec.kubeProviderRef` stays the cluster it mirrors. An -`Application` in another namespace than the applier claim's, such as `argocd`, needs -`allowCrossNamespace.appliers: true` on that `KubeProvider`; without it the applier claim reports -`ApplierNamespaceNotAllowed`. That is a delegation, not owner consent: every namespace the provider -admits may then sync any `Application` in reach of its identity whose source is the repository its -`GitProvider` names, and nothing proves it owns the `Application` or may write to the repository. See -[what a namespace may reach outside itself](configuration.md#what-a-namespace-may-reach-outside-itself-specallowcrossnamespace). +deploys to other clusters. The provider's `spec.allowedApplierClaims` must admit the applier claim's +namespace, its own included only by a grant. The `GitTarget`'s own `spec.kubeProviderRef` stays the +cluster it mirrors. The `Application`'s namespace, such as `argocd`, must be in the +`applierNamespaces` that grant gives; otherwise the applier claim reports +`ApplierNamespaceNotAllowed`. That is a delegation, not owner consent: every namespace the grant +admits may then sync any `Application` in its scope whose source is the repository its +`GitProvider` names, and nothing proves it owns the `Application` or may write to the repository. A +denial hands a held `Application` back. See [the grants](configuration.md#the-grants). Both references are immutable: delete and recreate the applier claim to hold another `Application`. An applier claim that holds the switch carries the finalizer `configbutler.ai/argocd-claim`: deleting it, or no longer naming it from any eligible `GitTarget`, hands the switch back first: as it was @@ -303,7 +315,7 @@ names it, is admitted by its `KubeProvider`s, and writes the repository and bran `Application` syncs. An applier claim nothing eligible references starts no sync and is not in line for the `Application`; one no `GitTarget` names also reads nothing in the `Application`'s cluster. -The applier claim reports `Configured` and `Held`, and its `Ready` includes both: +The applier claim reports `Configured` and `InControl`, and its `Ready` includes both: | Reason | Means | Readiness | | --- | --- | --- | @@ -317,18 +329,20 @@ The applier claim reports `Configured` and `Held`, and its `Ready` includes both | `OwnedByApplicationSet` | Automated sync is on, and an `ApplicationSet` generated the `Application`, so its template owns the switch | stalled: set `automated.enabled: false` in the template | | `AutomatedSyncOwnedElsewhere` | Automated sync is on, and another field manager applies the switch | stalled: set `enabled: false` in the manifest it applies, or leave it out | | `Forbidden` | The applier cluster's identity may not update or patch the `Application` | stalled | -| `NamespaceNotAuthorized` | The `kubeProviderRef` names a provider in another namespace whose `accessFrom` does not admit the applier claim's namespace | stalled | +| `NamespaceNotAuthorized` | No grant in the provider's `spec.allowedApplierClaims` admits the applier claim's namespace | stalled | | `KubeProviderNotFound` | The `kubeProviderRef` names a provider that does not exist | progressing | | `AuditRouteConflict` | The provider carries an audit route another namespace holds, so it is not used | stalled | -| `ApplierNamespaceNotAllowed` | The `Application` is in another namespace than the applier claim's, and the `KubeProvider` does not set `allowCrossNamespace.appliers` | stalled | +| `ApplierNamespaceNotAllowed` | The `Application`'s namespace is not in the `applierNamespaces` the provider's grants give the applier claim's namespace | stalled | | `HeldByAnotherClaim` | Another `ArgoCDClaim` in line for the same `Application` in the same cluster, one an eligible `GitTarget` references, holds it (it holds the switch already, or was created first); this one starts no sync, and takes over once that one lets go | stalled: delete one of the two | -`Held` says whether automated sync is off: +`InControl` says whether GitOps Reverser decides, right now, when the `Application` syncs, which +is whether automated sync is off: | Reason | Means | | --- | --- | | `AutomatedSyncOff` (`True`) | Automated sync is off: held off by GitOps Reverser, or kept off by the `ApplicationSet` or field manager that owns the switch (the message says which) | | `Releasing` (`False`) | The switch is being handed back | +| `Revoking` (`False`) | The switch is being handed back because the `KubeProvider`'s `allowedApplierClaims` no longer admit the applier claim; it finishes even if a grant returns meanwhile | | `NotReferenced` (`False`), `NotConfigured` (`Unknown`) | Nothing is held: no `GitTarget` names the applier claim, or it is not `Configured` | A sync that has not completed within 5 minutes also makes the applier claim `Ready=False` with @@ -531,7 +545,7 @@ it. | `ArgoCDClaim` `Configured=False`, `ArgoCDVersionNotSupported` | Argo CD before 3.1, whose `Application` has no `enabled` | Upgrade Argo CD to 3.1 or later | | `ArgoCDClaim` `Configured=False`, `ApplierNotFound` | No `Application` with that namespace and name in the applier claim's cluster, or Argo CD is not installed there | Create it, or recreate the applier claim with the right `applicationRef`; an omitted namespace is the applier claim's | | `ApplierClaimReady=False`, `SourceMismatch`, or applier claim `UnsupportedSource` | The message names the broken [`Application` rule](#the-application) | Fix the `Application`; a sync that was waiting is asked for afterward | -| `ArgoCDClaim` `Configured=False`, `ApplierNamespaceNotAllowed` | The `Application` is in another namespace than the applier claim, such as `argocd` | Set `allowCrossNamespace.appliers: true` on the applier claim's `KubeProvider` (Helm: `kubeProvider.allowCrossNamespace.appliers`), or move the applier claim and `GitTarget` to the `Application`'s namespace | +| `ArgoCDClaim` `Configured=False`, `ApplierNamespaceNotAllowed` | The `Application` is in a namespace the grants do not give, such as `argocd` | Add it to `applierNamespaces` in the `KubeProvider`'s `spec.allowedApplierClaims` (Helm: `kubeProvider.allowedApplierClaims`) | | `ArgoCDClaim` `Configured=Unknown`, `NotBound` | A `GitTarget` names the applier claim, but cannot be judged yet: its `GitProvider` or `KubeProvider` does not exist | Create the missing object; the message names it | | `ArgoCDClaim` `Configured=False`, `Forbidden` | The identity lacks one of the [permissions](#permissions) | Grant it, or set `applierAccess.argocd: true` | | A Warning `ApplyRequestFailed` Event | A request could not be delivered, such as a sync somebody else started that did not end within 5 minutes | GitOps Reverser keeps asking after its backoff, at most every five minutes | diff --git a/docs/bi-directional-flux.md b/docs/bi-directional-flux.md index 1fd8d2139..ff529e1d2 100644 --- a/docs/bi-directional-flux.md +++ b/docs/bi-directional-flux.md @@ -71,10 +71,12 @@ spec: The GitOps Reverser side. The `GitProvider` points at the same repository (any URL form of it), which is how the `GitReceiver` the Git host's push webhook calls finds it; a `FluxClaim` holds the `Kustomization`, in the cluster the chart's `local` `KubeProvider` names (the operator's own, -through `spec.inCluster`), and the `GitTarget` names the applier claim. The `Kustomization` is in -`flux-system` and the applier claim in `team-a`, so the provider lets `team-a`'s applier claims reach another -namespace (`allowCrossNamespace.appliers`); with the Helm chart, set -`kubeProvider.allowCrossNamespace.appliers: true` instead of declaring it. A `KubeProvider` with +through `spec.inCluster`), and the `GitTarget` names the applier claim. Nothing may use a provider +without a grant: the `KubeProvider` lets `team-a`'s `GitTarget`s mirror `team-a` +(`allowedGitTargets`), and its applier claims hold a `Kustomization` in `flux-system` +(`allowedApplierClaims`); with the Helm chart, set `kubeProvider.allowedGitTargets` and +`kubeProvider.allowedApplierClaims` instead of declaring it. The `GitProvider` lets `team-a` claim +`main`, and the `GitBranch` lets `team-a`'s `GitTarget`s write it. A `KubeProvider` with `inCluster` that you declare yourself is honored only once it is listed in `--in-cluster-kube-providers`, which the chart does for its own: @@ -86,10 +88,16 @@ metadata: namespace: gitops-reverser spec: inCluster: {} - accessFrom: - names: [team-a] - allowCrossNamespace: - appliers: true + allowedGitTargets: + - consumerNamespaces: + from: Names + names: [team-a] + sourceNamespaces: ["{consumer.namespace}"] + allowedApplierClaims: + - consumerNamespaces: + from: Names + names: [team-a] + applierNamespaces: [flux-system] --- apiVersion: v1 kind: Secret @@ -116,7 +124,9 @@ metadata: namespace: team-a spec: url: git@github.com:acme/platform.git - allowedBranches: ["main"] + allowedGitBranches: + - consumerNamespaces: {from: Same} + branchPatterns: [main] secretRef: name: platform-git --- @@ -129,6 +139,8 @@ spec: gitProviderRef: name: platform branch: main + allowedGitTargets: + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget @@ -189,7 +201,7 @@ covered by the apply window, but it is avoidable. An apply of an *older* revisio checked counts as failed, and GitOps Reverser asks again. **Shared sources.** Several `Kustomization`s may read one `GitRepository`. The applier claim lists its -source's other consumers in `status.source.otherConsumers`: the `Kustomization`s no `FluxClaim` +source's other users in `status.source.alsoAppliedBy`: the `Kustomization`s no `FluxClaim` holds and the `HelmRelease`s whose chart comes from it, directly or through a `HelmChart` their `chartRef` names. Each of them applies a new artifact the moment it arrives. The list is information only and never affects `Ready`: one source for `apps` and `infra` is a normal setup. @@ -209,14 +221,14 @@ Appliers on one branch that share a source apply one at a time. with its apply window open, for as long as it runs. - `spec.dependsOn`: a dependency whose apply failed is suspended with its generation unapplied, so its dependents wait until GitOps Reverser's retry of it succeeds. -- It lives in the applier claim's namespace unless the applier claim's `KubeProvider` sets - `allowCrossNamespace.appliers: true`; an applier claim naming one elsewhere without it reports - `ApplierNamespaceNotAllowed`. A `Kustomization` in `flux-system` therefore needs the grant. It is - a delegation, not owner consent: every namespace the provider admits may then - suspend and apply any `Kustomization` in reach of the provider's identity whose source fetches the +- It lives in a namespace the applier claim's `KubeProvider` grants it in `allowedApplierClaims` + (`applierNamespaces`); an applier claim naming one elsewhere reports + `ApplierNamespaceNotAllowed`. A `Kustomization` in `flux-system` therefore needs + `applierNamespaces: [flux-system]`. It is a delegation, not owner consent: every namespace the grant + admits may then suspend and apply any `Kustomization` in its scope whose source fetches the repository its `GitProvider` names. Nothing proves that the namespace owns the `Kustomization`, or may write to that repository: the binding compares URLs. See - [what a namespace may reach outside itself](configuration.md#what-a-namespace-may-reach-outside-itself-specallowcrossnamespace). + [the grants](configuration.md#the-grants). - Several `GitTarget`s may name one `FluxClaim`, for a `Kustomization` whose `path` covers several folders. It is applied once for all of them. - Two `GitProvider`s may name one repository. Before either branch worker asks, both flush: the @@ -248,7 +260,8 @@ spec: `kubeProviderRef` names the cluster that holds the `Kustomization`: the operator's own cluster for an `inCluster` provider such as the chart's `local`, or a management cluster where Flux runs. The -provider must admit the applier claim's namespace: its own, or one its `accessFrom` admits. Both +provider's `spec.allowedApplierClaims` must admit the applier claim's namespace, its own included only +by a grant; a denial hands a held `Kustomization` back. Both references are immutable: delete and recreate the applier claim to hold another `Kustomization`. ### How the applier claim holds the `Kustomization` @@ -277,6 +290,10 @@ stateDiagram-v2 Releasing --> NotReferenced: released, Flux applies on its own again Releasing --> NotConfigured: released, while a refused GitTarget still names it Releasing --> [*]: applier claim deleted + Suspended --> Revoking: allowedApplierClaims no longer admit the applier claim + Applying --> Revoking: the same, suspended first + Releasing --> Revoking: the same, during a release + Revoking --> NotConfigured: released; a regrant then adopts it anew note right of NotConfigured Configured says why @@ -304,7 +321,7 @@ stateDiagram-v2 `HeldByAnotherClaim`, does nothing, and takes over once the holder lets go. An applier claim nothing eligible references is not in line, so it keeps no other out. - **The applier claim hands the `Kustomization` back** when no eligible `GitTarget` references it any - more, and when it is deleted. `Held` reads `Releasing`: it suspends the `Kustomization` once more + more, and when it is deleted. `InControl` reads `Releasing`: it suspends the `Kustomization` once more if an apply has it resumed, waits until that is handled, and releases `spec.suspend`, so Flux applies it on its own again (a `Released` Event). A `Kustomization` that somebody suspended before the applier claim held it stays suspended. The applier claim records how it stood in @@ -316,19 +333,22 @@ stateDiagram-v2 which inherits `status.previousSuspendState`. A `KubeProvider` that is gone does not stop the hand-back: it goes on through whatever still reaches the cluster, which for an `inCluster` provider is the operator's own. When nothing - does, or the hand-back has not finished 10 minutes after `Held` turned `Releasing`, the applier claim + does, or the hand-back has not finished 10 minutes after `InControl` turned `Releasing`, the applier claim lets go with a `ReleaseFailed` Event saying where the `Kustomization` was left. A reference that returns before the release is done holds the `Kustomization` again; one that returns after adopts - it anew. + it anew. When the `KubeProvider`'s `allowedApplierClaims` no longer admit the applier claim, the + hand-back is the same, but `InControl` reads `Revoking`, and it finishes even if a grant admits the + applier claim again meanwhile; the applier claim then adopts the `Kustomization` anew. - **Deleting or re-creating a `GitTarget` hands the `Kustomization` back.** Flux then applies the Git tip at once, which overwrites live edits not yet in Git, and the applier claim adopts the `Kustomization` again once the `GitTarget` is back. That is what Flux does without GitOps Reverser, and with no `GitTarget` nobody would carry those edits to Git anyway. -- **An applier claim whose configuration turns invalid keeps its hold.** It reports `Held=Unknown` +- **An applier claim whose configuration turns invalid keeps its hold.** It reports `InControl=Unknown` (`NotConfigured`), and the message says the `Kustomization` is left suspended under GitOps Reverser's field manager; deleting the applier claim, or its `GitTarget`s, hands it back. -The applier claim reports `Configured` and `Held`, and its `Ready` includes both: +The applier claim reports `Configured` and `InControl` (whether GitOps Reverser decides, right now, +when the `Kustomization` applies), and its `Ready` includes both: | Reason | Means | Readiness | | --- | --- | --- | @@ -340,17 +360,18 @@ The applier claim reports `Configured` and `Held`, and its `Ready` includes both | `Configured`: `UnsupportedSource` | The source is not a `GitRepository`, or breaks one of the [`GitRepository` rules](#the-gitrepository) | stalled | | `Configured`: `SuspendOwnedElsewhere` | Another field manager applies `spec.suspend` | stalled: remove the field from the manifest it applies | | `Configured`: `Forbidden` | The applier cluster's identity may not patch the `Kustomization` or its `GitRepository` | stalled | -| `Configured`: `NamespaceNotAuthorized` | The `kubeProviderRef` names a provider in another namespace whose `accessFrom` does not admit the applier claim's namespace | stalled | +| `Configured`: `NamespaceNotAuthorized` | No grant in the provider's `spec.allowedApplierClaims` admits the applier claim's namespace | stalled | | `Configured`: `KubeProviderNotFound` | The `kubeProviderRef` names a provider that does not exist | progressing | | `Configured`: `AuditRouteConflict` | The provider carries an audit route another namespace holds, so it is not used | stalled | -| `Configured`: `ApplierNamespaceNotAllowed` | The `Kustomization` is in another namespace than the applier claim's, and the `KubeProvider` does not set `allowCrossNamespace.appliers` | stalled | +| `Configured`: `ApplierNamespaceNotAllowed` | The `Kustomization`'s namespace is not in the `applierNamespaces` the provider's grants give the applier claim's namespace | stalled | | `Configured`: `HeldByAnotherClaim` | Another `FluxClaim` holds this `Kustomization` | stalled: delete one of the two | -| `Held`: `Suspended` | Suspended under GitOps Reverser, and the last suspend is handled. Nothing applies | ready | -| `Held`: `Applying` | An apply GitOps Reverser started is in flight: `status.operation` shows it | ready | -| `Held`: `Suspending` | Just suspended; waiting for the `Kustomization` to handle it | progressing | -| `Held`: `Releasing` | Handing the `Kustomization` back: suspending it if needed, then releasing `spec.suspend`, within 10 minutes | progressing | -| `Held`: `NotReferenced` | Nothing to hold: no `GitTarget` names the applier claim | ready | -| `Held`: `NotConfigured` | The applier claim is not `Configured`, so nothing is held, or the `Kustomization` is left suspended as the applier claim last held it (the message says which) | follows `Configured` | +| `InControl`: `Suspended` | Suspended under GitOps Reverser, and the last suspend is handled. Nothing applies | ready | +| `InControl`: `Applying` | An apply GitOps Reverser started is in flight: `status.operation` shows it | ready | +| `InControl`: `Suspending` | Just suspended; waiting for the `Kustomization` to handle it | progressing | +| `InControl`: `Releasing` | Handing the `Kustomization` back: suspending it if needed, then releasing `spec.suspend`, within 10 minutes | progressing | +| `InControl`: `Revoking` | The same hand-back, because the provider's `allowedApplierClaims` no longer admit the applier claim. It finishes even if a grant returns meanwhile | progressing | +| `InControl`: `NotReferenced` | Nothing to hold: no `GitTarget` names the applier claim | ready | +| `InControl`: `NotConfigured` | The applier claim is not `Configured`, so nothing is held, or the `Kustomization` is left suspended as the applier claim last held it (the message says which) | follows `Configured` | | `Ready`: `OperationUnresolved` | An apply has not completed within 5 minutes, or the `Kustomization` was [resumed by hand](#overriding-by-hand) | progressing; it clears when the apply does | `Ready=True` for an applier claim no `GitTarget` names is deliberate: it does all it is asked to, which is @@ -372,7 +393,7 @@ with the identity of that `KubeProvider`. That identity needs: | --- | --- | --- | | `kustomize.toolkit.fluxcd.io` | `kustomizations` | `get`, `list`, `patch` | | `source.toolkit.fluxcd.io` | `gitrepositories` | `get`, `patch` | -| `helm.toolkit.fluxcd.io` | `helmreleases` | `list`, to find the source's other consumers; without it `status.source.otherConsumers` stays empty | +| `helm.toolkit.fluxcd.io` | `helmreleases` | `list`, to find what else applies the source; without it `status.source.alsoAppliedBy` stays empty | | `source.toolkit.fluxcd.io` | `helmcharts` | `list`, for the same reason | | `authorization.k8s.io` | `selfsubjectaccessreviews` | `create`, which every authenticated identity has | @@ -450,8 +471,8 @@ counted as `ambiguous`. **Reverting refused edits.** With `onRefusal: Reapply`, the target's `Kustomization` is resumed for one apply of the tip; no new commit is needed. If its `GitRepository` has to fetch to reach the -tip, the consumers GitOps Reverser holds ignore the new artifact, and one in -`status.source.otherConsumers` applies it, as it would for any push. +tip, the `Kustomization`s GitOps Reverser holds ignore the new artifact, and one in +`status.source.alsoAppliedBy` applies it, as it would for any push. ## Overriding by hand @@ -493,7 +514,7 @@ flux get ks checkout -n flux-system | `FluxClaim` `Configured=False`, `ApplierNotFound` | No `Kustomization` with that namespace and name in the applier claim's cluster, or Flux is not installed there | Create it, or recreate the applier claim with the right `kustomizationRef`; an omitted namespace is the applier claim's | | `FluxClaim` `Configured=False`, `SuspendOwnedElsewhere` | The manifest that applies the `Kustomization` sets `spec.suspend` | Remove the field from that manifest | | `ApplierClaimReady=False`, `SourceMismatch`, or applier claim `UnsupportedSource` | The message names the broken [`GitRepository` rule](#the-gitrepository) | Fix the `GitRepository`; the applier claim then adopts the `Kustomization`, and the apply that was waiting is asked for | -| `FluxClaim` `Configured=False`, `ApplierNamespaceNotAllowed` | The `Kustomization` is in another namespace than the applier claim, such as `flux-system` | Set `allowCrossNamespace.appliers: true` on the applier claim's `KubeProvider` (Helm: `kubeProvider.allowCrossNamespace.appliers`), or move the applier claim and `GitTarget` to the `Kustomization`'s namespace | +| `FluxClaim` `Configured=False`, `ApplierNamespaceNotAllowed` | The `Kustomization` is in a namespace the grants do not give, such as `flux-system` | Add it to `applierNamespaces` in the `KubeProvider`'s `spec.allowedApplierClaims` (Helm: `kubeProvider.allowedApplierClaims`) | | `FluxClaim` `Configured=Unknown`, `NotBound` | A `GitTarget` names the applier claim, but cannot be judged yet: its `GitProvider` or `KubeProvider` does not exist | Create the missing object; the message names it | | `FluxClaim` `Ready=True`, `NotReferenced`, and the `Kustomization` is not suspended | No `GitTarget` names the applier claim, so it holds nothing | Name it in the `GitTarget`'s `spec.applierClaimRef` | | `FluxClaim` `Configured=False`, `Forbidden` | The identity lacks one of the [permissions](#permissions) | Grant it, or set `applierAccess.flux: true` | @@ -534,8 +555,8 @@ applied the tip is asked nothing. | --- | --- | | The [complete example](#a-complete-example) decodes strictly, reports `Configured=True`, binds the `GitTarget`, and an apply fetches and resumes the `Kustomization`; the manifest omits `spec.suspend` | [`flux_guide_test.go`](../internal/applier/flux_guide_test.go) | | Every fact of kustomize-controller v1.9.6 the lifecycle relies on, in a fake that applies them | [`fluxsim`](../internal/applier/fluxsim/fluxsim_test.go) | -| An operation is not finished when the revision is attempted; a token handled while suspended is no apply; a resume alone reverts a refusal; a suspend does not stop a running apply; the artifact is rechecked before the resume; a failed apply is suspended and Flux's requeue applies nothing; a shared source applies only the consumer asked | [`flux_lifecycle_test.go`](../internal/applier/flux_lifecycle_test.go), [`flux_consumers_test.go`](../internal/applier/flux_consumers_test.go) | -| Adoption, an external resume suspended again with an Event each time, `SuspendOwnedElsewhere`, the hand-back on deletion (a `KubeProvider` gone included), and the source's other consumers | [`fluxclaim_hold_test.go`](../internal/controller/fluxclaim_hold_test.go) | +| An operation is not finished when the revision is attempted; a token handled while suspended is no apply; a resume alone reverts a refusal; a suspend does not stop a running apply; the artifact is rechecked before the resume; a failed apply is suspended and Flux's requeue applies nothing; a shared source applies only the consumer asked | [`flux_lifecycle_test.go`](../internal/applier/flux_lifecycle_test.go), [`flux_applied_by_test.go`](../internal/applier/flux_applied_by_test.go) | +| Adoption, an external resume suspended again with an Event each time, `SuspendOwnedElsewhere`, the hand-back on deletion (a `KubeProvider` gone included), and what else applies the source | [`fluxclaim_hold_test.go`](../internal/controller/fluxclaim_hold_test.go) | | An applier claim nothing names reads nothing and is `Ready`; the first eligible `GitTarget` adopts and the last releases, suspending an apply in flight first; a reference returning mid-release holds again; the binding over several `GitTarget`s; an older applier claim not in line keeps none out, and a retiring holder stays first until its release is done; `ApplierNamespaceNotAllowed` | [`applierclaim_rule_test.go`](../internal/controller/applierclaim_rule_test.go) | | Every operation checks the target again (incarnation, branch, admission, consent) and the source binding before it resumes, records itself first, and never asks for something weaker; a stop mid-apply suspends the `Kustomization` | [`dispatcher_test.go`](../internal/applier/dispatcher_test.go) | | A failed attempt is decided again; each `Kustomization` keeps its own backoff; a shared one is asked once; appliers sharing a source apply one at a time; nothing is sent while no flush can be made; an undo of an edit in the apply window is mirrored, and the applier's own write is not | [`apply_attempt_test.go`](../internal/git/apply_attempt_test.go), [`apply_groups_test.go`](../internal/git/apply_groups_test.go), [`applier_claim_test.go`](../internal/git/applier_claim_test.go), [`apply_window_test.go`](../internal/git/apply_window_test.go) | diff --git a/docs/bi-directional.md b/docs/bi-directional.md index 885856fda..724aafc57 100644 --- a/docs/bi-directional.md +++ b/docs/bi-directional.md @@ -32,8 +32,8 @@ flowchart TD Everything bi-directional is opt-in and off by default: the applier claims, the push receiver, `onRefusal: Reapply`, the chart's `applierAccess` grants, and the `KubeProvider`'s -`allowCrossNamespace.appliers`, which an applier claim needs to hold an applier outside its own namespace, -such as one in `flux-system` or `argocd`. A `GitTarget` without `spec.applierClaimRef` never reads, +`allowedApplierClaims`, which an applier claim needs to hold an applier at all, with `applierNamespaces` +naming where it may live, such as `flux-system` or `argocd`. A `GitTarget` without `spec.applierClaimRef` never reads, patches or syncs anything of Flux's or Argo CD's, and neither does an applier claim no `GitTarget` names, so a `Kustomization` keeps its interval and an `Application` keeps automated sync. @@ -445,9 +445,9 @@ returns to `Ready=True` only after a resync covering the refused scope. The elig wait: a push reaches the cluster only through a manual sync or `flux resume`. A graceful stop suspends a `Kustomization` it had resumed; after a crash, the apply that was running finishes, and the next process suspends it. Monitor the operator the way you monitor the applier. -- **An applier claim may hold any applier its `KubeProvider` reaches.** With - `allowCrossNamespace.appliers`, every namespace the provider admits may hold a `Kustomization` or - `Application` in another namespace whose source is the repository its `GitProvider` names. That +- **An applier claim may hold any applier its grant reaches.** A grant in `allowedApplierClaims` lets + every namespace it admits hold a `Kustomization` or `Application` in its `applierNamespaces` whose + source is the repository its `GitProvider` names. That is a delegation by the provider's owner: nothing proves the namespace owns the applier, or may write to the repository, since the binding compares URLs. - **Pending writes live in worker memory.** They do not survive a restart; recovery is a resync of diff --git a/docs/components.md b/docs/components.md index 9c4e0c458..3174d6539 100644 --- a/docs/components.md +++ b/docs/components.md @@ -79,7 +79,7 @@ The operator's own API surface and the reconcilers behind it. | CRD types | [`api/v1alpha3/`](../api/v1alpha3/) | the ten kinds users apply | | Reconcilers | [`internal/controller/`](../internal/controller/) | one per kind, plus shared condition and status helpers | | Admission handlers | [`internal/webhook/`](../internal/webhook/) | the always-allow observer and `validate-operator-types`, which captures a `CommitRequest` submitter | -| Namespace admission | [`internal/authz/`](../internal/authz/) | the `KubeProvider` and `GitProvider` namespace-admission decisions (own namespace, then `spec.accessFrom`) and the `spec.allowCrossNamespace` gates, in their own package because several call sites need the same answer | +| Access grants | [`internal/authz/`](../internal/authz/) | one admission decision per act, read from the grant lists on the referenced object (`allowedGitBranches`, `allowedGitTargets`, `allowedApplierClaims`) with the scope that applies, in their own package because several call sites need the same answer | | Rule store | [`internal/rulestore/`](../internal/rulestore/) | compiled `WatchRule` and `ClusterWatchRule` cache the data plane reads | ### Type and scope resolution diff --git a/docs/configuration.md b/docs/configuration.md index f0d18f16e..4e7899868 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -68,8 +68,7 @@ fields that need more than a line. | Field | Default | What it does | |---|---|---| | `url` | **required** | Repository URL, SSH or HTTPS | -| `allowedBranches` | **required** | Branches a `GitTarget` may write. A target naming any other is rejected | -| `accessFrom` | **no other namespace** | Other namespaces whose `GitTarget`s may write through this provider, by name or label selector. Its own namespace is always admitted | +| `allowedGitBranches` | **no `GitBranch`**, its own namespace included | Which namespaces' `GitBranch`es may claim which branches through this provider. Each grant has a `consumerNamespaces` block (`from`: `Same`, `Names`, `Selector`, `All`) and the `branchPatterns` it allows. See [the grants](#the-grants) | | `secretRef` | anonymous access | Secret holding the write credential. See [the credentials Secret](#gitproviderspecsecretref-the-credentials-secret) | | `knownHostsRef` | the Secret's own `known_hosts` | Optional ConfigMap or Secret sharing SSH host keys across providers. Host-key verification fails closed either way | | `commit.committer` | the operator's built-in identity | Name and email written as the commit committer. See [committer identity](#committer-identity) | @@ -77,12 +76,13 @@ fields that need more than a line. ### `GitBranch` (namespaced): one branch of the repository -Every field is immutable: to change one, delete the `GitBranch` and create a new one. +Every field but `allowedGitTargets` is immutable: to change one, delete the `GitBranch` and create a new one. | Field | Default | What it does | |---|---|---| -| `gitProviderRef` | **required** | The `GitProvider`, in this namespace, whose repository and credential write this branch | -| `branch` | **required** | Branch to write. Must be in the provider's `allowedBranches`. Only one `GitBranch` holds a repository branch; see [who writes a repository branch](#who-writes-a-repository-branch) | +| `gitProviderRef` | **required** | The `GitProvider` whose repository and credential write this branch: in this namespace, or `namespace` names another. Its `allowedGitBranches` must admit this namespace and `branch` | +| `branch` | **required** | Branch to write. Only one `GitBranch` holds a repository branch; see [who writes a repository branch](#who-writes-a-repository-branch) | +| `allowedGitTargets` | **no `GitTarget`**, its own namespace included | Which namespaces' `GitTarget`s may write through this branch, each grant with a `consumerNamespaces` block. The only mutable field. See [the grants](#the-grants) | | `parentBranch` | the remote's default branch | Branch a `branch` the remote does not have yet is created from. Only read, so it need not be allowed. See [starting the write branch from another branch](#starting-the-write-branch-from-another-branch-specparentbranch) | ### `GitReceiver` (namespaced): the endpoint a Git host notifies on a push @@ -100,9 +100,8 @@ Set exactly one of `secretRef` and `inCluster`. Both are immutable: delete and r |---|---|---| | `secretRef` | none; set `inCluster` instead | Secret in this provider's namespace holding a kubeconfig for the source cluster. `key` defaults to `value`, then `value.yaml`. See [`KubeProvider`](#kubeprovider) | | `inCluster` | none; set `secretRef` instead | `{}` uses the operator's own identity. Honored only for a provider listed in `--in-cluster-kube-providers`. See [the operator's own identity](#the-operators-own-identity-specincluster) | -| `accessFrom` | **no other namespace** | Other control-cluster namespaces whose objects may use this provider, by name or label selector. Its own namespace is always admitted; an empty `selector: {}` admits every namespace | -| `allowCrossNamespace.sources` | `false` | Whether rules may watch namespaces other than their own, `*` included. See [cross-namespace grants](#what-a-namespace-may-reach-outside-itself-specallowcrossnamespace) | -| `allowCrossNamespace.appliers` | `false` | Whether applier claims may name a `Kustomization` or `Application` in another namespace than their own | +| `allowedGitTargets` | **no `GitTarget`**, its own namespace included | Which namespaces' `GitTarget`s may mirror through this provider, and what: each grant has a `consumerNamespaces` block, and `sourceNamespaces`, `clusterResources` or both. See [the grants](#the-grants) | +| `allowedApplierClaims` | **no applier claim**, its own namespace included | Which namespaces' applier claims may hold an applier through this provider, and the `applierNamespaces` that applier may live in. See [the grants](#the-grants) | | `client.qps` | `20` | Client rate limit for this source cluster. Refused beside `inCluster` | | `client.burst` | `30` | Client burst for this source cluster. Refused beside `inCluster` | | `attribution.auditRoute` | `.` | Audit route this provider joins. A route is held by one namespace. See [audit route](#audit-route-and-auditfactsreceived) | @@ -113,9 +112,9 @@ The destination fields are immutable: to move a target, delete it and create a n | Field | Default | What it does | |---|---|---| -| `gitBranchRef` | **required** | The `GitBranch` this target writes: its own namespace, or `namespace` names one whose `GitProvider`'s `accessFrom` admits it | +| `gitBranchRef` | **required** | The `GitBranch` this target writes: its own namespace, or `namespace` names another. Its `allowedGitTargets` must admit this namespace | | `path` | **required** | Folder within the repository. `.` targets the root; empty is rejected | -| `kubeProviderRef` | **required** | The `KubeProvider` to mirror from: its own namespace, or `namespace` names one whose `accessFrom` admits it | +| `kubeProviderRef` | **required** | The `KubeProvider` to mirror from: its own namespace, or `namespace` names another. Its `allowedGitTargets` must admit this namespace | | `commit.window.idleTimeout` | `5s` | Close a commit window after this much silence. `0s` commits every write on its own. See [the commit window](#the-commit-window-speccommitwindow) | | `commit.window.maxDuration` | `1m` | Close a commit window this long after it opened, however much keeps arriving | | `commit.message` | the built-in templates | How commits are phrased. See [commit messages](commit-messages.md) | @@ -158,7 +157,7 @@ The references are immutable: to hold another `Kustomization`, delete the applie | Field | Default | What it does | |---|---|---| -| `kubeProviderRef` | **required** | The `KubeProvider` whose cluster holds the `Kustomization`: the applier claim's own namespace, or `namespace` names one whose `accessFrom` admits it | +| `kubeProviderRef` | **required** | The `KubeProvider` whose cluster holds the `Kustomization`: the applier claim's own namespace, or `namespace` names another. Its `allowedApplierClaims` must admit this namespace | | `kustomizationRef` | **required** | The `Kustomization` that applies the folders of the `GitTarget`s naming this applier claim. See [who applies this folder](#who-applies-this-folder-specapplierclaimref) | ### `ArgoCDClaim` (namespaced): the Argo CD `Application` GitOps Reverser asks to sync @@ -167,7 +166,7 @@ The references are immutable: to hold another `Application`, delete the applier | Field | Default | What it does | |---|---|---| -| `kubeProviderRef` | **required** | The `KubeProvider` whose cluster holds the `Application`: the applier claim's own namespace, or `namespace` names one whose `accessFrom` admits it | +| `kubeProviderRef` | **required** | The `KubeProvider` whose cluster holds the `Application`: the applier claim's own namespace, or `namespace` names another. Its `allowedApplierClaims` must admit this namespace | | `applicationRef` | **required** | The `Application`, with automated sync off, that syncs the folders of the `GitTarget`s naming this applier claim. See [who applies this folder](#who-applies-this-folder-specapplierclaimref) | ### `CommitRequest` (namespaced): save now @@ -187,68 +186,395 @@ The references are immutable: to hold another `Application`, delete the applier ## Sharing connections across namespaces A platform team usually owns the repository credential, the cluster connections and the endpoint the -Git host calls; application teams only decide what to mirror where. The kinds are cut along that -line. What holds a credential (`GitProvider`, `KubeProvider`, `GitReceiver`, and the `GitBranch` -that claims a branch with that credential) sits in its owner's namespace with its Secret. What a team -decides (`GitTarget`, `WatchRule`) sits in the team's namespace, and reaches the shared objects by -reference. +Git host calls; application teams decide what to mirror where. The kinds are cut along that line. +What holds a credential (`GitProvider`, `KubeProvider`, `GitReceiver`) sits in its owner's namespace +with its Secret. What a team decides (`GitBranch`, `GitTarget`, `WatchRule`, applier claims) can sit +in the team's namespace, and reaches the shared objects by reference, as far as their grants allow. ![A platform namespace holds the shared GitProvider with its credentials Secret, a KubeProvider and a GitReceiver; each team namespace has its own WatchRule and GitTarget writing its own branch of that repository, and a push event is routed by repository and branch to the GitTarget that writes it](images/config-platform-shared.excalidraw.svg) -| Object | How other namespaces use it | -|---|---| -| `GitProvider` | Its `spec.accessFrom` admits other namespaces, by name or label selector. A `GitTarget` there writes through a `GitBranch` of the provider's namespace, named with `spec.gitBranchRef.namespace` | -| `GitBranch` | Lives beside its `GitProvider`, so claiming a branch is an act of the credential's owner. Only one `GitBranch` holds a repository branch; see [who writes a repository branch](#who-writes-a-repository-branch) | -| `KubeProvider` | Its `spec.accessFrom` admits the `GitTarget`s and applier claims of other control-cluster namespaces. It selects nothing in the source cluster and grants nothing there | -| `GitReceiver` | Nothing names it, and nothing needs admitting: it serves every `GitProvider`, in any namespace, whose `spec.url` names the repository a push reports | +### One tenant, end to end + +A platform team keeps one repository credential and one cluster connection in its namespace, +`platform`. Namespace `team-a` carries the label `platform.example.com/tenant: "true"`. + +1. The platform lets tenants claim `teams//*` and mirror their own namespace. +2. Team A creates a `GitBranch` for `teams/team-a/live`, and lets `GitTarget`s from its own + namespace write it. +3. Team A creates a `GitTarget` that references that branch and the platform's `KubeProvider`. +4. The `GitTarget` runs only while **both** grants allow it: the branch's writer grant and the + provider's source grant. ```yaml -# platform: the credential, the branch it claims, and the namespaces it admits +# platform: the credential, and who may claim which branches apiVersion: configbutler.ai/v1alpha3 kind: GitProvider -metadata: {name: platform-repo, namespace: platform} +metadata: {name: shared-repo, namespace: platform} +spec: + url: https://git.example.com/platform/config.git + secretRef: {name: repository-auth} + allowedGitBranches: + - consumerNamespaces: + from: Selector + selector: {matchLabels: {platform.example.com/tenant: "true"}} + branchPatterns: ["teams/{consumer.namespace}/*"] +--- +# platform: the cluster connection, and who may mirror which namespaces +apiVersion: configbutler.ai/v1alpha3 +kind: KubeProvider +metadata: {name: production, namespace: platform} spec: - url: git@github.com:example-org/platform.git - secretRef: {name: git-creds} - allowedBranches: ["team-a/*", "team-b/*"] - accessFrom: - names: [team-a, team-b] + secretRef: {name: production-auth} + allowedGitTargets: + - consumerNamespaces: + from: Selector + selector: {matchLabels: {platform.example.com/tenant: "true"}} + sourceNamespaces: ["{consumer.namespace}"] --- +# team-a: the branch it claims, and who may write it apiVersion: configbutler.ai/v1alpha3 kind: GitBranch -metadata: {name: team-a-live, namespace: platform} +metadata: {name: live, namespace: team-a} spec: - gitProviderRef: {name: platform-repo} - branch: team-a/live + gitProviderRef: {name: shared-repo, namespace: platform} + branch: teams/team-a/live + allowedGitTargets: + - consumerNamespaces: {from: Same} --- -# team-a: what to mirror, and where +# team-a: what to mirror, and where; a WatchRule in team-a selects what apiVersion: configbutler.ai/v1alpha3 kind: GitTarget -metadata: {name: live, namespace: team-a} +metadata: {name: apps, namespace: team-a} spec: - gitBranchRef: {name: team-a-live, namespace: platform} - kubeProviderRef: {name: local, namespace: gitops-reverser} - path: live-cluster + gitBranchRef: {name: live} + kubeProviderRef: {name: production, namespace: platform} + path: apps ``` -**Push events route themselves.** The platform team points the Git host's push webhook, or a GitHub -App's or an organization's single webhook, at one [`GitReceiver`](#gitreceiver). A verified push -names a repository and a branch, and wakes the branch worker of every `GitProvider` whose -`spec.url` names that repository, whichever namespace it is in, for that branch only. Nobody wires a -team to the receiver: a team whose new `GitTarget` writes a new branch gets its push notices from -the first push on. A team that wants an endpoint of its own creates a `GitReceiver` in its own -namespace. +| Request | Result | Because | +|---|---|---| +| `team-a`'s `GitBranch` claims `teams/team-a/live` | Allowed | The provider's grant selects `team-a`, and `teams/{consumer.namespace}/*` expands to `teams/team-a/*` | +| `team-a`'s `GitBranch` claims `main` or `teams/team-b/live` | Refused, `BranchNotAllowed` | No grant gives `team-a` either branch | +| `team-a`'s `GitTarget` writes through `team-a/live` | Allowed | `from: Same` on the `GitBranch` | +| `team-b`'s `GitTarget` writes through `team-a/live` | Refused, until team A adds a grant with `consumerNamespaces: {from: Names, names: [team-b]}` | The branch's owner decides who shares it | +| A `WatchRule` in `team-a` watches `team-a` | Allowed | `sourceNamespaces: ["{consumer.namespace}"]` | +| A `WatchRule` in `team-a` watches `kube-system` | Refused, `SourceNamespaceNotAllowed` | The grant's scope is `team-a` only | +| A `GitTarget` in `platform` mirrors through `production` | Refused, `NamespaceNotAuthorized` | No grant admits `platform`; its own namespace is not automatic | + +Two expressions name a namespace, and they name different ones: + +| Expression | Whose namespace | +|---|---| +| `from: Same` | The object **containing** the grant: for this `GitBranch`, `team-a`; for the providers, `platform` | +| `{consumer.namespace}` in a scope | The **consumer**, the object asking for access: for `team-a`'s `GitBranch`, `team-a` | + +`consumerNamespaces: {from: Same}` does not mean "the consumer's own namespace". The block says +which namespaces' consumers the grant admits, and `Same` picks the namespace of the object holding +the grant, as Gateway API's `from: Same` means "the same namespace as the Gateway". + +**Platform administrators manage provider grants and authorization labels. Branch owners manage +writer grants on their branches.** That split is why team A can share its branch without asking +the platform. + +### The grants + +Every grant answers one question: who may use this object, and for what? A grant list lives on the +object that lends something, and is named `allowed` plus the kind that refers to it: + +| Grant list | Admits | Scope | +|---|---|---| +| `GitProvider.spec.allowedGitBranches` | `GitBranch`es claiming a branch with its credential | `branchPatterns` (required) | +| `GitBranch.spec.allowedGitTargets` | `GitTarget`s writing a folder into the branch | none | +| `KubeProvider.spec.allowedGitTargets` | `GitTarget`s mirroring with its credential | `sourceNamespaces`, `clusterResources`, or both | +| `KubeProvider.spec.allowedApplierClaims` | `FluxClaim`s and `ArgoCDClaim`s holding an applier with its credential | `applierNamespaces` (required) | + +- **Nothing is automatic, the object's own namespace included.** An omitted or empty list admits + nobody. GitOps Reverser is stricter than Flux here on purpose: administering a provider and + creating the objects that use it can then carry different RBAC permissions. +- **Grants combine by union**, and order never matters. +- **An explicit scope is the whole scope**, never "these plus your own": + `applierNamespaces: [flux-system]` means `flux-system` only. An object's scope is the union of + the scopes of every grant that admits it. +- **A scope is a permission, not a selection.** `sourceNamespaces` bounds the namespaces a + `WatchRule` may name; listing one watches nothing until a rule names it. A request outside the + union is refused as a whole, never narrowed to fit: a `rules[].sourceNamespace: "*"` item under a + union without `"*"` is refused, not trimmed to the namespaces listed. +- **Every grant list is `+listType=atomic`**, so one owner applies it whole. +- **References are checked when the referrer reconciles, not at admission.** A refused object is + accepted by the API server, reports its reason in status, and recovers on its own once a grant + admits it. + +Every grant has two halves: a `consumerNamespaces` block saying which control-cluster namespaces' +consumers it admits, and beside it the scope it gives them. The block is shaped like Gateway API's +`allowedRoutes.namespaces`, with the same `from`: + +```yaml +allowedGitTargets: # on a KubeProvider + - consumerNamespaces: # who: control-cluster namespaces + from: Names + names: [team-a] + sourceNamespaces: ["{consumer.namespace}"] # what: namespaces in this provider's cluster +``` + +Unlike Gateway API, nothing defaults: `consumerNamespaces` is required on every grant, and so is +its `from`. A grant without the block, with `consumerNamespaces: null`, or with +`consumerNamespaces: {}` is refused. + +| `from` | Admits | Requires | +|---|---|---| +| `Same` | The namespace of the object containing the grant | nothing else | +| `Names` | Exactly these control-cluster namespaces | `names` | +| `Selector` | Control-cluster namespaces whose labels match | `selector` | +| `All` | Every namespace | nothing else | + +`names` goes only with `Names`, and `selector` only with `Selector`. "Team B or every tenant" is two +grants. `Same` admits an object's own namespace without writing its name, so a kustomize overlay +that moves the object moves the grant with it; a literal own-namespace name in `names` would not +follow. Admitting everyone takes `from: All`, a word a reviewer sees: `from: Selector` with an empty +`selector: {}` is refused. + +| Field | Omitted | `[]` | A value | `"*"` | +|---|---|---|---|---| +| A grant list | No access | No access | Grants, combined by union | (not a value) | +| `consumerNamespaces` | Refused: required | (not a list) | A block with `from` | (not a value) | +| `consumerNamespaces.from` | Refused: required | (not a list) | `Same`, `Names`, `Selector` or `All` | Refused: use `All` | +| `consumerNamespaces.selector` (with `Selector`) | Refused | (not a list) | A label selector on control-cluster Namespaces | `selector: {}` is refused: use `from: All` | +| `consumerNamespaces.names` (with `Names`) | Refused | Refused | Exact control-cluster namespace names; a consumer variable is refused, because `{consumer.namespace}` would admit every consumer (use `from: All`, or `from: Same` for the grant's own namespace) | Refused: use `from: All` | +| `allowedGitBranches[].branchPatterns` | Refused: required | Refused | Branch patterns | Every branch | +| `allowedGitTargets[].sourceNamespaces` | No namespace | Refused | Exactly these source-cluster namespaces, each still named by a `WatchRule` | Every namespace; **required**, in at least one admitting grant, for `rules[].sourceNamespace: "*"`, which is one cluster-wide watch | +| `allowedGitTargets[].clusterResources` | No cluster-scoped resource | Refused | Exactly these, by identity | Every cluster-scoped resource, including CRDs installed later | +| `allowedApplierClaims[].applierNamespaces` | Refused: required | Refused | Exactly these namespaces | Every namespace | + +A `KubeProvider` `allowedGitTargets` grant sets `sourceNamespaces`, `clusterResources`, or both; +one that grants neither is refused. + +### The consumer variables + +The object asking for access is the **consumer**: the `GitBranch` for `allowedGitBranches`, the +`GitTarget` for `allowedGitTargets`, and the applier claim for `allowedApplierClaims`. One grant +serves many consumers, so its scope can name the consumer instead of listing each one: + +| Variable | Is | Allowed in | For `team-a`'s `GitBranch` `live` | +|---|---|---|---| +| `{consumer.namespace}` | The consumer's namespace, in the control cluster | every scope and branch pattern | `team-a` | +| `{consumer.name}` | The consumer's name | a branch pattern that also holds `{consumer.namespace}` | `live` | + +They are the only variables. Each is replaced with the consumer's value before the scope is +compared, and nothing else about them is special. A grant has no variable for its own namespace: +the grant sits on its object, so that namespace is known when the grant is written, and +`from: Same` admits it. + +**Only the namespace bounds anything.** Creating an object in a namespace takes RBAC there, and a +grant's `from` decides which namespaces count. A name is whatever the object's creator types, so a +scope bounded by `{consumer.name}` alone would be no bound: a `GitTarget` named `kube-system` would +reach `kube-system`. That is why `{consumer.name}` is refused in `sourceNamespaces` and +`applierNamespaces`, and allowed in a branch pattern only beside `{consumer.namespace}`, where the +namespace segment does the bounding. + +**In `sourceNamespaces` and `applierNamespaces`** an entry is an exact name, +`"{consumer.namespace}"` alone, or `"*"` alone. The variable names a namespace **of the same name in +the provider's cluster**: `sourceNamespaces: ["{consumer.namespace}"]` lets each `GitTarget` mirror the +source namespace that shares its own namespace's name. For a `KubeProvider` that reaches another +cluster, that is a namespace over there that happens to share the name. Like every scope entry it +selects nothing on its own: it permits a `WatchRule` to name that one namespace (an omitted +`sourceNamespace` names it too, being the rule's own namespace), or an applier to live there. + +**In a branch pattern** both variables may appear, each as a whole path segment, and the pattern is +matched against the whole branch name: + +- **`*` matches any sequence of characters, `/` included, and the empty one.** `feature/*` matches + `feature/checkout/v2`, and `*` alone is every branch. `main` does not match `main-old`. +- **A variable is a whole path segment:** at the start or after a `/`, and at the end or before a + `/`. `teams/{consumer.namespace}/*` and `teams/{consumer.namespace}/{consumer.name}` are valid. + `teams/{consumer.namespace}*` is refused, because for `team-a` it would also match + `teams/team-ab/live`, and so is `{consumer.namespace}-{consumer.name}`. +- **`{consumer.name}` needs `{consumer.namespace}` in the same pattern.** `env/{consumer.name}` is + refused: any admitted namespace could claim `env/prod` by naming a `GitBranch` `prod`. Beside the + namespace the name is a naming convention, not a bound: `teams/{consumer.namespace}/{consumer.name}` + gives each `GitBranch` the one branch named after it, so a branch says which object claims it, but + a team that may create `GitBranch`es can still reach every branch under its own prefix. +- **No escaping, `?` or character classes.** Git refuses `*`, `?` and `[` in a branch name, so a + literal never needs escaping. +- Any other `{...}` is refused when the object is applied, naming it: + `branch pattern teams/{namespace}/* references unknown variable {namespace}; the variables are + {consumer.namespace} and {consumer.name}`. + +A [placement template](#template-variables) also has a `{namespace}`, and it means something else: +the mirrored resource's namespace, which is `_cluster` for a cluster-scoped resource. A grant +renders nothing, so placement's `|fallback`, sanitizing and empty-segment dropping do not apply. + +### Cluster-scoped resources + +`clusterResources` bounds what a [`ClusterWatchRule`](#clusterwatchrule) may mirror through a +`GitTarget` the grant admits. It names resource types: + +- **A non-core resource is `resource.group`:** `clusterroles.rbac.authorization.k8s.io`, + `customresourcedefinitions.apiextensions.k8s.io`. **A bare name is the core group only:** `nodes`, + `namespaces`. A bare `clusterroles` therefore matches nothing. +- **Exact identities or `"*"`,** with no aliases and no partial wildcards. An identity covers every + served version of its group and resource. +- **It is decided on the rule's text.** A `ClusterWatchRule` item with `"*"` in `apiGroups` or + `resources` needs `clusterResources: ["*"]`. An item that names its groups and resources is checked + pair by pair. An item that omits `apiGroups` is checked by the types it would watch: those it + matches in the source cluster's discovery, at the `apiVersions` it names. +- **One forbidden type refuses the whole `ClusterWatchRule`**, with `ResourcesResolved=False`, reason + `ClusterResourceNotAllowed`, and a message naming the type. Nothing is filtered silently. +- **Discovery never widens a grant.** Under a narrow grant no wildcard item compiles, and nothing + outside the grant is watched, so a CRD installed later cannot slip in; the rule is refused when it + next reconciles. Under `["*"]` it is included: that is what `"*"` grants. + +### Refusal messages name the grant + +A refusal says what the grants allow and what was asked, so a tenant never needs a broad grant to +find out why something failed: + +- `GitBranch`: `namespace "team-a" may claim "teams/team-a/*", "feature/*" through GitProvider + platform/shared-repo (spec.allowedGitBranches); requested branch "main" is not allowed` +- `GitTarget` on a branch: `GitBranch team-a/live admits team-a, team-b in spec.allowedGitTargets; + namespace "team-c" is not admitted` +- `WatchRule`: `KubeProvider platform/production lets namespace "team-a" watch "team-a" + (spec.allowedGitTargets[].sourceNamespaces); spec.rules[0] (...) is not allowed` + +The reasons are `NamespaceNotAuthorized` (no grant admits the namespace), `BranchNotAllowed`, +`SourceNamespaceNotAllowed`, `ClusterResourceNotAllowed` and `ApplierNamespaceNotAllowed`. + +### When a grant shrinks + +A grant shrinks through an edited list, a removed Namespace label, or a deleted object, and every +one of them reaches the objects it admitted through a watch. Once the controller observes it, it +admits no new work under the revoked permission. Work it already held may complete: + +- **A `GitBranch` no grant admits any more** gives up its branch the way a deleted one does, but + keeps its finalizer. Its branch worker closes its intake, pushes or refuses each pending write and + settles every `CommitRequest`; an event that arrives afterwards is refused. The `GitBranch` reports + `Stalled=True` with the grant's reason. Nobody holds the branch until a waiting `GitBranch` takes + it on its own reconcile, which checks its grant first, so a candidate revoked by the same edit + never holds it. The new holder starts a worker of its own, with none of the old one's work. Writes + can therefore land after the API server accepted the policy edit: Git cannot take back a push + already sent. +- **A `GitTarget`, `WatchRule`, `ClusterWatchRule` or applier claim** that loses admission or scope is + refused on its next reconcile. A write already decided is still committed and pushed. +- **An applier claim its `allowedApplierClaims` refuse** hands its applier back, as one no eligible + `GitTarget` references does: it stays the holder (`InControl=False`, `Revoking`) while it hands back, + keeps a `Kustomization` that was suspended before adoption suspended, and gives up after ten + minutes with a Warning Event if it cannot finish. A regrant during the hand-back lets it finish, and + the applier claim then adopts the applier anew. A read error never starts a hand-back. Once the + applier is resumed, Flux or Argo CD may apply Git over live edits not mirrored yet. + +Restoring a grant is enough to recover, also after a controller restart: nothing has to be edited on +the objects it admits. + +A `GitBranch` is a candidate for its branch only while a grant admits it, and the oldest admitted +candidate holds the branch, by `creationTimestamp` and then UID. Granting a namespace that holds an +old `GitBranch` for a branch someone else holds therefore hands the branch to the old one once it +proves its credential, and the live holder reports `BranchConflict`. **Remove stale `GitBranch`es +before granting.** + +### What grants do not replace + +- **Every operation runs as the provider's identity, never as the referrer's.** Grants decide who may + use that identity, and for what; they are not impersonation. A source-namespace grant permits + mirroring what the provider identity can read in that namespace. An applier-namespace grant + permits controlling matching appliers there, and the applier's own execution identity bounds what + it deploys: a Flux `Kustomization`'s `serviceAccountName`, or an Argo CD `AppProject`'s + destinations. +- **On an `inCluster` provider a scope bypasses live namespace RBAC.** The config plane *is* the + watched cluster, so the owner of an admitted object acts on the namespaces in its scope through the + operator's own credential. Grant `sourceNamespaces`, `clusterResources` and `applierNamespaces` + there on purpose. +- **A namespace grant trusts that namespace's administrators.** Anyone, or any reconciler, allowed + to create the referring kind there can use the grant. Labels a `Selector` grant matches must be + controlled by trusted administrators. A `Names` grant also admits a namespace that is deleted and + recreated with the same name. +- **Branch grants restrict claiming and writing; they do not make branches confidential from each + other.** A `parentBranch` may name any branch the provider's credential can read, so a permitted + parent's contents can be copied into the delegated branch. Inside one branch, the folder overlap + check coordinates writers; it is not a boundary between tenants. + +**The limit.** Grants decide who may claim a branch through this provider, and who may write through +this `GitBranch`. They cannot stop a claim on the same repository branch through another provider +whose credential opens a push session. The claim is keyed by the repository and the branch, not the +provider, so a `GitBranch` claiming `main` through a different `GitProvider` for the same URL, with a +copied token, holds it if it is older or first. On Gitea and Forgejo a token that can only read is +enough. Keep tenants from creating providers, as in the next example. + +### A platform and its tenants + +The model assumes one split, and RBAC enforces it: platform administrators manage providers, their +grants and the namespace labels; branch owners manage writer grants on their branches; tenants +manage the objects that use both. With Flux, objects are applied by a reconciler, so what counts is +what each reconciler's identity may create. The platform's reconciler applies: + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: team-a + labels: {platform.example.com/tenant: "true"} # the label the grants select on +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: {name: gitops-reverser-tenant} +rules: + - apiGroups: [configbutler.ai] + resources: [gitbranches, gittargets, watchrules, fluxclaims, argocdclaims, commitrequests] + verbs: [get, list, watch, create, update, patch, delete] +--- +apiVersion: v1 +kind: ServiceAccount +metadata: {name: tenant-reconciler, namespace: team-a} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: {name: tenant-reconciler, namespace: team-a} +roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: gitops-reverser-tenant} +subjects: [{kind: ServiceAccount, name: tenant-reconciler, namespace: team-a}] +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: {name: team-a, namespace: team-a} +spec: + serviceAccountName: tenant-reconciler # team-a's repository is applied as this identity + sourceRef: {kind: GitRepository, name: team-a} + path: ./ + interval: 10m + prune: true +``` + +It also applies the `GitProvider` and `KubeProvider` above, with their grants, and a `GitBranch` for +`main` in `platform` that the platform keeps for itself (`from: Same` in `allowedGitBranches`). The +Role also needs whatever team A's own workloads require. + +| Identity | Manages | Cannot | +|---|---|---| +| The platform's reconciler | `GitProvider`, `KubeProvider` and their grants; Namespaces and their labels; tenant RBAC; platform `GitBranch`es such as `main` | | +| `team-a/tenant-reconciler` | `GitBranch`, `GitTarget`, `WatchRule`, applier claims and `CommitRequest`s in `team-a` | Create or edit a provider; label a Namespace; change RBAC | + +The last column is what makes the grants hold: a tenant cannot grant itself a label, and cannot +create the second provider the limit describes. Flux's multi-tenancy lockdown +(`--no-cross-namespace-refs` and `--default-service-account` on kustomize-controller) makes the +tenant identity mandatory, so no tenant `Kustomization` falls back to the controller's own. + +### Push events route themselves + +The platform team points the Git host's push webhook, or a GitHub App's or an organization's single +webhook, at one [`GitReceiver`](#gitreceiver). A verified push names a repository and a branch, and +wakes the branch worker of every `GitProvider` whose `spec.url` names that repository, whichever +namespace it is in, for that branch only. Nothing references a `GitReceiver` and nothing needs +admitting: a delivery only wakes a worker, which reads the remote with its own credential. A team +that wants an endpoint of its own creates a `GitReceiver` in its own namespace. Nothing has a default. The operator cannot infer a safe repository, branch, or write credential, nor which cluster a folder means, so every `GitTarget` names its `GitBranch` and its `KubeProvider`. -Nothing falls back to the provider the chart renders. +Nothing falls back to the provider the chart renders, and that provider grants nothing until the +installer says so. ## `GitProvider` -`GitProvider` defines the Git remote, credentials, allowed branches, and commit behavior. A +`GitProvider` defines the Git remote, credentials, who may claim which branches, and commit behavior. A [`GitReceiver`](#gitreceiver) tells it about pushes by matching its `spec.url`; the provider does not name one. @@ -257,7 +583,8 @@ The important fields are: - `spec.url`: repository URL - `spec.secretRef.name`: Secret with Git credentials such as SSH or HTTPS auth - `spec.knownHostsRef`: optional ConfigMap/Secret with SSH `known_hosts` shared across providers -- `spec.allowedBranches`: branches this provider is allowed to write +- `spec.allowedGitBranches`: which namespaces' `GitBranch`es may claim which branches with this + credential ([the grants](#the-grants)); its own namespace needs a grant too - `spec.commit`: committer identity and signing How writes are batched and phrased is **not** here: `spec.commit.window` and `spec.commit.message` @@ -275,8 +602,9 @@ spec: url: git@github.com:example-org/example-repo.git secretRef: name: git-creds - allowedBranches: - - main + allowedGitBranches: + - consumerNamespaces: {from: Same} + branchPatterns: [main] ``` ### `GitProvider.spec.secretRef`: the credentials Secret @@ -415,8 +743,9 @@ metadata: namespace: default spec: url: git@github.com:example-org/example-repo.git - allowedBranches: - - main + allowedGitBranches: + - consumerNamespaces: {from: Same} + branchPatterns: [main] secretRef: name: git-creds commit: @@ -586,11 +915,22 @@ metadata: spec: secretRef: name: prod-eu-1-kubeconfig - accessFrom: - names: [team-a] - selector: - matchLabels: - gitops.configbutler.ai/source-access: "true" + allowedGitTargets: + - consumerNamespaces: + from: Names + names: [team-a] + sourceNamespaces: ["{consumer.namespace}"] + - consumerNamespaces: + from: Selector + selector: + matchLabels: + gitops.configbutler.ai/source-access: "true" + sourceNamespaces: ["{consumer.namespace}"] + allowedApplierClaims: + - consumerNamespaces: + from: Names + names: [team-a] + applierNamespaces: [flux-system] ``` The Secret is read in the provider's namespace, so a team's connection keeps its credential with the @@ -605,16 +945,23 @@ before it uses it, without dialing the cluster, and reports the verdict as `Vali | `KubeConfigInsecureTLSNotAllowed` | sets `insecure-skip-tls-verify`. Allowed with `--insecure-kubeconfig-tls` | | `KubeConfigFileReferenceNotAllowed` | names a token, certificate or key by file path, which the operator would read from its own Pod. Always refused: embed the `*-data` form instead | -A `KubeProvider` admits the `GitTarget`s and applier claims in its own namespace. `accessFrom` admits -other namespaces, and is evaluated against namespaces in the **control cluster**, where those -objects live. In this example, a `GitTarget` in `platform-connections`, in `team-a`, or in a -control-cluster namespace with the shown label may reference `prod-eu-1`. `names` and `selector` are -ORed, and an omitted policy admits no other namespace. A selector is only as strong as the right to -set the labels it matches: anyone who can label a namespace can admit it, so select on labels only -the platform can set, or use `names`. The check runs when the referrer is -reconciled, not at admission: a `GitTarget` or applier claim the provider does not admit is accepted by -the API server and reports `NamespaceNotAuthorized`, and it recovers on its own once the provider -admits it. +A `KubeProvider` is used only through its [grants](#the-grants), its own namespace included: + +- **`spec.allowedGitTargets`** admits `GitTarget`s to mirror through it. The union of the admitting + grants' `sourceNamespaces` bounds which namespaces the `WatchRule`s feeding a target may name, and + the union of their `clusterResources` what a `ClusterWatchRule` may mirror through it. +- **`spec.allowedApplierClaims`** admits `FluxClaim`s and `ArgoCDClaim`s to hold an applier through + it, and the union of the admitting grants' `applierNamespaces` bounds where that applier may + live. A namespace admitted to mirror is not admitted to apply. + +Grants are evaluated against namespaces in the **control cluster**, where the referring objects +live. In this example, a `GitTarget` in `team-a`, or in a control-cluster namespace with the shown +label, may mirror its own namespace through `prod-eu-1`, and an applier claim in `team-a` may hold a +`Kustomization` in `flux-system`. Nothing in `platform-connections` may use it: no grant admits it. +A selector is only as strong as the right to set the labels it matches: anyone who can label a +namespace can admit it, so select on labels only the platform can set, or use `Names`. A referrer +the provider does not admit is accepted by the API server and reports `NamespaceNotAuthorized`, and +it recovers on its own once a grant admits it. Which namespaces are read *from the source cluster* is bounded by the source connection's Kubernetes RBAC: the credential's own RBAC is the hard maximum, and there is no second allow-list beside it. @@ -650,34 +997,14 @@ to ask for by name. `spec.client` is refused beside `inCluster`: the operator's own client is shared by everything that uses it, so it is not throttled per provider. -### What a namespace may reach outside itself (`spec.allowCrossNamespace`) - -```yaml - allowCrossNamespace: # what an admitted namespace may reach outside itself - sources: false # a WatchRule may watch other namespaces, "*" included - appliers: false # an applier claim may name a Kustomization or Application in another namespace -``` +### Grants on an `inCluster` provider -Both default to `false`, and each is a power of its own: - -- **`sources`** lets a namespace *read* other namespaces' objects into its own Git. A `WatchRule` may - name a source namespace other than its own only while it is `true`; while it is `false`, it - watches only its own namespace, and `sourceNamespace: "*"` is refused outright. It delegates the - *choice* of source namespace and grants nothing on its own, since the credential still has to be - able to read what is chosen. -- **`appliers`** lets a namespace *suspend and apply* another namespace's deployments: a - `FluxClaim` or `ArgoCDClaim` may name a `Kustomization` or `Application` outside its own - namespace, such as `flux-system`. While it is `false`, an applier claim naming one elsewhere reports - `Configured=False` (`ApplierNamespaceNotAllowed`). The applier claim's own namespace is always allowed; - in a remote cluster that is the namespace of the same name. - -**It is a delegation, not owner consent.** You trust every namespace the provider admits, its own -and those `accessFrom` admits, with everything in reach of the provider's identity. Neither flag -proves that a namespace owns the objects it reaches, or the repository its `GitTarget` writes. -Setting either on an `inCluster` provider is a much sharper decision than on a remote one: there the -config plane *is* the watched cluster, so it deliberately bypasses live namespace RBAC and lets the -owner of an admitted object act on another namespace through the operator's own credential. That is -legitimate to grant on purpose, which is why each is explicit and defaults to false. +A grant on a provider that uses the operator's own identity is a much sharper decision than on a +remote one: there the config plane *is* the watched cluster, so a scope deliberately bypasses live +namespace RBAC and lets the owner of an admitted object act on the namespaces in its scope through +the operator's own credential. A grant is a delegation, not owner consent: it does not prove that a +namespace owns the objects it reaches, or the repository its `GitTarget` writes. That is legitimate +to grant on purpose, which is why the chart's `local` provider grants nothing by default. `spec.secretRef` and `spec.inCluster` are immutable, switching from one to the other included, and so is `GitTarget.spec.kubeProviderRef`: changing either would silently make an existing @@ -769,7 +1096,8 @@ There are two supported ways to get one, and both are fully declarative: the provider the chart renders. - **Let the chart render it.** With `kubeProvider.create` (on by default), the chart creates and owns a `KubeProvider` named `kubeProvider.name` (`local`) in the release namespace, using `inCluster` - unless `kubeProvider.secretRef.name` is set, with its `accessFrom` and `allowCrossNamespace`. See + unless `kubeProvider.secretRef.name` is set, with the grants in `kubeProvider.allowedGitTargets` and + `kubeProvider.allowedApplierClaims`, empty by default. See [charts/gitops-reverser/README.md](../charts/gitops-reverser/README.md). Turn `kubeProvider.create` off to manage the object yourself. Helm then deletes the provider it created on the next upgrade, so ownership never silently splits between Helm and you. Because a missing @@ -807,19 +1135,28 @@ metadata: namespace: default spec: gitProviderRef: - name: example-provider # a GitProvider in this namespace - branch: main # must match the provider's allowedBranches + name: example-provider # add namespace: for a GitProvider in another namespace + branch: main # a grant on the provider must allow it parentBranch: develop # optional; only used to create a missing branch + allowedGitTargets: # who may write through it, this namespace included + - consumerNamespaces: {from: Same} ``` -- `spec.gitProviderRef` names a `GitProvider` in the `GitBranch`'s own namespace, never another one. - Creating a `GitBranch` therefore takes the right to create objects where the credential lives. -- `spec.branch` must match one of the provider's `allowedBranches`, or the `GitBranch` reports - `Stalled=True` with reason `BranchNotAllowed`. +- `spec.gitProviderRef` names the `GitProvider` whose credential writes the branch, in this + namespace or, with `namespace`, another. The `GitBranch` claims only while a grant in the provider's + `spec.allowedGitBranches` admits its namespace and a pattern matches `spec.branch` + (`NamespaceNotAuthorized` or `BranchNotAllowed` otherwise, both `Stalled=True`). That check comes + before any push session, so a `GitBranch` no grant admits never uses the credential. +- `spec.allowedGitTargets` says which namespaces' `GitTarget`s may write through it. Claiming a branch + includes the right to share it: sharing `teams/team-a/live` with `team-b` is a `from: Names` grant + here, and the provider does not change. To keep control of the writers of a branch such as `main`, + the platform owns its `GitBranch`. - `spec.parentBranch` is what the branch is created from while the remote does not have it. See [starting the write branch from another branch](#starting-the-write-branch-from-another-branch-specparentbranch). -- Every field is immutable. To write another branch, or through another `GitProvider`, create a new - `GitBranch` and delete the old one. +- `spec.gitProviderRef`, `spec.branch` and `spec.parentBranch` are immutable: to write another branch, + or through another `GitProvider`, create a new `GitBranch` and delete the old one. The reference is + compared as written, so spelling out the `GitBranch`'s own namespace where it was omitted is a + change too. `spec.allowedGitTargets` is mutable. ### Who writes a repository branch @@ -827,8 +1164,8 @@ Only one `GitBranch` writes a repository branch. The key is the provider's repos normalized (so `https://github.com/acme/platform.git` and `git@github.com:acme/platform` are one repository), plus the branch. -1. A `GitBranch` becomes a **candidate** once its `GitProvider`'s credential opens a push - (receive-pack) session on the remote. Nothing is pushed: the session is opened and closed. A +1. A `GitBranch` becomes a **candidate** once a grant admits it and its `GitProvider`'s credential + opens a push (receive-pack) session on the remote. Nothing is pushed: the session is opened and closed. A `GitBranch` whose credential cannot open one reports `Ready=False` with reason `PushSessionFailed` and claims nothing, so creating it first blocks nobody. 2. Among the candidates for one repository branch, the **oldest** (by `creationTimestamp`) holds it @@ -836,7 +1173,8 @@ repository), plus the branch. and a message naming the holder. Reference the holder from your `GitTarget`s, or delete it first. 3. The holder keeps the branch through a later credential or network failure. It reports `Ready=False` (`PushSessionFailed`) and keeps its pending writes, and the branch does not pass to - another `GitBranch` while it is down. + another `GitBranch` while it is down. A holder no grant admits any more gives the branch up; see + [when a grant shrinks](#when-a-grant-shrinks). 4. The claim lives in the operator's memory. After a restart it is decided again by 1 and 2. What the push session proves depends on the Git host: @@ -868,14 +1206,16 @@ status: than one means they share its branch worker; each still reports its own folder's health on its own conditions. -`kubectl get gitbranch` shows the provider, the branch, the commit the branch is at, and `Ready`; +`kubectl get gitbranch` shows the provider and its namespace, the branch, the commit the branch is +at, and `Ready`; `-o wide` adds the reason, the repository, the target count and when the remote was last verified. | Reason | What it means | |---|---| -| `GitProviderNotFound` | No `GitProvider` of that name in this namespace (`Stalled=True`) | +| `GitProviderNotFound` | The `GitProvider` `spec.gitProviderRef` names does not exist (`Stalled=True`) | | `GitProviderNotReady` | The `GitProvider` exists and is not `Ready` | -| `BranchNotAllowed` | `spec.branch` is outside the provider's `allowedBranches` (`Stalled=True`) | +| `NamespaceNotAuthorized` | No grant in the provider's `spec.allowedGitBranches` admits this namespace (`Stalled=True`) | +| `BranchNotAllowed` | The grants that admit this namespace give no pattern matching `spec.branch` (`Stalled=True`) | | `PushSessionFailed` | The credential cannot open a push session; a holder keeps the branch meanwhile | | `BranchConflict` | Another `GitBranch` holds this repository branch (`Stalled=True`) | | `ParentBranchNotFound` | Neither the branch nor its parent is on the remote (`Stalled=True`) | @@ -962,10 +1302,10 @@ If you enable `spec.encryption`, that applies to `Secret` resource writes for th and age details, see [sops-age-guide.md](sops-age-guide.md). `spec.gitBranchRef` references a `GitBranch` by `name`, in the `GitTarget`'s own namespace, and is -required and immutable. Set `namespace` to write through a `GitBranch` in another namespace; the -`GitProvider` that `GitBranch` names must admit this `GitTarget`'s namespace in its -`spec.accessFrom`, or the `GitTarget` is refused with `Validated=False` (`NamespaceNotAuthorized`). -One policy governs both hops. A `GitBranch` that does not exist is `Validated=False` +required and immutable. Set `namespace` to write through a `GitBranch` in another namespace. Either +way the `GitBranch`'s `spec.allowedGitTargets` must admit this `GitTarget`'s namespace, its own +included, or the `GitTarget` is refused with `Validated=False` (`NamespaceNotAuthorized`). A +`GitBranch` that does not exist is `Validated=False` (`GitBranchNotFound`), and one that does not hold its branch, or is not `Ready`, keeps the target at `Ready=False` with reason `GitBranchNotReady` until it does. Either clears on its own once the `GitBranch` appears or proves itself, so manifests can be applied in any order. @@ -974,15 +1314,14 @@ One policy governs both hops. A `GitBranch` that does not exist is `Validated=Fa on a `GitTarget` is refused at admission with a message naming `spec.gitBranchRef`. Two `GitTarget`s on one `GitBranch` must not write the same or nested paths: the later-created one -reports `Validated=False` with reason `TargetConflict`. Only a `GitTarget` that its providers admit -holds a path this way. One that its `GitBranch`'s `GitProvider` or its `KubeProvider` does not admit -writes nothing and holds no path, so creating it first cannot block a folder on someone else's -branch. +reports `Validated=False` with reason `TargetConflict`. Only an admitted `GitTarget` holds a path this +way. One that its `GitBranch` or its `KubeProvider` does not admit writes nothing and holds no path, +so creating it first cannot block a folder on someone else's branch. `spec.kubeProviderRef` references a `KubeProvider` by `name`, in the `GitTarget`'s own namespace -unless `namespace` names another, and is required and immutable. A provider in another namespace -must admit this `GitTarget`'s namespace in its `spec.accessFrom`, or the `GitTarget` is refused with -`Validated=False` (`NamespaceNotAuthorized`). A provider that does not exist is `Validated=False` +unless `namespace` names another, and is required and immutable. The provider's +`spec.allowedGitTargets` must admit this `GitTarget`'s namespace, its own included, or the +`GitTarget` is refused with `Validated=False` (`NamespaceNotAuthorized`). A provider that does not exist is `Validated=False` (`KubeProviderNotFound`), one whose audit route another namespace holds is refused with `AuditRouteConflict`, and one that is not `Ready` keeps the target at `KubeProviderReady=False` (`KubeProviderNotReady`). See [`KubeProvider`](#kubeprovider). @@ -1189,7 +1528,7 @@ spec: - Once the write branch exists, it is used as it is: new commits on the parent do not reach it. Delete the write branch after its changes are merged, and the next edit starts a fresh one. - `parentBranch` is immutable, including adding or removing it: delete and recreate the `GitBranch` - to change it. It is only read, so it does not need to be in `allowedBranches`. + to change it. It is only read, so the provider's `allowedGitBranches` need not allow it. - While the write branch is absent, the `GitBranch`'s `status.remote.parent` shows the parent, whether it was `Found`, `Missing` or `Unborn`, and the commit the first commit would build on. With `parentBranch` omitted, that is how you see which default branch the remote resolved. @@ -1494,11 +1833,12 @@ it under the other. Name one `KubeProvider` for every applier claim that reaches **Where the applier is looked up.** In the cluster the applier claim's `spec.kubeProviderRef` names, with the identity that [`KubeProvider`](#kubeprovider) uses. Name a provider for a management cluster when Flux or Argo CD runs there; an `inCluster` provider is the operator's own cluster. That -provider must admit the applier claim's namespace, as for mirroring, or the applier claim is -`Configured=False` with `NamespaceNotAuthorized` (or `KubeProviderNotFound`). An applier in another -namespace than the applier claim's, such as `flux-system` in the example above, also needs the -provider's [`allowCrossNamespace.appliers`](#what-a-namespace-may-reach-outside-itself-specallowcrossnamespace), -or the applier claim is `Configured=False` with `ApplierNamespaceNotAllowed`. For the operator's own +provider's `spec.allowedApplierClaims` must admit the applier claim's namespace, separately from +mirroring, or the applier claim is `Configured=False` with `NamespaceNotAuthorized` (or +`KubeProviderNotFound`). The applier's namespace, such as `flux-system` in the example above, must +be in the `applierNamespaces` those grants give it, or the applier claim is `Configured=False` with +`ApplierNamespaceNotAllowed`. A denial hands a held applier back; see +[when a grant shrinks](#when-a-grant-shrinks). For the operator's own cluster, the chart grants the applier permissions with `applierAccess.flux: true` and `applierAccess.argocd: true`. @@ -1563,16 +1903,16 @@ status: status: "True" reason: Succeeded message: Kustomization flux-system/checkout applies GitRepository flux-system/platform, ... - - type: Held # FluxClaim only + - type: InControl status: "True" - reason: Suspended # Suspended | Applying | Suspending | Releasing | NotReferenced | NotConfigured + reason: Suspended # Suspended | Applying | Suspending | Releasing | Revoking | NotReferenced | NotConfigured - type: Ready status: "True" source: gitRepository: {namespace: flux-system, name: platform} url: https://github.com/acme/platform branch: main - otherConsumers: # what else applies this GitRepository, held by no FluxClaim + alsoAppliedBy: # what else applies this GitRepository, held by no FluxClaim - {kind: HelmRelease, namespace: platform, name: ingress} operation: phase: Succeeded # Requested | Running | Settling | Unresolved | Succeeded | Failed @@ -1593,15 +1933,16 @@ status: | `operation.suspendToken` | Flux only: the token of the suspend after the apply. Once it is handled, nothing that started before the suspend is still running | | `operation.appliedRevision` | The revision the applier reports it applied. It can differ from `commit` when the branch moved between the check and the apply | | `repairPending` | `true` while an apply reverted an API edit made after its flush: Git kept the edit, and the cluster runs the older value until the `Repair` | -| `source.otherConsumers` | Flux only: the `Kustomization`s and `HelmRelease`s that apply the same `GitRepository` and that no `FluxClaim` holds. Information only; it never affects `Ready` | +| `source.alsoAppliedBy` | Flux only: the `Kustomization`s and `HelmRelease`s that apply the same `GitRepository` and that no `FluxClaim` holds. Information only; it never affects `Ready` | A `Kustomization` resumed outside GitOps Reverser (`flux resume`) is suspended again and recorded as an `Unresolved` operation with no `reason` and no `commit`, which completes once the suspend is handled. -**Conditions.** `Configured` is a gate, and the applier claim's `Ready` aggregates it; a `FluxClaim` is -also `Ready` only once `Held` is `True` (`Suspended`, or `Applying` while an apply of GitOps -Reverser's is in flight). Either kind is `Ready=False` (`OperationUnresolved`) while its operation +**Conditions.** `Configured` is a gate, and the applier claim's `Ready` aggregates it; either kind is +also `Ready` only once `InControl` is `True`: GitOps Reverser decides when the applier applies +(`Suspended`, or `Applying` while an apply of GitOps Reverser's is in flight; `AutomatedSyncOff` +for an `ArgoCDClaim`). Either kind is `Ready=False` (`OperationUnresolved`) while its operation is `Unresolved`. An unreferenced applier claim is `Ready=True` (`NotReferenced`). The `GitTarget`s naming the applier claim are reported as `NotBound` (`Unknown` while one cannot be judged yet, `False` once each is refused for different reasons), or as the refusal they share, @@ -1655,7 +1996,7 @@ to this target's applier alone, and it goes out after a flush, so an allowed edi commit window is published before the apply and is not reverted with the refused one. An `Application` is asked to sync the tip. When a `Kustomization`'s `GitRepository` has to fetch to reach the tip, the `Kustomization`s a `FluxClaim` holds stay suspended and ignore the new artifact; -only the consumers no applier claim holds, listed in the applier claim's `status.source.otherConsumers`, apply +only the objects no applier claim holds, listed in the applier claim's `status.source.alsoAppliedBy`, apply it, as they would for any push. The [bi-directional guide](bi-directional.md#choosing-speconrefusal) covers when to choose it. @@ -2345,16 +2686,18 @@ so one `WatchRule` can follow different resource types in different namespaces. | an exact name | one source namespace | | `"*"` | every namespace the source credential can read, as one cluster-wide watch | -Naming anything other than the rule's own namespace, **including `"*"`**, is authorized by two -things, both of which must hold: +Every item, the rule's own namespace included, is authorized by the grants of the `GitTarget`'s +`KubeProvider`: -1. the `GitTarget`'s namespace is admitted by its `KubeProvider`: the provider's own, or one its - `accessFrom` admits; and -2. that `KubeProvider` sets `allowCrossNamespace.sources: true`. +1. a grant in its `spec.allowedGitTargets` admits the `GitTarget`'s namespace; and +2. the union of those grants' `sourceNamespaces` holds the namespace the item asks for: its name, + `"{consumer.namespace}"` for the `GitTarget`'s own (which is the `WatchRule`'s), or `"*"`. An item + asking for `"*"` needs `"*"` in at least one of them; under a union without it the item is + refused, never narrowed to the namespaces listed. There is no third condition on the `GitTarget`. What a target may read from its source cluster is -bounded by that cluster's RBAC for the provider's credential: a request these two conditions permit -still fails with a clean 403 if the credential cannot read the namespace. +bounded by that cluster's RBAC for the provider's credential: a request the grants permit still +fails with a clean 403 if the credential cannot read the namespace. ```yaml apiVersion: configbutler.ai/v1alpha3 @@ -2376,7 +2719,7 @@ spec: `"*"` is one cluster-wide list and one cluster-wide watch per matched type, not one of each per namespace. Connection count is bounded per matched type, but object count, traffic, and memory still grow with the cluster. Access is bounded by the source credential's RBAC -and by nothing else, which is why it is refused outright while `allowCrossNamespace.sources` is false. +and by nothing else, which is why it is refused outright unless a grant gives `sourceNamespaces: ["*"]`. A `"*"` item and a named-namespace item for the same type **overlap**, so one `GitTarget` cannot hold both: the newer rule is refused (see [overlapping collections](#overlapping-collections)). Keep @@ -2386,16 +2729,16 @@ The outcome for all items is aggregated into one `SourceNamespaceAuthorized` con `kubectl get watchrules -o wide`. A **denied** explicit name refuses the whole `WatchRule` (`Ready=False`, `Stalled=True`, no streams) rather than silently trimming that item and mirroring part of what you asked for; the message names the failing item by index and by what it selects. -Authorization is re-evaluated on every reconcile, so withdrawing `allowCrossNamespace.sources` revokes a -running rule rather than only affecting new ones. +Authorization is re-evaluated whenever the provider's grants or the namespace's labels change, so +narrowing a grant revokes a running rule rather than only affecting new ones. This changes only which namespace is **watched**. Git placement always follows each mirrored object's own namespace, so the rule above writes secrets under `repo-config/…`, not `tenant-acme/…`. -A namespace allow-list cannot partition **cluster-scoped** objects, which have no namespace. A -`ClusterWatchRule` receives every such object its source credential can read. If a tenant must not -see another tenant's cluster-scoped objects, give each tenant its own `KubeProvider` and -credential, so that credential's RBAC is the boundary. +A namespace scope cannot partition **cluster-scoped** objects, which have no namespace. A +`ClusterWatchRule` mirrors only the types the grant's `clusterResources` name (see +[cluster-scoped resources](#cluster-scoped-resources)), and of those, every object its source +credential can read. Each entry in `spec.rules` is a logical OR. A resource matching any rule is watched. A rule selects whole resource collections: every selected collection is observed through creates, updates, and @@ -2531,7 +2874,11 @@ namespaced resources across namespaces, use a `WatchRule` with [`rules[].sourceNamespace`](#watching-a-different-source-namespace). Because it is cluster-scoped, its `gitTargetRef` must include the namespace of the referenced -`GitTarget`, and that namespace must be admitted by the target's `KubeProvider`. +`GitTarget`. That namespace must be admitted by the target's `KubeProvider`, and the grants that admit +it must name every type the rule asks for in `clusterResources` (see +[cluster-scoped resources](#cluster-scoped-resources)). The example needs +`clusterResources: [clusterroles.rbac.authorization.k8s.io, clusterrolebindings.rbac.authorization.k8s.io]`, +or `["*"]`. Example: @@ -2550,10 +2897,10 @@ spec: resources: ["clusterroles", "clusterrolebindings"] ``` -Cluster-scoped objects have no namespace, so no namespace policy bounds a `ClusterWatchRule` at all: -it is intentionally cluster-global, limited only by its source credential's Kubernetes RBAC. Use this -sparingly. It grants the widest reach of any rule kind and usually belongs to cluster-admin-managed -setups. +Cluster-scoped objects have no namespace, so no namespace scope bounds a `ClusterWatchRule`: the +types in `clusterResources` do, and for those types its source credential's Kubernetes RBAC. Only a +cluster admin can create one, and no default grant, the chart's included, gives `clusterResources`. +Use it sparingly. ## `CommitRequest` diff --git a/docs/definitions.md b/docs/definitions.md index 1041bd0f9..f1300067d 100644 --- a/docs/definitions.md +++ b/docs/definitions.md @@ -26,7 +26,8 @@ rather than per field: | condition reasons | Flux (`fluxcd/pkg/apis/meta`) | one alerting rule should span our kinds and the Flux kinds in the same cluster | | readiness shape (`Ready`, `Reconciling`, `Stalled`, `observedGeneration`) | Flux and kstatus | the tools that read a cluster's health read this shape | | flags and their chart values | Flux | [`config-flag-conventions.md`](config-flag-conventions.md) is built on it | -| a field we mirror verbatim | Flux | `spec.accessFrom`, `meta.KubeConfigReference`, `suspend`, `sops` | +| a field we mirror verbatim | Flux | `meta.KubeConfigReference`, `suspend`, `sops` | +| who a grant admits (`consumerNamespaces`, with `from: Same`, `Names`, `Selector`, `All`) | Gateway API | `allowedRoutes.namespaces` and its `from`; `Names` is ours, a plain list where Gateway API matches a label, and nothing defaults where Gateway API defaults to `Same` | | enum value shape | Kubernetes `core/v1` | Flux is inconsistent here, and `core/v1` is not. See rule 7 | | field, block and timestamp shape | Kubernetes API conventions, then Flux | rules 2 through 4 | @@ -61,12 +62,12 @@ folder has exactly one `GitTarget`, and one `GitTarget` has exactly one folder. The two directions, which share the word "source" and must not share anything else: -- **Access** is the control-cluster direction: which control-cluster namespaces may reference a - provider (`KubeProvider` or `GitProvider`) from a `GitTarget` or applier claim. A provider's own - namespace always may; `spec.accessFrom` admits the others (the spelling is Flux's, on purpose). +- **Access** is the control-cluster direction: which control-cluster namespaces may reference an + object that lends something (a `GitProvider`, a `GitBranch`, a `KubeProvider`). It is granted on + the referenced object, in a **grant** list, and nothing is automatic, the own namespace included. - **Source namespace** is the source-cluster direction: which source-cluster namespace a rule reads - objects from. `WatchRule.spec.rules[].sourceNamespace`, gated by - `KubeProvider.spec.allowCrossNamespace.sources`. + objects from. `WatchRule.spec.rules[].sourceNamespace`, bounded by the union of the + `sourceNamespaces` of the `KubeProvider` grants that admit the `GitTarget`. A sentence about namespace policy names one of those two. Never "namespace access" unqualified. @@ -144,8 +145,23 @@ branch; never a bare "claim", which names a rule's demand on the read side. `Bra The **applier claim** is the cluster side of the same idea: one holds an applier, and another reports `HeldByAnotherClaim`. -**Write branch.** The branch a `GitTarget` writes to: its `GitBranch`'s `spec.branch`. It must be in -the `GitProvider`'s `allowedBranches`. +**Write branch.** The branch a `GitTarget` writes to: its `GitBranch`'s `spec.branch`. A grant in the +`GitProvider`'s `spec.allowedGitBranches` must allow it for the `GitBranch`'s namespace. + +**Grant.** One entry in a list named `allowed` plus the kind that refers to it +(`GitProvider.spec.allowedGitBranches`, `GitBranch.spec.allowedGitTargets`, +`KubeProvider.spec.allowedGitTargets` and `spec.allowedApplierClaims`). It says which +control-cluster namespaces it admits consumers from (`consumerNamespaces`, with its `from`) and, +where the object lends reach, the scope it gives (`branchPatterns`, `sourceNamespaces`, +`clusterResources`, `applierNamespaces`). Grants combine by union; an explicit scope is the whole +scope. Not "permission" or "policy". + +**Consumer.** The object a grant admits or refuses: the `GitBranch` for `allowedGitBranches`, the +`GitTarget` for `allowedGitTargets`, the applier claim for `allowedApplierClaims`. A grant's scope +names it with `{consumer.namespace}`, and a branch pattern may add `{consumer.name}` beside it; the +name alone bounds nothing, since the consumer picks it. The grant's own object is its holder, +and `from: Same` is the holder's namespace, never the consumer's. Not "referrer", "requester" or +"caller". **Parent branch.** The branch a write branch is created from, and compared against while the write branch does not exist on the remote: the `GitBranch`'s `spec.parentBranch`, or the remote's default @@ -219,8 +235,8 @@ timing and nothing else: the applier still fetches, renders, applies, prunes and The applier claim does not say when to apply either; the branch worker decides that. Among applier claims that reach an applier through the same `KubeProvider`, one holds it and the others report `HeldByAnotherClaim`; applier claims made through two `KubeProvider`s for one cluster are not -arbitrated (see **In line**). Whether an applier claim holds its applier is **Held**, not -**Bound**. While it holds a `Kustomization`, a `FluxClaim` keeps it suspended and resumes it only +arbitrated (see **In line**). Holding the applier is not the same as being **in control** of it, +and neither is being **Bound**. While it holds a `Kustomization`, a `FluxClaim` keeps it suspended and resumes it only for an apply GitOps Reverser asks for. While it holds an `Application`, an `ArgoCDClaim` keeps automated sync off and starts each sync itself. Deleting either hands the applier back as `status.previousSuspendState` recorded it. A `GitTarget` names one with `spec.applierClaimRef` and @@ -239,7 +255,8 @@ claim's namespace, not being deleted, admitted by its `KubeProvider`s, and write and branch the applier fetches. An applier claim acts only while an eligible `GitTarget` references it; one nothing references is `NotReferenced`. Eligibility is judged from those inputs alone, never from the `GitTarget`'s `Ready`. **Bound** names an applier claim with at least one eligible -`GitTarget`; it says nothing about whether the applier claim holds its applier, which is **Held**. +`GitTarget`; it says nothing about whether the applier claim holds its applier, or is **in +control** of it. **In line.** The applier claims that may hold one applier: those an eligible `GitTarget` references, and one that holds the applier or is handing it back. One of them holds it; the others @@ -247,15 +264,19 @@ report `HeldByAnotherClaim`. Applier claims are in line together only when they through the same `KubeProvider`. GitOps Reverser cannot tell that two `KubeProvider`s reach the same cluster, so name one `KubeProvider` for every applier claim that reaches a given cluster. -**Held.** How an applier claim reports its applier, from its own side. For a `FluxClaim`: -`Suspended` (suspended under GitOps Reverser's field manager, the last suspend handled, nothing -applies), `Applying` (resumed for an apply GitOps Reverser started), `Suspending` (a suspend was -sent, and Flux has not handled its token yet), `Releasing` (being handed back to Flux). For an -`ArgoCDClaim`: `AutomatedSyncOff` (held off by GitOps Reverser, or kept off by whoever owns the -switch) and `Releasing`. Either may be `NotReferenced` (nothing to hold) or `NotConfigured`. A -suspend counts as done only once it is handled: by then no -reconcile that started before it is still running. Not "fenced", and not "resumed", which says what -happens to the `Kustomization` rather than what the applier claim does. **Hand back** is releasing +**In control.** An applier claim is in control when it decides, right now, when its applier +applies: nothing the applier starts on its own can apply. Its `InControl` condition reports this, +and the reason says how the applier stands. The reasons and their values are listed with each kind, +in [The `FluxClaim`](bi-directional-flux.md#the-fluxclaim) and +[The `ArgoCDClaim`](bi-directional-argocd.md#the-argocdclaim) (and in +`kubectl explain fluxclaim.status.conditions`). In control is not **holding**: an applier claim +holds its applier from the moment it is first in line until it has handed it back, and while it +hands it back (`Releasing`, `Revoking`) it still holds it but is no longer in control. Nor is it +**Bound**. For a `FluxClaim`, a suspend counts only once it is handled: by then no reconcile that +started before it is still running, so a `Kustomization` suspended but not yet handled is not in +control. Not "held", which names holding; not "controlled", which reads as if the applier claim +were the one controlled; not "fenced"; and not "suspended", which says what happens to a +`Kustomization` rather than what the applier claim does. **Hand back** is releasing `spec.suspend`, or switching automated sync back, so the applier applies on its own again. **Apply request.** What GitOps Reverser sends an applier: apply this commit, for this reason @@ -522,3 +543,4 @@ outage. | fence, fenced (for what an applier claim does to its applier) | suspend, held | the `Kustomization` is suspended, and the applier claim holds it; say which, and whether the suspend is handled | | trigger, apply trigger (for an applier claim) | applier claim | the resource does not declare when to apply, and "trigger" already names watch-plan, API-surface and Argo Events triggers | | quiet branch | flush | no apply waits for a branch with nothing pending; it goes after a flush | +| consumer (for what applies a `GitRepository`) | what applies it, `alsoAppliedBy` | consumer names the object a grant admits | diff --git a/docs/demo/commit-window.md b/docs/demo/commit-window.md index 189eec44e..9a0ac35cf 100644 --- a/docs/demo/commit-window.md +++ b/docs/demo/commit-window.md @@ -7,7 +7,8 @@ resource types, so saving never commits the save button. **Run the [quickstart](../quickstart.md) first.** It leaves behind everything this example builds on: the `gitops-reverser-quickstart-demo` namespace, the `git-creds` Secret, a `GitProvider` named -`example-provider` whose `allowedBranches` is `"*"`, and the chart's `local` `KubeProvider` in +`example-provider` that lets its own namespace claim any branch (`allowedGitBranches` with +`branchPatterns: ["*"]`), and the chart's `local` `KubeProvider` in `gitops-reverser`. If you configured those yourself instead, substitute your own names below. The example writes to its own branch, `commit-window-demo`, which no reconciler deploys. The diff --git a/docs/design/apply-trigger.md b/docs/design/apply-trigger.md index f208f6357..6d6635d5d 100644 --- a/docs/design/apply-trigger.md +++ b/docs/design/apply-trigger.md @@ -177,7 +177,7 @@ flowchart LR in flight). A recorded attempt that then asks for nothing withdraws its record, which puts back what the status held: the trigger never shows an operation nothing could have applied. The writer matches operations by their token, never by time. A `FluxTrigger` that is not - `Held=True` (still adopting, releasing, or being deleted) is not resumed (`RequestFailed`). A source + `InControl=True` (still adopting, releasing, or being deleted) is not resumed (`RequestFailed`). A source on another repository or branch, a `Kustomization` whose `spec.suspend` another manager applies, or an `Application` with automated sync on whose switch an `ApplicationSet` or another manager owns, is `Rejected`; one whose switch the trigger holds was switched on by somebody, and fails @@ -398,8 +398,8 @@ would not be pruned when Git later deletes it. started is never replaced. - **Adoption.** A `Configured` `FluxTrigger`, so one an eligible `GitTarget` references, puts its finalizer in place and then suspends its `Kustomization` with a server-side apply under - `gitops-reverser`, and a token. A `Normal` `Adopted` Event records it. It is `Held=True` - (`Suspended`), and `Ready`, once that token is handled, and `Held=True` (`Applying`) while an + `gitops-reverser`, and a token. A `Normal` `Adopted` Event records it. It is `InControl=True` + (`Suspended`), and `Ready`, once that token is handled, and `InControl=True` (`Applying`) while an operation of a branch worker is in flight and followed. A trigger that never adopted has no finalizer, and deletes at once. - **The manifest omits `suspend`.** Whatever applies the `Kustomization` object (a parent @@ -425,11 +425,11 @@ would not be pruned when Git later deletes it. nothing watching it. - **Hand-back.** The trigger carries the finalizer `configbutler.ai/flux-trigger` from before its first adoption write until it has handed the `Kustomization` back or over, also while its - configuration is invalid (then `Held=Unknown`, `NotConfigured`, and the message says the + configuration is invalid (then `InControl=Unknown`, `NotConfigured`, and the message says the `Kustomization` is left suspended under `gitops-reverser`). It hands it back when it is deleted, and when no eligible `GitTarget` references it anymore, conclusively: a reference that cannot be judged yet keeps the hold, since a release is not undone. Until the hand-back is done the trigger - is `Held=False`, `Releasing`, and stays the holder in line. It suspends the `Kustomization` with a + is `InControl=False`, `Releasing`, and stays the holder in line. It suspends the `Kustomization` with a token if an apply has it resumed, waits until that is handled, then releases `spec.suspend` with an empty apply, so Flux owns it again (`Released`). A `Kustomization` that was suspended when the trigger first held it (`status.previousSuspendState: Suspended`) stays suspended instead ([decisions of @@ -449,7 +449,7 @@ would not be pruned when Git later deletes it. `Kustomization` that is gone lets the trigger go. A `ClusterProvider` that is gone does not end the hand-back: it goes on through whatever client still reaches the cluster, which for a provider without `kubeConfig` is the operator's own. A cluster no client reaches anymore, a suspend or - release the identity may not make, or a hand-back still unfinished 10 minutes after `Held` turned + release the identity may not make, or a hand-back still unfinished 10 minutes after `InControl` turned `Releasing` (each a `Warning` `ReleaseFailed` Event, which says where the `Kustomization` was left) lets the trigger go. @@ -482,7 +482,7 @@ trigger's `status.operation`, so that converge asks nothing of an applier alread `Kustomization`s with that `sourceRef`, and `HelmRelease`s whose chart comes from it, directly or through a `HelmChart` their `chartRef` names (at whichever `HelmRelease` version the cluster serves; a listing that fails is logged and publishes nothing). Those no `FluxTrigger` on the same - `ClusterProvider` holds, that is reports `Held=True`, are `status.source.otherConsumers`, + `ClusterProvider` is in control of, that is reports `InControl=True`, are `status.source.otherConsumers`, information only: it never affects `Ready`, and no `Warning` is sent, because one `GitRepository` for `apps` and `infra` is a normal setup. Each of them applies a new artifact the moment it arrives. @@ -548,7 +548,7 @@ Taken 2026-10-19, after the seventh review: applier itself (`ReferenceGrant` style) stays a possible follow-up. 3. **`allowAnySourceNamespace` is removed, not migrated.** It is never read, and kept in the schema only so a rule on `spec` refuses `true` on any spec change. -4. **The trigger's status speaks from its own side:** `Held` with `Suspended`, `Applying`, +4. **The trigger's status speaks from its own side:** `InControl` with `Suspended`, `Applying`, `Suspending`, `Releasing`, `NotReferenced`, `NotConfigured`; "fence" is retired for "suspend". The operation phases keep `Running` and `Settling`. 5. **A holder keeps its `Kustomization` until it lets go:** a trigger that carries its finalizer @@ -865,12 +865,12 @@ adopted by one apply. | Retries are decided by the worker; recovered refusals, withdrawn consent and recreated targets are not retried; each applier keeps its own clock; one attempt for a shared applier; a stale attempt is decided again without backoff | [`apply_attempt_test.go`](../../internal/git/apply_attempt_test.go) | | The operation is recorded before the write that starts it, moves through its phases on the trigger's status, an attempt that asked nothing restores the record before it, and a withheld rollback makes a `Repair` due | [`apply_operation_test.go`](../../internal/git/apply_operation_test.go), [`status_writer_test.go`](../../internal/applier/status_writer_test.go) | | Every attempt rechecks targets, asks for nothing weaker than it was told, and resumes no `Kustomization` on another branch; a stop mid-apply suspends the `Kustomization` | [`dispatcher_test.go`](../../internal/applier/dispatcher_test.go), [`flux_test.go`](../../internal/applier/flux_test.go) | -| The suspended lifecycle against the source facts: an apply is not finished when attempted; a token handled while suspended is no apply; a resume alone reverts a refusal; a suspend does not stop a running apply; the artifact is rechecked before the resume; a failed apply is suspended and Flux's requeue applies nothing; a shared source applies only the consumer asked | [`flux_lifecycle_test.go`](../../internal/applier/flux_lifecycle_test.go), [`flux_consumers_test.go`](../../internal/applier/flux_consumers_test.go), [`fluxsim`](../../internal/applier/fluxsim/fluxsim_test.go) | +| The suspended lifecycle against the source facts: an apply is not finished when attempted; a token handled while suspended is no apply; a resume alone reverts a refusal; a suspend does not stop a running apply; the artifact is rechecked before the resume; a failed apply is suspended and Flux's requeue applies nothing; a shared source applies only the consumer asked | [`flux_lifecycle_test.go`](../../internal/applier/flux_lifecycle_test.go), [`flux_applied_by_test.go`](../../internal/applier/flux_applied_by_test.go), [`fluxsim`](../../internal/applier/fluxsim/fluxsim_test.go) | | Adoption, `SuspendOwnedElsewhere`, an external resume suspended again with an Event every time, the hand-back on deletion (a `ClusterProvider` gone included), and the source's other consumers | [`fluxtrigger_hold_test.go`](../../internal/controller/fluxclaim_hold_test.go) | | The rule: a trigger nothing names reads nothing and is `Ready`; the first eligible reference adopts and the last releases, suspending an apply in flight first; a reference returning mid-release holds again; a re-created `GitTarget` releases and re-adopts; the binding over several `GitTarget`s; an older trigger not in line keeps none out, and a retiring holder stays first until its release is done; a holder left unconfigured says the `Kustomization` is suspended; `ApplierNamespaceNotAllowed` for both kinds | [`applytrigger_rule_test.go`](../../internal/controller/applierclaim_rule_test.go) | | `allowAnySourceNamespace: true` is refused on create and on any spec change, a stored one included; `false` and its removal are accepted; a `WatchRule` that relied on it is refused naming the new field | `removed_allow_any_source_namespace_test.go` (removed with `ClusterProvider`), [`watchrule_source_namespace_test.go`](../../internal/controller/watchrule_source_namespace_test.go) | | A restarted worker follows an operation left in flight with its window open, restores `repairPending`, and follows an apply started elsewhere; one recorded but never resumed ends `Failed` | [`apply_recovery_test.go`](../../internal/git/apply_recovery_test.go), [`dispatcher_test.go`](../../internal/applier/dispatcher_test.go) | -| Appliers sharing a source have one operation at a time and take turns, across workers too; a shared source applies only the consumer asked | [`apply_groups_test.go`](../../internal/git/apply_groups_test.go), [`flux_consumers_test.go`](../../internal/applier/flux_consumers_test.go) | +| Appliers sharing a source have one operation at a time and take turns, across workers too; a shared source applies only the consumer asked | [`apply_groups_test.go`](../../internal/git/apply_groups_test.go), [`flux_applied_by_test.go`](../../internal/applier/flux_applied_by_test.go) | | An applier in another cluster is reached through its own `ClusterProvider`, behind that provider's `accessFrom` | [`dispatcher_test.go`](../../internal/applier/dispatcher_test.go), [`kubeprovider_admission_test.go`](../../internal/authz/kubeprovider_admission_test.go), [`applytrigger_controller_test.go`](../../internal/controller/applierclaim_controller_test.go) | | The applier's late write over a newer edit is withheld, and nothing that could be the edit is: a tie, and an undo that leaves an older applier entry, are mirrored; the apply itself is not mirrored, a delayed echo of the attempt before included; an applier entry older than the send is an edit; an applier write with no window open is late | [`apply_window_test.go`](../../internal/git/apply_window_test.go), [`last_field_managers_test.go`](../../internal/watch/last_field_managers_test.go) | | A newly named applier is caught up | [`apply_trigger_test.go`](../../internal/git/applier_claim_test.go) | diff --git a/docs/design/gitbranch-and-kubeprovider-plan.md b/docs/design/gitbranch-and-kubeprovider-plan.md index 439f0e5a5..abf877a0b 100644 --- a/docs/design/gitbranch-and-kubeprovider-plan.md +++ b/docs/design/gitbranch-and-kubeprovider-plan.md @@ -5,6 +5,11 @@ The current contract is in [architecture.md](../architecture.md) and [configuration.md](../configuration.md). This page records what we decided and in what order we build it. +> **Superseded in part.** That a `GitBranch` has no access list of its own, and that the +> `GitProvider`'s `accessFrom` governs both hops, no longer hold: grants on each referenced object +> replace `accessFrom`, `allowedBranches` and `allowCrossNamespace` +> ([who may reference what](../finished/provider-access-rules.md)). + The plan breaks the API on purpose, more than once. There are no production users to carry across, so each phase replaces a shape outright instead of keeping a deprecated twin beside it. A removed field stays in the schema only so that setting it is refused by name, the pattern the API already diff --git a/docs/design/one-writer-per-branch-plan.md b/docs/design/one-writer-per-branch-plan.md new file mode 100644 index 000000000..dd2a3fcb0 --- /dev/null +++ b/docs/design/one-writer-per-branch-plan.md @@ -0,0 +1,173 @@ +# One writer per branch: where it stands + +> **status**, 2026-10-23. This page was the plan of 2026-10-21. Most of it has shipped, unreleased, +> in #428, #429, #430 and #432. It now records what exists, how that differs from the plan, and what +> the plan asked for that is still missing. The contract itself is in +> [configuration.md](../configuration.md#sharing-connections-across-namespaces) and +> [architecture.md](../architecture.md); the phased API break that carried most of it is +> [gitbranch-and-kubeprovider-plan.md](gitbranch-and-kubeprovider-plan.md). + +## The direction it served + +GitOps Reverser is moving toward an event-sourced state machine per branch, with durable execution +([branch worker event model](branch-worker-event-model.md)). The plan judged every option against +five properties: + +| | Property | +|---|---| +| **E1** | One owner per branch: one loop orders every decision about a branch | +| **E2** | Every input is an event, in order, on that branch's stream | +| **E3** | Nothing waits by polling another owner | +| **E4** | Decisions are recorded as actions (pending writes) | +| **E5** | The journal key is the branch itself: canonical repository plus branch | + +In October 2026 the worker was keyed by `GitProvider` and branch, so two `GitProvider`s for one +repository gave a branch two workers ("siblings"). Siblings broke E1, E2, E3 and E5, and every +defect the #425 self-review found in the sibling flush came from that shape. + +The plan recommended option F (one shared `GitProvider` per repository, referenced across +namespaces, the Flux model) together with five rules. All five properties now hold. + +## What we have now + +### One worker per repository branch (rule 1): done, #428 + +- The worker key is [`BranchKey{Repository, Branch}`](../../internal/git/types.go), with the + repository as `repourl.Normalize` returns it. Same-host SSH and HTTPS spellings are one key. +- `apply_siblings.go` and `apply_sibling_flush.go` are deleted, and with them the shared flush + generation, the copied observations and the cross-worker source-group claims. Source groups are + local to the one worker. +- The worker keeps the owning provider for credential, committer, signing and metric labels. +- The HA plan's `BranchWriteShard` is this key + ([shard identity](../future/ha-gittarget-distribution-plan.md#shard-identity)). + +### One credential per repository branch (rule 2): done, in a stronger form, #429 + +The plan said: elect implicitly from the `GitTarget`s and refuse a branch written through two +`GitProvider`s. #428 built exactly that, with the oldest `GitTarget` winning. That left a squat gap: +any namespace could hold a branch without a working credential. #429 replaced the rule with an +explicit object: + +- **`GitBranch`** is one branch of the repository its `GitProvider` names. It holds the branch for + every `GitTarget` that references it: one worker, one clone, one credential, one signing key. +- **The branch claim**, in [`branch_owner.go`](../../internal/git/branch_owner.go): a `GitBranch` + becomes a candidate only once its credential opens a receive-pack session. The oldest candidate + holds the branch. The holder keeps it through later proof failures. The claim is in memory and is + recomputed after a restart. +- A younger `GitBranch` for a held branch reports `Stalled=True`, reason `BranchConflict`, naming + the holder. Status carries this; the plan's admission refusal was dropped + so that a folder of manifests can be applied in any order. +- `GitBranch.spec.gitProviderRef` was a `LocalObjectReference`, so creating a claim took the right + to create objects where the credential lives. Since #441 it may name a `GitProvider` in another + namespace, and the right to claim comes from that provider's `spec.allowedGitBranches` instead. + +So option B's per-write credential selection never had to exist, which was the purpose of rule 2. + +### The shared credential (rule 3, option F): done, with a different reference, #428 + #429 + +- #428 shipped `GitProvider.spec.accessFrom`; #441 replaced it with explicit grants, and nothing is + admitted automatically, the object's own namespace included. A `GitProvider`'s + `spec.allowedGitBranches` decide which namespaces may claim which branches through it, and a + `GitBranch`'s `spec.allowedGitTargets` decide which namespaces may write through it. +- The cross-namespace reference is **`GitTarget.spec.gitBranchRef`** (namespaced), as well as + `gitProviderRef` since #441. Each hop is checked against the grants of the object it reaches + ([`gitBranchAdmitted`](../../internal/authz/kubeprovider_admission.go), + [`GitBranchClaimAdmitted`](../../internal/authz/kubeprovider_admission.go)). This is checked on + every reconcile, so a grant that is narrowed later revokes access. +- One Secret in the platform namespace serves every team. Self-service stays: a team can still own + a `GitProvider`, a `GitBranch` and a Secret in its own namespace. +- Committer and signing come with the credential, so a branch is signed by one key. + +### The decoupled receiver (rule 5, gitops-api #30): done, #432 + +The plan's text was one endpoint, routed by repository and branch, with a `GitProvider`'s own +receiver kept for self-service. What shipped is simpler: + +- `GitReceiver` is a namespaced kind. `GitProvider.spec.receiver` is gone. +- A verified delivery wakes the worker of **every** `GitProvider`, in any namespace, whose + `spec.url` names the pushed repository, for that branch only. It has no reference and no + `accessFrom`. +- Waking across namespaces grants nothing: the worker reads the remote with its own credential and + holds at most one pending push notice. The `202` has no body. +- For self-service, a team creates a `GitReceiver` in its own namespace. +- `GitBranch.status.lastPushNoticeAt` shows whether deliveries arrive. + +### Decided differently from the plan + +| Plan | What we did | Why | +|---|---|---| +| Report `status.branches` to every routed `GitProvider` | Removed; `GitBranch` status replaces it (`status.remote`, `gitTargetCount`) | Listing `GitBranch` objects answers the question | +| Move metric labels to the repository-branch key (breaking) | Kept `{provider_namespace, provider_name, branch}`, taken from the holding `GitBranch`'s provider | gitops-api dashboards; the label set still names one branch worker | +| Refuse a second `GitProvider` for one repository in one namespace, before anything else | Not built; no longer needed | The claim is per `GitBranch`, so extra providers on one repository are harmless, and useful (see "Still missing", item 1) | +| Ship additively, then the key change a release later | One release, hard cuts, no migration | No users yet | +| C2 (`ClusterGitProvider` or `GitCredential`) only if one object per repository is too many | Not built | Nobody has hit the limit | + +## Still missing against the original + +Ordered by how much they matter now. + +1. **Branch-scoped access ("who may write what").** The plan's rule 3 and decision 3 wanted + `accessFrom` to say which namespaces may write *which branches*, optionally *which paths*. What + shipped is namespace-only and provider-wide: every namespace a `GitProvider` admits may use + every `GitBranch` of that provider, and write any folder that the overlap check does not already + hold. Branch isolation is possible today without code: create one `GitProvider` per team in the + platform namespace. Every one of them references the same Secret, with + `allowedBranches: [teams/team-a/*]` and `accessFrom: {names: [team-a]}`. Path isolation is not + possible at all. [gitbranch-and-kubeprovider-plan.md](gitbranch-and-kubeprovider-plan.md) + parks this as "Folder and branch grants per namespace", and + [who may use a provider](../finished/provider-access-rules.md) built it as grants + (`allowedGitBranches`, `GitBranch.allowedGitTargets`), and also covers item 2 below. + +2. **The claim is weaker than "one credential per branch, enforced".** Three gaps: + - **The push-session proof proves less on some hosts.** On Gitea and Forgejo, a credential that + can only *read* opens a receive-pack session (the AGit flow). On a host that offers push + sessions anonymously, it proves only that the repository is reachable. + - **Who claims first holds the branch.** Anyone who can create a `GitProvider` and a `GitBranch` + with a credential that passes the proof can claim a branch that no older `GitBranch` holds. + That includes a tenant in its own namespace with a copy of the token. The plan's rule 2 tied + the branch to the shared provider. The claim instead ties it to the oldest proven object on + that repository, wherever it lives. + - **An older `GitBranch` takes the branch from a live holder** once it proves itself, and after + a restart a holder whose credential broke loses its claim. A persisted claim belongs to the + HA work. + + Only a per-repository authority closes these: option C's object per repository URL, or a + cluster-level rule naming the one provider through which a repository's branches may be + claimed. Nothing of that exists. + +3. **Alias detection (rule 4, option E).** Not built. `repourl.Normalize` still cannot unify Gitea's + SSH on `:2222` beside HTTPS, a separate SSH hostname, a vanity domain, or a renamed repository. + Two such URLs get two keys, two claims and two workers on one branch: the sibling problem, + undetected. The HA plan's HA-0 question about aliases is still open. The design is unchanged: + a warning when a commit we pushed under one key shows up under the other, in both directions. + +4. **The #425 self-review items that are not about siblings.** R2 and R3 went with the siblings, + and R5 (the `UPGRADING.md` entries) is done. Reading the code, R1 and R4 still look open. + - **R1:** an applier that joins a flush already in progress starts `flushEdits` at the moment it + joins, not at the flush mark + ([`flushed`](../../internal/git/applier_claim.go)). + - **R4:** `forgetSettledSnapshots` still runs only on `GitTarget` teardown, so a replaced worker + can read the old worker's settled-snapshot record. + + Both affect a user with a single `GitProvider`. The local review is + `docs/reviews/20261021-review-425.md`. + +5. **A rate bound on deliveries.** Folding bounds the queue but not the rate, so a token holder can + keep a worker reading continuously. This is accepted for now; a per-worker cooldown would bound + it. + +6. **Per-tenant identity on the Git host.** This is by design. A shared provider + pushes as the platform's account, so the host's audit log names the platform instead of the + team, and one team's credential cannot be revoked separately. A team that needs either keeps its + own provider. The plan named this as Flux's argument for an identity per object and accepted the + trade. + +## Stale text elsewhere + +- [HA plan, shard identity](../future/ha-gittarget-distribution-plan.md#shard-identity) says "one + `GitProvider` owns a repository branch: a `GitTarget` writing it through another one is + refused". Since #429 it is the holding `GitBranch`, and a younger `GitBranch` reports + `BranchConflict`. +- [gitbranch-and-kubeprovider-plan.md](gitbranch-and-kubeprovider-plan.md) still draws + `FluxTrigger`/`ArgoCDTrigger` and `applyTriggerRef` (now `FluxClaim`/`ArgoCDClaim` and + `applierClaimRef`, #438). Its status line says phase 3 is still to come, but it shipped in #432. diff --git a/docs/finished/provider-access-rules.md b/docs/finished/provider-access-rules.md new file mode 100644 index 000000000..9f4a5e4c6 --- /dev/null +++ b/docs/finished/provider-access-rules.md @@ -0,0 +1,842 @@ +# Access between objects: who may reference what + +> **finished** — shipped or closed. Kept for context only; **nothing here binds**. For current behaviour see [`../configuration.md`](../configuration.md#sharing-connections-across-namespaces). Index: [`../INDEX.md`](../INDEX.md) + +> Shipped 2026-10-23 as [#441](https://github.com/ConfigButler/gitops-reverser/pull/441), before the +> names were ever released (see [why now](#why-now)). [What shipped](#what-shipped-and-where-it-departs) +> records where the code departs from this page, and two tests still missing. It replaced three +> drafts: `docs/reviews/20261023-gitbranch-claims-from-other-namespaces.md`, +> `docs/reviews/20261023-gitbranch-claims-review.md` and `docs/future/gitprovider-branch-rules.md`, +> and follows up items 1 and 2 of "Still missing" in the +> [one-writer-per-branch status](../design/one-writer-per-branch-plan.md). +> [Tests to add](#tests-to-add) is the checklist for the PR. + +## How it works: one tenant, end to end + +A platform team keeps one repository credential and one cluster connection in its namespace, +`platform`. Namespace `team-a` carries the label `platform.example.com/tenant: "true"`. + +1. The platform allows tenants to claim `teams//*` and to mirror their own + namespace. +2. Team A creates a `GitBranch` for `teams/team-a/live`, and allows `GitTarget`s from its own + namespace to write it. +3. Team A creates a `GitTarget` that references that branch and the platform's `KubeProvider`. +4. The `GitTarget` runs only while **both** grants allow it: the branch's writer grant and the + provider's source grant. + +```yaml +# platform: the credential, and who may claim which branches +kind: GitProvider +metadata: {name: shared-repo, namespace: platform} +spec: + url: https://git.example.com/platform/config.git + secretRef: {name: repository-auth} + allowedGitBranches: + - from: Selector + selector: {matchLabels: {platform.example.com/tenant: "true"}} + branches: ["teams/{namespace}/*"] +--- +# platform: the cluster connection, and who may mirror which namespaces +kind: KubeProvider +metadata: {name: production, namespace: platform} +spec: + secretRef: {name: production-auth} + allowedGitTargets: + - from: Selector + selector: {matchLabels: {platform.example.com/tenant: "true"}} + sourceNamespaces: ["{namespace}"] +--- +# team-a: the branch it claims, and who may write it +kind: GitBranch +metadata: {name: live, namespace: team-a} +spec: + gitProviderRef: {name: shared-repo, namespace: platform} + branch: teams/team-a/live + allowedGitTargets: + - from: Same +--- +# team-a: what to mirror, and where; a WatchRule in team-a selects what +kind: GitTarget +metadata: {name: apps, namespace: team-a} +spec: + gitBranchRef: {name: live} + kubeProviderRef: {name: production, namespace: platform} + path: apps +``` + +| Request | Result | Because | +|---|---|---| +| `team-a`'s `GitBranch` claims `teams/team-a/live` | Allowed | The provider's grant selects `team-a`, and `teams/{namespace}/*` expands to `teams/team-a/*` | +| `team-a`'s `GitBranch` claims `main` or `teams/team-b/live` | Refused, `BranchNotAllowed` | No grant gives `team-a` either branch | +| `team-a`'s `GitTarget` writes through `team-a/live` | Allowed | `from: Same` on the `GitBranch` | +| `team-b`'s `GitTarget` writes through `team-a/live` | Refused, until team A adds `from: Names, names: [team-b]` | The branch's owner decides who shares it | +| A `WatchRule` in `team-a` watches `team-a` | Allowed | `sourceNamespaces: ["{namespace}"]` | +| A `WatchRule` in `team-a` watches `kube-system` | Refused, `SourceNamespaceNotAllowed` | The grant's scope is `team-a` only | +| A `GitTarget` in `platform` mirrors through `production` | Refused, `NamespaceNotAuthorized` | No grant admits `platform`; its own namespace is not automatic | + +Two expressions name a namespace, and they name different ones: + +| Expression | Whose namespace | +|---|---| +| `from: Same` | The object **containing** the grant: for this `GitBranch`, `team-a`; for the providers, `platform` | +| `{namespace}` in a scope | The object **asking** for access: for `team-a`'s `GitBranch`, `team-a` | + +**Platform administrators manage provider grants and authorization labels. Branch owners manage +writer grants on their branches.** That split is why team A can share its branch without asking +the platform. + +## The rules + +Every grant answers one question: **who may use this object, and for what?** Grants exist only +where an object lends something: a credential, a branch, or reach into other namespaces (the +[whole picture](#access-between-objects-the-whole-picture) lists every reference). + +This is stricter than Flux on purpose. Flux keeps consent on the referenced object too (`accessFrom`), +but its ACL helper admits every same-namespace reference. GitOps Reverser requires explicit consent +for provider and branch use, also within one namespace. Administering a provider and creating its +consumers can then carry different RBAC permissions. + +| Question | Answer | +|---|---| +| Where does permission live? | On the referenced object | +| Who gets access automatically? | Nobody, including the object's own namespace | +| What does an omitted or empty grant list mean? | No access | +| How do several grants combine? | Union; order never matters | +| What does an omitted scope mean? | The grant is refused where a scope is required | +| Who controls the grant? | Whoever may edit the referenced object | +| Who owns a grant list? | One policy owner per object: every grant list is `+listType=atomic`, and `names`, `branches`, `sourceNamespaces`, `clusterResources` and `applierNamespaces` are `+listType=set` | +| Are there booleans? | No. A grant's source is an enum (`from`), and every scope is a list, so either can grow a value later without a new field ([API conventions](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md): think twice about bool fields) | + +The field is `allowed` plus the referring kind, or a family of kinds the API already defines, +followed by a `from` in each grant. That is Gateway API's `allowedRoutes.namespaces.from`, the +shape `from` is borrowed from. "Applier claim" is such a family: there is no `ApplierClaim` kind, +but a `GitTarget` points at one with `applierClaimRef`, and `definitions.md` defines the term. +The same `allowedGitTargets` on two objects always means "which `GitTarget`s may reference me"; +the referenced object decides the activity. The names follow [definitions.md](../definitions.md) +rule 2, and avoid a bare "claim", which `definitions.md` keeps apart as "branch claim" and "applier +claim". + +This means a little more YAML and fewer hidden rules. It replaces the phase 1 decision that a +`GitBranch` has no access list and its provider's `accessFrom` "governs both hops". The provider +decides who may claim, and the branch decides who may write. + +### Where a grant admits from + +Every grant has a required `from`. The values `Same`, `Selector` and `All` are Gateway API's +(`allowedRoutes.namespaces.from`); Gateway API makes `from` optional with `Same` as the default, and +here it is required, so no grant admits anyone by omission: + +```yaml +allowedGitTargets: + - from: Same # this object's own namespace, whatever it is named + - from: Names + names: [team-b] + - from: Selector + selector: {matchLabels: {platform.example.com/tenant: "true"}} +``` + +| `from` | Admits | Requires | +|---|---|---| +| `Same` | The referenced object's own namespace | nothing else | +| `Names` | Exactly these control-cluster namespaces | `names` | +| `Selector` | Control-cluster namespaces whose labels match | `selector` | +| `All` | Every namespace | nothing else | + +CEL refuses `names` without `Names` and `selector` without `Selector`. "Team-b or every tenant" is +two grants, combined by union; one grant never combines two sources. `Same` is how an object admits +its own namespace without writing its name. A kustomize overlay that sets `namespace:` does not +rewrite a name inside `names`, so a literal own-namespace name breaks silently; `Same` does not. +`Names` has no Gateway API counterpart, which matches by the `kubernetes.io/metadata.name` label; +a plain list reads better. Admitting everyone takes `from: All`, a word a reviewer sees: +`from: Selector` with an empty `selector: {}` is refused. + +### Semantics, field by field + +| Field | Omitted | `[]` | A value | `"*"` | +|---|---|---|---|---| +| A grant list | No access | No access | Grants, combined by union | Not a value of a list | +| `from` | **Refused**: required | (not a list) | `Same`, `Names`, `Selector` or `All` | Refused: use `All` | +| `selector` (with `Selector`) | Refused | (not a list) | A label selector on control-cluster Namespaces | `selector: {}` is refused: use `from: All` | +| `names` (with `Names`) | Refused | Refused | Exact control-cluster namespace names; `{namespace}` is refused, because it would admit every referrer (use `Same` for the own namespace) | Refused: use `All` | +| `GitProvider.allowedGitBranches[].branches` | **Refused**: required | Refused | Branch patterns (see [patterns](#patterns)) | Every branch | +| `KubeProvider.allowedGitTargets[].sourceNamespaces` | No namespaces | Refused | Exactly these source-cluster namespaces | Every namespace; also allows `rules[].sourceNamespace: "*"` | +| `KubeProvider.allowedGitTargets[].clusterResources` | No cluster-scoped resources | Refused | Exactly these cluster-scoped resources, by identity (see [cluster-scoped resources](#cluster-scoped-resources)) | Every cluster-scoped resource, including CRDs installed later | +| `KubeProvider.allowedApplierClaims[].applierNamespaces` | **Refused**: required | Refused | Exactly these namespaces | Every namespace | +| `GitBranch.allowedGitTargets[]` | no scope field | | | | + +A `KubeProvider` `allowedGitTargets` grant must set `sourceNamespaces`, `clusterResources`, or +both. CEL refuses one that grants nothing. **An explicit list is the whole scope, never "these +plus your own".** `applierNamespaces: [flux-system]` means `flux-system` only. `["*"]` is a +deliberate, broad grant. + +### Patterns + +A small language of our own, specified here and independent of the platform. Go's +`filepath.Match` was dropped: its `*` stops at `/`, and it brings character classes, `?` and +escaping, none of which a branch grant needs. + +- **A branch pattern is matched against the whole branch name.** It is made of literal text, `*` + and `{namespace}`. +- **`*` matches any sequence of characters, `/` included, and the empty one.** `feature/*` + matches `feature/checkout/v2`, `teams/{namespace}/*` covers a team's whole prefix, and `*` alone + is every branch. +- **`{namespace}` must be a whole path segment:** at the start or after a `/`, and at the end or + before a `/`. `teams/{namespace}/*` is valid. `teams/{namespace}*` is refused, because for + `team-a` it would also match `teams/team-ab/live`. This is placement's rule, "every value is a + single path segment", applied to a grant. +- **No escaping, `?` or character classes.** Git refuses `*`, `?` and `[` in a branch name + (`git check-ref-format`), so a literal never needs escaping. +- **`sourceNamespaces` and `applierNamespaces`** take exact names, `{namespace}` alone, or `"*"` + alone. A `WatchRule` source namespace is already a name or `"*"`, so these scopes need no + patterns. +- **CEL checks the grammar at admission:** allowed characters, `*`, and `{namespace}` as a whole + segment. Any other `{...}` is refused with a message in placement's style, naming the unknown + variable and the one that exists: "branch pattern `teams/{ns}/*` references unknown variable + `{ns}`; the only variable is `{namespace}`". + +**`{namespace}` always means the referring object's namespace in the control cluster.** It is the +`GitBranch`'s in `branches`, the `GitTarget`'s in `sourceNamespaces`, and the applier claim's in +`applierNamespaces`. In a source-namespace or applier scope it expands to a literal namespace name +in the source cluster. In a remote cluster that is the namespace of the same name, as "own +namespace" means today. A namespace name is a DNS label, so it is always valid inside a branch name. + +The syntax is placement's: single braces, as in `{namespace}/{resource}/{name}.yaml`, so the API +has one variable syntax. The subject differs, and the configuration docs say so side by side. +In a placement template `{namespace}` is the mirrored resource's namespace, and can render +`_cluster`. In a grant it is the referring object's, which always exists. Placement's `|fallback`, +its sanitizing and its empty-segment dropping do not carry over: they exist to render a path, and a +grant renders nothing. The matcher is a few lines in `api/v1alpha3`. It +does not reuse the placement parser, which carries fourteen variables, fallbacks and label keys. + +### Cluster-scoped resources + +`clusterResources` is the one scope that names resource types, so it defines their identity: + +- **A non-core resource is `resource.group`:** `clusterroles.rbac.authorization.k8s.io`, + `customresourcedefinitions.apiextensions.k8s.io`. **A bare name is the core group only:** + `nodes`, `namespaces`. A bare `clusterroles` therefore matches nothing. +- **Exact identities or `"*"`,** with no aliases and no partial wildcards. An identity covers every + served version of its group and resource. +- **It is decided on the rule's text, as `sourceNamespace: "*"` is.** A `ClusterWatchRule` item + whose `apiGroups` or `resources` holds `"*"` needs `clusterResources: ["*"]`. An item that names + its groups and resources is checked pair by pair. An item that omits `apiGroups` is checked by + the types the planner selects for it (see [what shipped](#what-shipped-and-where-it-departs)). +- **One forbidden type refuses the whole `ClusterWatchRule`,** with a message that names the + forbidden types, as an invalid `objectSelector` refuses it today. Nothing is filtered silently. +- **Discovery never widens a grant.** Under a narrow grant no wildcard item compiles, so a CRD + installed later cannot slip in. Under `["*"]`, a CRD installed later is included: that is what + `"*"` grants. + +### Refusal messages name the grant + +A refusal says what the grants allow and what was asked. A tenant should never need a broad grant +to find out why something failed: + +- `GitBranch`: "namespace `team-a` may claim `teams/team-a/*`, `feature/*` through GitProvider + `platform/shared-repo`; requested branch `main` is not allowed". +- `GitTarget` on a branch: "GitBranch `team-a/live` admits `team-a`, `team-b`; namespace `team-c` is + not admitted (`spec.allowedGitTargets`)". +- `WatchRule`: "KubeProvider `platform/production` lets namespace `team-a` watch `team-a`; + `sourceNamespace: kube-system` is not allowed". + +The reasons `NamespaceNotAuthorized`, `BranchNotAllowed`, `SourceNamespaceNotAllowed` and +`ApplierNamespaceNotAllowed` stay. Only their messages change. + +## `GitProvider`: who may claim which branches + +```yaml +kind: GitProvider +metadata: {name: shared-repo, namespace: platform} +spec: + url: https://git.example.com/platform/config.git + secretRef: {name: repository-auth} + allowedGitBranches: + - from: Same + branches: [main, "release/*"] + - from: Selector + selector: {matchLabels: {platform.example.com/tenant: "true"}} + branches: ["teams/{namespace}/*", "feature/*"] +``` + +- **The complete claim policy is in one place.** A `GitBranch` claims through this provider only if + a grant matches its namespace and its branch. That includes the provider's own namespace, so a + team that owns its provider writes one grant for itself. +- **`allowedBranches` and `accessFrom` are removed.** There is no separate limit and no implicit + bypass for the platform. Who may write moves to the `GitBranch`. +- **`GitBranch.spec.gitProviderRef` becomes a `NamespacedObjectReference`.** An omitted namespace + means the `GitBranch`'s own. CEL's `self == oldSelf` treats an omitted namespace and an explicit + own namespace as different values. Default the field at admission, or compare the resolved + namespace in the immutability rule. + +## `GitBranch`: who may write through it + +```yaml +kind: GitBranch +metadata: {name: live, namespace: team-a} +spec: + gitProviderRef: {name: shared-repo, namespace: platform} + branch: teams/team-a/live + allowedGitTargets: + - from: Same +--- +kind: GitBranch +metadata: {name: main, namespace: platform} +spec: + gitProviderRef: {name: shared-repo} + branch: main + allowedGitTargets: + - from: Names + names: [release-team] +``` + +- **Claiming a branch delegates it, including the right to share it.** The platform decides which + branches it delegates, and the team decides who collaborates there. Sharing `teams/team-a/live` + with `team-b` means adding a `from: Names` grant for it; the provider and its credential do not change. +- **To keep control of the writers, the platform owns the `GitBranch`,** as for `main` above. There + is no provider-level writer policy beside this one. That would turn delegation into two + policies that intersect. +- **The `GitBranch`'s own namespace needs a grant too.** `from: Same` above is not implied. +- **`allowedGitTargets` is mutable.** `gitProviderRef`, `branch` and `parentBranch` stay immutable, + because together they are the branch's identity. The type comment "every field is immutable" + changes with it. +- [`gitBranchAdmitted`](../../internal/authz/kubeprovider_admission.go) reads this list on the + `GitBranch` it already fetches. It no longer reads the provider, and it no longer admits a target + in the `GitBranch`'s namespace automatically. + +## `KubeProvider`: who may mirror, and who may control appliers + +```yaml +kind: KubeProvider +metadata: {name: production, namespace: platform} +spec: + secretRef: {name: production-auth} + allowedGitTargets: + - from: Selector + selector: {matchLabels: {platform.example.com/tenant: "true"}} + sourceNamespaces: ["{namespace}"] + - from: Names + names: [platform-audit] + sourceNamespaces: ["*"] + clusterResources: ["*"] + allowedApplierClaims: + - from: Selector + selector: {matchLabels: {platform.example.com/tenant: "true"}} + applierNamespaces: ["{namespace}"] + - from: Same + applierNamespaces: [flux-system] +``` + +- **`allowedGitTargets`** admits a `GitTarget` to mirror through this provider. Its + `sourceNamespaces` bound what the `WatchRule`s feeding that target may watch. A `WatchRule` feeds + a `GitTarget` in its own namespace. +- **`clusterResources`** lets a `ClusterWatchRule` mirror those cluster-scoped resources through + that `GitTarget` (see [cluster-scoped resources](#cluster-scoped-resources)). Today a + `ClusterWatchRule` needs only the target's admission. Only a cluster admin can create one, but + without this field `sourceNamespaces: ["{namespace}"]` would look like the whole reach and would + not be. +- **`allowedApplierClaims`** admits an applier claim, and its `applierNamespaces` bound where the + `Kustomization` or `Application` it holds may live. +- **`allowCrossNamespace` and `accessFrom` are removed.** Mirroring and controlling appliers are + granted separately: a namespace admitted to mirror is no longer admitted to apply. + +### The chart's `local` provider + +The installer chooses which namespaces may use the operator's identity; the chart never chooses for +them. + +- **Default install: empty grant lists.** Nothing mirrors or holds an applier through `local` until + values grant it. The controller's refusal names the API field, because the + same provider may be managed with Kustomize: "KubeProvider `gitops-reverser/local` admits no + namespace in `spec.allowedGitTargets`". The chart docs map that field to + `kubeProvider.allowedGitTargets`. +- **`quickstart.enabled=true`** also renders a grant for exactly the quickstart namespace, so the + [quickstart](../quickstart.md) stays one `helm install`: + + ```yaml + allowedGitTargets: + - from: Names + names: [gitops-reverser-quickstart-demo] + sourceNamespaces: ["{namespace}"] + allowedApplierClaims: + - from: Names + names: [gitops-reverser-quickstart-demo] + applierNamespaces: ["{namespace}"] + ``` + +- **`values-playground.yaml`,** shipped with the chart, grants every namespace its own namespace. + That is today's default, chosen visibly with `-f values-playground.yaml`: + + ```yaml + kubeProvider: + allowedGitTargets: + - from: All + sourceNamespaces: ["{namespace}"] + allowedApplierClaims: + - from: All + applierNamespaces: ["{namespace}"] + ``` + +- **No default grants `clusterResources`,** the playground included. A `ClusterWatchRule` through + `local` stops mirroring until a grant sets it. That is an intentional restriction, and + `UPGRADING.md` says so. + +The `inCluster` arm's consent (`--in-cluster-kube-providers`) is unchanged. A scope granted on an +`inCluster` provider still bypasses live namespace RBAC through the operator's identity, so the +configuration docs keep that warning for `sourceNamespaces`, `clusterResources` and +`applierNamespaces`. + +## Boundaries the grants do not replace + +State these in the configuration docs, beside the grants: + +- **Every operation runs as the provider's identity, never as the referrer's.** Grants decide who + may use that identity, and for what. They are not Flux-style impersonation, and this PR does not + add impersonation. + - A source-namespace grant permits mirroring the resource kinds the provider identity can read in + that namespace. It does not reproduce the referring user's Kubernetes permissions. + - An applier-namespace grant permits controlling matching appliers in that namespace. The + applier's own execution identity and RBAC bound what it deploys: a Flux `Kustomization`'s + `serviceAccountName`, or an Argo CD `AppProject`'s destinations. `targetNamespace` only places + the workloads; the service account's permissions authorize them. +- **A namespace grant trusts that namespace's administrators.** Anyone, or any reconciler, allowed + to create the referring kind there can use the grant. Labels used by a `Selector` grant must + be controlled by trusted administrators. A `Names` grant also admits a namespace that is deleted and + recreated with the same name. +- **Branch grants restrict claiming and writing. They do not make branches confidential from each + other.** A `parentBranch` may name any branch the provider's credential can read, so a permitted + parent's contents can be copied into the delegated branch. Inside one branch, the folder overlap + check coordinates writers; it is not a boundary between tenants. + +## A platform and its tenants + +The model assumes one split, and RBAC enforces it. Platform administrators manage providers, their +grants and the authorization labels. Branch owners manage writer grants on their branches, and +tenants manage the objects that use both. With Flux, objects are applied by a +reconciler, so what counts is what each reconciler's identity may create. The platform's +reconciler applies: + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: team-a + labels: {platform.example.com/tenant: "true"} # the label the grants select on +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: {name: gitops-reverser-tenant} +rules: + - apiGroups: [configbutler.ai] + resources: [gitbranches, gittargets, watchrules, fluxclaims, argocdclaims, commitrequests] + verbs: [get, list, watch, create, update, patch, delete] +--- +apiVersion: v1 +kind: ServiceAccount +metadata: {name: tenant-reconciler, namespace: team-a} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: {name: tenant-reconciler, namespace: team-a} +roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: gitops-reverser-tenant} +subjects: [{kind: ServiceAccount, name: tenant-reconciler, namespace: team-a}] +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: {name: team-a, namespace: team-a} +spec: + serviceAccountName: tenant-reconciler # team-a's repository is applied as this identity + sourceRef: {kind: GitRepository, name: team-a} + path: ./ + interval: 10m + prune: true +``` + +The platform's reconciler also applies the `GitProvider` and `KubeProvider` above, with their +grants, and the `GitBranch` for `main`. The Role also needs whatever team-a's own workloads require. + +| Identity | Manages | Cannot | +|---|---|---| +| The platform's reconciler | `GitProvider`, `KubeProvider` and their grants; Namespaces and their labels; tenant RBAC; platform `GitBranch`es such as `main` | | +| `team-a/tenant-reconciler` | `GitBranch`, `GitTarget`, `WatchRule`, applier claims and `CommitRequest`s in `team-a` | Create or edit a provider; label a Namespace; change RBAC | + +The last column is what makes the grants hold. A tenant cannot grant itself a label, and cannot +create the second provider that [the limit](#the-limit-another-provider-on-the-same-repository) +describes. Flux's multi-tenancy lockdown (`--no-cross-namespace-refs` and +`--default-service-account` on kustomize-controller) makes the tenant identity mandatory, so no +tenant `Kustomization` falls back to the controller's own. + +## Revocation + +A grant can shrink in three ways: an edited list, a removed Namespace label, or a deleted object. +Shrinking can end admission altogether, or narrow a scope while the object stays admitted. For +example, `platform-audit` keeps a narrower grant after losing `"*"`. The promise, for both: + +> Policy changes trigger reconciliation. Once revocation is observed, the controller stops +> admitting new work under the revoked permission. Work accepted before that may complete, +> following the drain below. + +**Accepted** means what the branch worker already holds when the revocation is observed: queued +events, open commit windows, and decided writes. That matches what deleting a `GitBranch` drains +today. An event that arrives after the observation is refused. The tests rely on this line to tell +a permitted drain from a revocation bug. The product already has two revocation paths, and both +drain: + +- **Deleting a `GitBranch`.** + [`finalize`](../../internal/controller/gitbranch_controller.go) releases the claim first. It then + retires the worker, which closes intake, pushes or refuses each pending write, and settles every + `CommitRequest`. Last, it wakes the `GitBranch`es waiting for the branch. +- **A `GitTarget`, `WatchRule`, `ClusterWatchRule` or applier claim that loses admission or scope.** + It is refused on its next reconcile. A write already decided is still committed and pushed + ([`write_gate.go`](../../internal/git/write_gate.go) explains why: a write held back would come + out of order later). + +A `GitBranch` that loses `allowedGitBranches` takes the `finalize` path but keeps its finalizer, and +reports `Stalled=True`, reason `NamespaceNotAuthorized`. Writes can therefore land after the API +server accepts a policy edit. Git cannot take back a push that is already sent, and the docs should +promise no more than that. + +**An applier claim that loses `allowedApplierClaims`:** + +> Revocation stops new apply requests and starts handing back the existing hold. The hand-back +> restores the recorded prior state and leaves other owners' declarations alone. It may need retries +> and may fail; its outcome is visible in status and Events. + +Today a claim that stops being `Configured` keeps its `Kustomization` suspended, and hands back only +once no eligible `GitTarget` references it or it is deleted. A denial now takes the same +[`retire`](../../internal/controller/fluxclaim_hold.go) path as "no eligible `GitTarget`". That path: + +- keeps the claim the holder (`Held=False`, `Releasing`) while it hands back; +- keeps a `Kustomization` that was suspended before adoption suspended + (`status.previousSuspendState`); +- gives up after `releaseDeadline` (10 minutes) with a Warning Event, as it already does for a live + claim. It may be unable to finish: the Secret is gone, the identity lost its RBAC, or the cluster + is unreachable. + +A regrant during the hand-back lets it finish, then adopts the applier anew. A read error never +starts a hand-back; the controller already tells it apart from a denial (`kept` against +`falseCheck`). Once the applier is resumed, Flux or Argo CD may apply Git over live edits not +mirrored yet. That is what losing control of an applier means. + +Two alternatives were rejected: + +- **Refusing unpublished work at revocation.** It needs a worker shutdown with no flush, which + nothing else uses. +- **Keeping a revoked claim reserved until released.** It needs a persisted claim, which belongs + to the HA work. + +### Authorization and priority are separate + +- **Authorization: a `GitBranch` is a candidate only while a grant admits it.** An unadmitted one + never opens a push session with the shared credential. Once a grant admits it, it proves itself + and becomes a candidate like any other. +- **Priority: the oldest authorized candidate holds the branch,** by `creationTimestamp` and then + UID, as it does today for a `GitBranch` whose proof succeeds late. That is a choice: it is + deterministic, and a restart recomputes it from the objects alone. +- **The consequence:** granting a namespace that holds an old `GitBranch` for a branch someone else + holds hands the branch to the old one once it proves itself. The live holder then reports + `BranchConflict`. The configuration docs say so: remove stale `GitBranch`es before granting. + +The implementation must get these right: + +- **Revocation calls `Claims().Release(uid)`, not `Observe(..., false)`.** `Observe` keeps a + holder's candidacy through a failed proof. A revoked candidate that does not hold the branch must + stop being a candidate too. +- **A transient read error is not a revocation.** Keep the old answer, retry, and hand nothing off + on an error alone. +- **A successor starts only after the old worker retires.** `EnsureWorker` already refuses while + another `GitBranch`'s worker is live. One edit can revoke several candidates, so a successor must + not hold the branch on an admission it was given before the edit. +- **Pending work never moves to a new holder or credential.** + +## The limit: another provider on the same repository + +The claim key is the repository and the branch; the provider is not part of it. Grants on +`shared-repo` decide who may claim **through that provider**. They do not stop a `GitBranch` that +claims `main` through a different `GitProvider` for the same URL. That provider can be in a tenant's +namespace with a copied token. On Gitea or Forgejo, a token that can only read is enough, because it +still opens a receive-pack session. If that `GitBranch` is older, or first, it holds the branch. The +platform's `GitBranch`, and the `allowedGitTargets` on it, then report `BranchConflict` and govern +nothing. + +State the limit wherever the grants are documented: + +> Grants decide who may claim a branch through this provider, and who may write through this +> `GitBranch`. They cannot stop a claim on the same repository branch through another provider +> whose credential opens a push session. + +In the [platform example](#a-platform-and-its-tenants), tenants cannot create providers, so a +competing claim needs someone with provider rights. If tenants must create their own providers, one +governing authority per repository becomes part of the design: a cluster-scoped object per +repository URL (option C of the original one-writer plan), or a cluster-level rule naming the one +provider through which a repository's branches may be claimed. Neither exists. + +## Access between objects: the whole picture + +Every reference in `api/v1alpha3`, and whether it needs a grant. **An object that lends something +lists every grant explicitly: a credential, a branch, or reach into other namespaces.** A +reference that stays in its own namespace and lends nothing needs no grant. + +| Reference | What it lends | Grant | +|---|---|---| +| `GitBranch.gitProviderRef` → `GitProvider` | A credential, and a branch of its repository | **`GitProvider.allowedGitBranches`**, scoped by `branches` | +| `GitTarget.gitBranchRef` → `GitBranch` | Writing a folder into the branch | **`GitBranch.allowedGitTargets`** | +| `GitTarget.kubeProviderRef` → `KubeProvider` | Reading a cluster with its credential | **`KubeProvider.allowedGitTargets`**, scoped by `sourceNamespaces` and `clusterResources` | +| `FluxClaim` / `ArgoCDClaim` `.kubeProviderRef` → `KubeProvider` | Control over an applier, with its credential | **`KubeProvider.allowedApplierClaims`**, scoped by `applierNamespaces` | +| `FluxClaim.kustomizationRef`, `ArgoCDClaim.applicationRef` → the applier in the source cluster | (points into a cluster whose objects we do not own) | Bounded by `applierNamespaces` | +| `WatchRule.rules[].sourceNamespace` → a source-cluster namespace | (same) | Bounded by `sourceNamespaces` | +| `ClusterWatchRule.gitTargetRef` → a `GitTarget` in any namespace | Cluster-scoped reads through that target's `KubeProvider` | Bounded by `clusterResources` on the grant that admits the target | +| `WatchRule.gitTargetRef`, `CommitRequest.gitTargetRef` (own namespace only) | Nothing: what flows in is bounded by the `GitTarget`'s own grants | None | +| `GitTarget.applierClaimRef` (own namespace only) | Nothing new: the applier claim is already bound to one applier by its own grant | None | +| `secretRef`, `knownHostsRef` (own namespace only) | The Kubernetes convention for Secrets | None | +| `GitReceiver` | Nothing references it; a delivery only wakes a worker, which reads the remote with its own credential | None | +| `KubeProvider.inCluster` | The operator's own identity | Consent at install time (`--in-cluster-kube-providers`), unchanged | + +The list is closed. A new kind that references a provider, or lends reach, gets a grant list on the +object it references, following [the rules](#the-rules). + +## Why now + +| Field | In 0.53.0 | In the next release as it stands | +|---|---|---| +| `GitProvider.spec.allowedBranches` | Yes | Yes | +| `GitProvider.spec.accessFrom` | No | New (#428) | +| `KubeProvider.spec.accessFrom`, `.allowCrossNamespace` | On `ClusterProvider`, which the next release removes | New, on a new kind (#430) | + +`allowedBranches` is the only released name among them, and `GitProvider` breaks in that release +anyway (`spec.receiver` is gone, and `GitBranch` takes over the branch). If `accessFrom` ships, a +later rename breaks released manifests. If the rename lands first, those names are never released. + +## The change, surface by surface + +One PR. Measured with `git grep` on 2026-10-23, outside `docs/reviews`: `accessFrom` is in about 75 +files, `allowCrossNamespace` in about 50, and `allowedBranches` in about 40. Most of these are +docs, samples, the chart and e2e fixtures. + +| Surface | Change | +|---|---| +| `api/v1alpha3` | `GitProvider`: drop `AccessFrom` and `AllowedBranches`; add `AllowedGitBranches []GitBranchGrant`. `GitBranch`: `gitProviderRef` becomes namespaced; add `AllowedGitTargets []NamespaceGrant`. `KubeProvider`: drop `AccessFrom` and `AllowCrossNamespace`; add `AllowedGitTargets []SourceGrant` and `AllowedApplierClaims []ApplierGrant`. Each grant has `from` with `names` or `selector`, and the CEL rules from the semantics table. `NamespaceMatcher` goes: nothing else uses it. Doc comments on `GitTarget`, `GitBranch`, `FluxClaim` and `ArgoCDClaim` that say `accessFrom` | +| Accessors | `GitProvider.AdmitsGitBranch(ns, labels, branch)`, `GitBranch.AdmitsGitTarget(ns, labels)`, `KubeProvider.AdmitsGitTarget` and `AdmitsApplierClaim`, each returning the scope that applies. They replace `AdmitsNamespace`, `AllowsCrossNamespaceSources` and `AllowsCrossNamespaceAppliers`. One `from` matcher, and one pattern matcher with `{namespace}` expansion | +| `internal/authz` | Every own-namespace shortcut goes: `gitBranchAdmitted`'s and `namespaceAdmitted`'s, and **`ApplierAdmitted`'s**, which returns early when the claim and the target share a provider and a namespace. It must now check `allowedApplierClaims` on its own. `source_namespace.go` checks `sourceNamespaces`, and `watchrule_compile.go` checks `clusterResources`. Messages follow [refusal messages](#refusal-messages-name-the-grant) | +| Claim and worker | `GitBranch` admission is checked before proof and candidacy; revocation goes through `finalize` without removing the finalizer; `WorkerRequest` carries the resolved provider namespace (see implementation notes) | +| Applier claims | An explicit admission denial takes the `retire` path; a read error keeps the hold | +| Controllers and watches | `WatchRule` and `GitBranch` get Namespace watches; `GitBranch`es are indexed by resolved provider key (see implementation notes) | +| CRDs and chart | Regenerate. The chart's `local` provider gets empty grant lists by default, a grant for the quickstart namespace under `quickstart.enabled`, and `values-playground.yaml` (see [the chart's `local` provider](#the-charts-local-provider)). `config/kubeprovider-local.yaml` and the e2e install grant what the suite needs; samples | +| `hack/crdfields` | The field inventory | +| Docs | `configuration.md` (sharing section, the semantics table, `GitProvider`, `GitBranch`, `KubeProvider`, the boundaries, the platform example), `security-model.md`, `rbac.md`, `quickstart.md`, `components.md`, `architecture.md`, the bi-directional and attribution guides, `docs/spec` | +| `definitions.md` | Remove `spec.accessFrom` from the fields mirrored from Flux word for word, and rewrite the line saying a provider's "`spec.accessFrom` admits the others". Add "grant", and `from` among the names borrowed from Gateway API | +| `gitbranch-and-kubeprovider-plan.md` | Its "no `accessFrom` of its own" and "one policy governs both hops" for `GitBranch` are superseded; point there from here | +| `UPGRADING.md` | One entry: `allowedBranches` became `allowedGitBranches`, which also says who may claim; every `GitBranch`, `GitTarget` and applier claim now needs a grant, also in the provider's own namespace; the chart's `local` provider grants nothing unless values or the quickstart do; and a `ClusterWatchRule` needs `clusterResources` | +| e2e and fixtures | Every `accessFrom`, `allowCrossNamespace` and `allowedBranches` in `test/e2e` and `test/fixtures`; same-namespace fixtures gain explicit grants | + +The worker and the Git write path change, so this is the high-risk case in `AGENTS.md`: run local +e2e before pushing. + +## Until this lands + +Today a platform isolates teams on one repository by creating one `GitProvider` per team, all +sharing one Secret, each with its own `allowedBranches` and `accessFrom`, and creating the +`GitBranch`es itself. The example in +[configuration.md](../configuration.md#sharing-connections-across-namespaces) uses one provider for +two teams, which lets each write the other's branch; the page should say so. + +## Alternatives considered + +| Alternative | Why not chosen | +|---|---| +| Keep `accessFrom` (Flux's spelling) | It does not say which act it grants, and it cannot grant mirroring without applying, or one branch without all | +| Admit the own namespace automatically, with its own namespace as the scope, and keep `allowedBranches` as a limit on every claim (the previous version of this page) | Hidden rules: the default differed per field, `[flux-system]` meant "flux-system plus your own", and a delegated branch needed two fields read together. Explicit grants show the whole policy in one place | +| Admit the own namespace with unlimited scope | Widens today's `KubeProvider` behavior, and loses the RBAC split where a team may create `GitBranch`es beside a provider it may not edit | +| A provider-level writer policy beside `GitBranch.allowedGitTargets` | Two policies that intersect. The platform keeps control of a branch by owning its `GitBranch` | +| `allowedGitTargets` on `GitProvider`, with `branches` per grant | A `GitTarget` does not reference the provider, so the decision reached through the branch | +| Rules keyed by branch on `GitProvider`, first match wins | It needs list order to keep `main` narrow beside a `*` rule. That order is invisible in a merged manifest, and the shape does not carry over to `KubeProvider` | +| Field names ending in `From` (`gitBranchesFrom`, an earlier version of this page) | With `from` in every grant, `gitBranchesFrom: [{from: Same}]` says "from" twice. `allowed` plus the kind reads as one sentence, and is Gateway API's own pattern | +| Flat `branchClaimsFrom` beside `allowedBranches` and `accessFrom` | Three lists that interact. The effective rule for one branch is their intersection | +| A separate grant kind in the provider's namespace (`GitBranchGrant`, after Gateway API's `ReferenceGrant`) | Worth it only if someone needs separate RBAC for grants and connections; it adds a kind, a watch and grant-deletion handling | +| Two phases: the renames first, cross-namespace claims later | Ships an API whose own-namespace rules change again in the second phase. One PR settles the model once | +| `filepath.Match` for branch patterns | Its `*` stops at `/`, so `feature/*` missed `feature/a/b` and nothing meant "every branch"; and it brings character classes, `?` and escaping that a grant never needs | +| A chart default that grants every namespace, or every namespace carrying a label | Every namespace silently gains use of the operator's identity. A label default quietly becomes a grant to everyone on platforms where tenants may label their own namespaces | +| `names` and `selector` side by side in one grant, and `clusterScoped: true` (the previous version of this page) | Two booleans in disguise: an unset field meant something, `selector: {}` meant everyone, and a bool cannot narrow later. `from` and list scopes say it outright | +| A per-grant limit on the number of `GitBranch`es | Quota and authorization have different owners. Overlapping grants make per-grant counting complicated, and a namespace `ResourceQuota` already counts custom resources | +| Binding `Names` grants to namespace UIDs | Breaks declarative provisioning, because the UID exists only after the namespace is created. Recreating a namespace is a trusted administrative act | +| Tenant-owned providers with their own credentials | Right when repositories separate tenants. On one shared repository, a copied repository-wide token can claim any branch (see the limit) | + +## Implementation notes + +- **Watches.** + - **Already wired:** a `GitBranch` event reaches the `GitTarget`s in every namespace that + reference it ([`gitTargetsOn`](../../internal/controller/gittarget_controller.go)). A Namespace + label change reaches that namespace's `GitTarget`s and applier claims (`namespaceToClaims`). A + `KubeProvider` spec change reaches the `WatchRule`s (`kubeProviderToWatchRules`). + - **`WatchRule` needs a Namespace watch.** Its `GitTarget` watch fires only on a spec change or a + readiness move, so a label change that narrows `sourceNamespaces` while the `GitTarget` stays + admitted would re-evaluate no `WatchRule`. Map a Namespace to the `WatchRule`s in it. The same + holds for `ClusterWatchRule` and `clusterResources`, mapped through the targets in that namespace. + - **`GitBranch` needs a Namespace watch,** mapped to the `GitBranch`es in that namespace. From + there, revocation reaches their `GitTarget`s through `gitTargetsOn`. + - **Index `GitBranch`es by resolved provider key.** `gitTargetsOn` and `gitProviderToGitBranches` + find a provider's `GitBranch`es in the provider's namespace only. +- **Credential resolution.** + [`WorkerRequest.provider()`](../../internal/git/worker_manager.go) builds the provider key from + `GitBranch.Namespace`. The resolved provider namespace has to be carried through + `gitDestination`, `WorkerRequest` and worker creation. +- **One admission function per act, in `internal/authz`.** Each returns the decision, the scope + that applies, and the grants for the refusal message. Call the `GitBranch` one before proof and + candidacy. Keep distinct reasons for each case: reference missing, namespace not admitted, branch + not allowed, proof failed, branch conflict. +- **Status.** `kubectl get gitbranch` should show the provider's namespace. + +## What shipped, and where it departs + +The API, the grants and the checklist below shipped as written. Where the code departs from the +page: + +- **`GitBranch.spec.gitProviderRef` is immutable as written.** CEL cannot read + `metadata.namespace`, and there is no mutating webhook to fill in the default, so writing out the + `GitBranch`'s own namespace where it was omitted counts as a change and is refused. + `GitTarget.spec.kubeProviderRef` already behaves this way. +- **A holder giving its branch up hands it to nobody.** The plan's "check the successor's admission + again" was first built as a walk down the candidates, re-reading each one's grant. A review found + a race in it: the claim registry elected the successor before the walk checked it, so a + concurrent `GitTarget` reconcile could start a revoked successor's worker, and the walk did not + stop it. Now `BranchClaims.Release` leaves the branch unheld and returns the waiting + `GitBranch`es, which are woken. Each takes the branch only through its own reconcile, whose grant + check is the only one (rule 5 in + [`branch_owner.go`](../../internal/git/branch_owner.go)). A waiting `GitBranch` is stalled on + `BranchConflict` and requeues within seconds anyway; the wake only saves that wait. If a younger + one reconciles first and holds the branch, an older one takes it over when it reconciles, as + after a restart. +- **`clusterResources` is checked against what the planner selects.** "Decided on the text" holds + for `"*"` and for named groups. An item that omits `apiGroups` is checked by the types the + planner's own matcher selects for it in the source cluster + (`Manager.ClusterResourcesSelectedFor`), with its `apiVersions` and its ambiguity rule. An earlier + version resolved the group by resource name alone and let through an item it could not pin to + one group. That was wrong when only one of the groups serves the named version. The compiled rule + also carries the granted identities: the planner watches nothing outside them, and they are part + of the plan's fingerprint, so a recompile under a narrower grant rebuilds the plan. A type + discovery serves only after the compile is not watched, and the rule is refused when it next + reconciles. +- **A denied applier claim hands back through `retire` as it is.** If the identity is Forbidden from + patching the applier, it lets go at once with a Warning Event. Other failures retry until + `releaseDeadline`. A hand-back a denial began reads `Held=False`, reason `Revoking`, rather than + `Releasing`, which is how a regrant knows to let it finish before adopting the applier again. A + first cut marked it by the opening words of the `Releasing` message instead. +- **The scope variable is `{consumer.namespace}`, and `{consumer.name}` joined it.** This page + writes `{namespace}`. That collided with the placement template's `{namespace}`, the mirrored + resource's namespace, and did not say whose namespace it was among the three a grant involves. + The object a grant admits is now the consumer. `{consumer.name}` is allowed only in a branch + pattern that also holds `{consumer.namespace}`, as a naming convention: the consumer picks its own + name, so in a namespace scope, or alone in a pattern, it would bound nothing. `{namespace}` is + refused as an unknown variable. +- **A deleted `GitProvider` or `KubeProvider` is not a revocation.** The `GitBranch` keeps its claim, + and the applier claim keeps its hold, as before. +- **Watch-driven tests.** The drain, successor and two-candidate revocation tests, and the applier + claim's hand-back tests, call `Reconcile` directly rather than running in envtest. The two + recovery items left open below are the gaps. + +## Decided + +The questions earlier versions left open, closed without a new grant field: + +| Question | Decision | Why | +|---|---|---| +| Branch-count limits | No per-grant limit; a namespace `ResourceQuota` (`count/gitbranches.configbutler.ai: "20"`) | Quota and authorization have different owners. It bounds `GitBranch` objects in a namespace, across providers; repository size and churn stay unbounded. Exact branch names in a grant cap the distinct branches, while competing `GitBranch` objects for one branch stay uncounted | +| `parentBranch` outside the claim grant | Allowed, as far as the provider's credential can read | Creating `feature/x` from `main` should not need the right to claim `main`. The confidentiality boundary above says what follows | +| Path isolation | Out of scope | The delegated unit is the branch; the overlap check coordinates writers | +| Recreated namespaces | `Names` grants stay name-based, with no UID binding | It fits declarative provisioning; reusing a namespace is a trusted administrative act | + +## Tests to add + +The checklist for the PR. Each line is a behavior a later change could break without noticing. +Unit tests cover the matcher and the admission functions. Watch-driven tests run in envtest, driven +by watch events: a direct `Reconcile` call proves no event wiring. e2e covers what needs a Git +server and an applier. + +**Patterns and CEL** (unit, plus CEL tests on the CRD): + +- [x] `teams/{namespace}/*` for `team-a` matches `teams/team-a/live` and **does not** match + `teams/team-ab/live`. +- [x] `teams/{namespace}*` is refused at admission (`{namespace}` is not a whole segment). +- [x] `feature/*` matches `feature/checkout/v2`; `*` matches every branch, `main` included. +- [x] `teams/*` matches `teams/a/b` (`*` crosses `/`); `main` does not match `main-old` (whole name). +- [x] `teams/{ns}/*` is refused, and the message names `{ns}` and `{namespace}`. +- [x] `[]` is refused for `names`, `branches`, `sourceNamespaces`, `clusterResources` and + `applierNamespaces`; a grant without `from` is refused, and so are `names` without `Names` and + `selector` without `Selector`; a `KubeProvider` `allowedGitTargets` grant with neither + `sourceNamespaces` nor `clusterResources` is refused. +- [x] `names: ["{namespace}"]` is refused, with a message pointing to `from: Same`. +- [x] `from: Selector` with no `selector`, or with `selector: {}`, is refused (use `from: All`); + a non-empty selector is accepted. +- [x] `from: Same` admits the object's own namespace after a kustomize overlay moves the object to + another namespace, and admits nothing else; `from: All` admits every namespace. + +**Admission** (unit, `internal/authz`): + +- [x] The own namespace is refused when no grant names it, on all four lists. For a `GitBranch`, + that includes a `GitTarget` in the `GitBranch`'s own namespace. +- [x] `applierNamespaces: [flux-system]` refuses a claim on an applier in the claim's own + namespace. An explicit list is the whole scope. +- [x] `sourceNamespaces: ["{namespace}"]` refuses `sourceNamespace: "*"`, and `["*"]` allows it. +- [x] Mirroring allowed, applying denied, on one `KubeProvider`, with the `GitTarget` and its applier + claim in one namespace. Today's `ApplierAdmitted` shortcut would allow the apply. +- [x] A `ClusterWatchRule` for RBAC `clusterroles` is refused through a target whose grant has no + `clusterResources`, or only `[customresourcedefinitions.apiextensions.k8s.io]`, or a bare + `[clusterroles]`. It compiles with `[clusterroles.rbac.authorization.k8s.io]` or `["*"]`. +- [x] A `ClusterWatchRule` item with `resources: ["*"]` or `apiGroups: ["*"]` is refused under a + narrow grant, and a CRD installed later is not mirrored. Under `["*"]` it is. +- [x] A `ClusterWatchRule` naming one permitted and one forbidden type is refused whole, and the + message names the forbidden type. +- [x] Removing one identity from `clusterResources` refuses the rules naming it, while the + `GitTarget` stays admitted for its other rules. +- [x] An item that omits `apiGroups`, whose resource two groups serve but only one at the version it + names, is checked by the type the planner selects; withdrawing that grant refuses the rule and + empties the plan. A rule recompiled under a narrower grant rebuilds the cached plan. +- [x] Refusal messages name the grants that apply and the requested value, for each act. + +**Claim and credential** (unit and envtest): + +- [x] An unadmitted `GitBranch` never opens a push session with the shared credential. +- [x] A `GitBranch` admitted later becomes a candidate. If it is older than the holder, it takes the + branch (`creationTimestamp`, then UID), also after a restart. +- [x] A cross-namespace `GitBranch` pushes with the platform provider's Secret, both when its own + namespace has no provider and when it has a same-name provider with a different credential. +- [x] Receiver routing still wakes the worker of a cross-namespace `GitBranch`. + +**Revocation** (envtest, driven by watch events): + +- [x] Removing a namespace from `GitBranch.allowedGitTargets` refuses that namespace's `GitTarget`s; + adding it back admits them. +- [x] Scope narrowed while admission stays: a namespace matched by two grants loses the label of the + one granting `"*"`. Its `WatchRule`s stop watching the other namespaces, and the `GitTarget` + stays admitted. This is the new `WatchRule` Namespace watch. +- [x] A label removed from a claimant's namespace revokes its `GitBranch` through the new + `GitBranch` Namespace watch, and its `GitTarget`s in other namespaces follow. +- [x] A revoked `GitBranch`'s worker drains what it held when the revocation was observed. An event + arriving afterwards is refused. The successor starts only after the old worker retires, and + receives none of its work. (Unit, reconciling directly: `gitbranch_access_test.go`.) +- [x] One grant edit revokes two candidates; neither holds the branch afterwards, and a `GitTarget` + reconcile racing the handoff cannot start the revoked successor's worker. (Unit.) +- [x] An applier claim denied by `allowedApplierClaims` hands its applier back. A read error on the + `KubeProvider` keeps the hold. (Unit, `applierclaim_revocation_test.go`, as the next three.) +- [x] A `Kustomization` suspended before adoption stays suspended after a denial's hand-back. +- [x] A hand-back that cannot finish (the identity lost its RBAC) shows in status and a Warning + Event, and gives up at `releaseDeadline`. +- [x] A regrant during the hand-back lets it finish, then adopts the applier anew. + +**Recovery** (envtest; fixing the grant must be enough, with no consumer edited): + +- [ ] Restoring a grant re-admits, each through its watch: a `GitBranch` (it proves itself and + claims), a namespace's source scope, `clusterResources` for a `ClusterWatchRule`, and an applier + claim (it adopts its applier). Done through watches for the first three (`wiring/`); the applier + claim's re-adoption is covered by a unit test only. +- [ ] The same after a controller restart: restored grants are read fresh, and nothing revoked + earlier stays refused. Covered for the branch claim only (a fresh claim registry, unit). + +**Chart and end to end:** + +- [x] A default install grants nothing: a `GitTarget` through `local` is refused with the message + that names `spec.allowedGitTargets`. +- [x] `quickstart.enabled=true` mirrors the quickstart namespace and nothing else. +- [x] `values-playground.yaml` lets any namespace mirror its own namespace, and no other. +- [x] e2e: a team claims `teams//live` through a platform provider and writes it, then shares it + with a second team. Claims on `main` and on another team's branch are refused with their + messages. diff --git a/docs/future/ha-gittarget-distribution-plan.md b/docs/future/ha-gittarget-distribution-plan.md index 2fcefde4c..2ee6c009b 100644 --- a/docs/future/ha-gittarget-distribution-plan.md +++ b/docs/future/ha-gittarget-distribution-plan.md @@ -147,8 +147,9 @@ target incarnations alongside the shard key; sharing a destination must not sile incompatible policy or reroute old work to a replacement object with the same name. Providers are not combined: the branch's one `GitProvider` decides credential, branch -permissions and signing policy, and `GitTarget`s in other namespaces share it through its -`spec.accessFrom`. A shared destination does not authorize one provider's work to use another +permissions and signing policy. `GitBranch`es in other namespaces claim through it by its +`spec.allowedGitBranches`, and `GitTarget`s write through a `GitBranch` by that branch's +`spec.allowedGitTargets`. A shared destination does not authorize one provider's work to use another provider's permissions. ### Journal record diff --git a/docs/layout/model.md b/docs/layout/model.md index 084a942c7..ba233fa71 100644 --- a/docs/layout/model.md +++ b/docs/layout/model.md @@ -530,5 +530,5 @@ one asserts a rule PR 1 ships; the second-namespace one is written and skipped n - Namespace-local `GitProvider` (answered): the homelab examples put a `GitTarget` in `argocd`, `flux-system` and `homelab-config`, each with its own `GitProvider`, three copies of one credential in a single-owner cluster. One `GitBranch` now serves all three: - `spec.gitBranchRef.namespace` names it, and its `GitProvider`'s `spec.accessFrom` admits the other + `spec.gitBranchRef.namespace` names it, and its `spec.allowedGitTargets` admit the other namespaces. A second `GitBranch` for the same repository branch holds nothing (`BranchConflict`). diff --git a/docs/quickstart.md b/docs/quickstart.md index 534ecaf4e..ef0785936 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -148,8 +148,9 @@ kubectl describe gitprovider,gittarget,watchrule -n gitops-reverser-quickstart-d ``` Two `GitTarget` conditions stop the data plane and are worth recognizing: `KubeProviderNotFound` -(the `local` `KubeProvider` in `gitops-reverser` is missing) and `NamespaceNotAuthorized` (its -`accessFrom` selector does not cover the demo namespace). +(the `local` `KubeProvider` in `gitops-reverser` is missing) and `NamespaceNotAuthorized` (no grant +in its `spec.allowedGitTargets` admits the demo namespace). `quickstart.enabled=true` adds the grant +for `quickstart.namespace`; the chart grants nothing else by default. ## Clean up diff --git a/docs/rbac.md b/docs/rbac.md index da6172899..76e370927 100644 --- a/docs/rbac.md +++ b/docs/rbac.md @@ -75,8 +75,9 @@ it, so the list must cover every type your rules name. ### `namespaces` on a REMOTE source cluster The operator needs **no `Namespace` access at all** in a source cluster. The manager role's -`namespaces` `get`/`list`/`watch` covers the operator's own cluster, where `KubeProvider.accessFrom` -selectors match control-cluster labels. +`namespaces` `get`/`list`/`watch` covers the operator's own cluster, where `Selector` grants (in +`GitProvider.spec.allowedGitBranches`, `GitBranch.spec.allowedGitTargets` and the `KubeProvider`'s +grant lists) match control-cluster labels. This used to be different: `GitTarget.spec.allowedSourceNamespaces` could carry a label selector evaluated against `Namespace`s in the *source* cluster, so a remote provider's identity needed diff --git a/docs/security-model.md b/docs/security-model.md index 896206ee8..20ad641cc 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -55,11 +55,11 @@ age key under `generateWhenMissing`). RBAC above is the hard maximum; *within* it, scope is carried by the rule **kind**: - A **`WatchRule`** selects namespaced resources. Each `spec.rules[]` item watches the rule's own - namespace by default. Naming any other source namespace — including `sourceNamespace: "*"` — - requires `KubeProvider.spec.allowCrossNamespace.sources`, a delegation by the provider's owner that - is false by default. That flag is the only namespace policy in this direction: what may actually be - read is bounded by the provider credential's own Kubernetes RBAC, and a `"*"` reaches every - namespace that credential can read, as one cluster-wide watch. + namespace by default, and every item, that one included, needs the `KubeProvider`'s grant: the + `sourceNamespaces` of the `allowedGitTargets` grants that admit the `GitTarget`'s namespace. A + `sourceNamespace: "*"` needs `"*"` there. That scope is the only namespace policy in this direction: + what may actually be read is bounded by the provider credential's own Kubernetes RBAC, and a `"*"` + reaches every namespace that credential can read, as one cluster-wide watch. On an **`inCluster`** provider that delegation deliberately bypasses live namespace RBAC: the owner of an admitted `GitTarget` can then mirror another namespace's objects — read through the @@ -117,9 +117,10 @@ namespace it already knows. Narrowing that further is | Audit ingress (`/audit-webhook/`, such as `/audit-webhook/gitops-reverser.local` for the chart's provider) | Accepts audit traffic; protected by mutual TLS via cert-manager. A route is held by one namespace: a `KubeProvider` elsewhere that carries it is `Stalled` (`AuditRouteConflict`) and reads none of its facts. | | Push receiver (`--receiver-bind-address`, off by default) | Plain HTTP for Git hosts' push webhooks. Each `GitReceiver` route is a path derived from its token, whose Secret lives in the receiver's own namespace, and a delivery must carry a valid signature or token for it before its body is read. A delivery is only a hint: it wakes the branch worker of every `GitProvider`, in any namespace, whose `spec.url` names the repository it reports, and that worker reads the remote with its own provider's credential. The payload's repository URL is only compared with `spec.url`, never fetched, so a delivery cannot choose a URL, a credential or a commit. A worker holds at most one pending look however many deliveries arrive, so deliveries never queue up behind its writes; but they are not rate-limited, so a token holder, in whatever namespace, who keeps delivering can keep a matching worker reading its remote continuously. That availability cost is accepted: there is no per-worker cooldown. The `202` carries no body, so it does not say whether another namespace tracks a repository. | | Generated Secret material | Signing keys and generated age keys live in cluster Secrets. | -| `accessFrom` label selectors on `GitProvider` and `KubeProvider` | A selector admits every control-cluster namespace whose labels match, so it is only as strong as the right to set those labels. Whoever can label a namespace can admit it, including a tenant allowed to edit its own namespace. Select on labels only the platform can set, or list namespaces by `names`. | +| Grants (`allowedGitBranches`, `allowedGitTargets`, `allowedApplierClaims`) | Nothing is admitted automatically, an object's own namespace included. A `from: Selector` grant admits every control-cluster namespace whose labels match, so it is only as strong as the right to set those labels: whoever can label a namespace can admit it, including a tenant allowed to edit its own namespace. Select on labels only the platform can set, or use `from: Names`. A `Names` grant also admits a namespace deleted and recreated with the same name. Grants decide who may use a provider's identity; every operation still runs as that identity, never as the referrer. | +| Another provider on the same repository | The branch claim is keyed by repository and branch, not by provider, so grants cannot stop a `GitBranch` that claims a branch through a second `GitProvider` for the same URL with a copied credential. On Gitea and Forgejo a read-only token opens a push session. Keep tenants from creating `GitProvider`s where they share a repository. | | `KubeProvider.spec.inCluster` and `--in-cluster-kube-providers` | `inCluster` wields the operator's own identity in the cluster it runs in. It is honored only for a `KubeProvider` the flag lists by exact `namespace/name` (no wildcards, empty by default); any other is `Stalled` (`InClusterNotAuthorized`) and never used. Anyone who can create a `KubeProvider` under a listed name wields that identity through it, so list only names in namespaces you control. | -| `KubeProvider.spec.allowCrossNamespace.appliers` | Lets every namespace the provider admits suspend and apply a `Kustomization` or `Application` in another namespace, such as `flux-system`, through the provider's identity. A delegation, not owner consent: it does not prove the namespace owns the applier or its repository. An applier claim only acts while an eligible `GitTarget` names it, and the binding check compares the `GitProvider` URL, not write access. | +| `KubeProvider.spec.allowedApplierClaims` | Lets the namespaces a grant admits suspend and apply a `Kustomization` or `Application` in its `applierNamespaces`, such as `flux-system`, through the provider's identity. Admission to mirror does not admit to apply. A delegation, not owner consent: it does not prove the namespace owns the applier or its repository. An applier claim only acts while an eligible `GitTarget` names it, and the binding check compares the `GitProvider` URL, not write access. A denial hands the applier back. | ## Secret data the controller writes to Git @@ -139,10 +140,11 @@ resource types can opt into the same encryption path at controller startup. See ## Git credentials Secret shape -`GitProvider.spec.secretRef` points to a Secret in the provider's own namespace. A `GitTarget` in -another namespace that writes through the provider, which its `spec.accessFrom` must admit, uses -that Secret without being able to read it. The controller picks the auth -method from the keys present, preferring an SSH key, then HTTP basic auth, then a bearer token. The +`GitProvider.spec.secretRef` points to a Secret in the provider's own namespace. A `GitBranch` in +another namespace that claims a branch through the provider, which its `spec.allowedGitBranches` +must admit, and the `GitTarget`s that branch admits, use that Secret without being able to read it. +The controller picks the auth method from the keys present, preferring an SSH key, then HTTP basic +auth, then a bearer token. The examples below use the Kubernetes-native key names; the reader also accepts the Flux and Argo CD key names so an existing GitOps Secret works unchanged (see [`design/git-credentials-interop.md`](finished/git-credentials-interop.md)). diff --git a/docs/spec/e2e-bi-directional-corner.md b/docs/spec/e2e-bi-directional-corner.md index 391bae3cd..c28153a5d 100644 --- a/docs/spec/e2e-bi-directional-corner.md +++ b/docs/spec/e2e-bi-directional-corner.md @@ -424,7 +424,7 @@ path would each stamp their own tracking-id on the same objects and raise The `Application` lives in the `argocd` namespace, targets `spec.destination.namespace = `, `spec.source.path = `, `targetRevision: main`, `directory.recurse: true`. The applier claim lives in the test -namespace, so each describe first grants `allowCrossNamespace.appliers` on the +namespace, so each describe first widens `allowedApplierClaims` on the install's `local` `KubeProvider` in the operator's namespace, as a user must for an `Application` in `argocd`. The `GitProvider` has a `gitea` receiver, registered as a Gitea webhook next to Argo @@ -494,7 +494,7 @@ env var no overlay declaration can express. Request an applier claim reconcile, since the applier is read on reconcile rather than watched. 2. Assert the switch is `false` again, with a `ClaimedResourceReconciled` Event on the - applier claim, `Held=True` (`AutomatedSyncOff`), and `gitops-reverser` among the + applier claim, `InControl=True` (`AutomatedSyncOff`), and `gitops-reverser` among the `Application`'s field managers. 3. Push a Git-side change. Assert GitOps Reverser syncs it as a `ForeignCommit` pinned to the pushed commit, and the order is live. @@ -509,7 +509,7 @@ env var no overlay declaration can express. `GitTarget`'s `ApplierClaimReady=False` with the same reason, and no `gitops-reverser` field manager on the `Application`. 3. Re-apply the `ApplicationSet` with `automated: {enabled: false}` in its template. - Assert `Held=True` (`AutomatedSyncOff`, kept off by the `ApplicationSet`), + Assert `InControl=True` (`AutomatedSyncOff`, kept off by the `ApplicationSet`), `ApplierClaimReady=True`, still no `gitops-reverser` field manager, and no finalizer on the applier claim. diff --git a/docs/spec/status-conditions-guide.md b/docs/spec/status-conditions-guide.md index e5a5aa2b4..938df9c58 100644 --- a/docs/spec/status-conditions-guide.md +++ b/docs/spec/status-conditions-guide.md @@ -263,10 +263,10 @@ Canonical reads: `GitPathAccepted=False` with reason `AmbiguousLayout` rather than by a second gate - WatchRule and ClusterWatchRule carry target dependency health in `GitTargetReady` - WatchRule carries source-namespace authorization in `SourceNamespaceAuthorized`, a positive - state-style condition set even for legacy own-namespace rules (reason `LegacySourceNamespace`), so - the effective authorization is always visible and automation has one condition to inspect. It is an - additional prerequisite of `Ready`, and is deliberately kept out of `GitTargetReady`, which stays - the referenced target's own health. + state-style condition set for every rule, its own namespace included (reason + `SourceNamespaceAllowed`), so the effective authorization is always visible and automation has + one condition to inspect. It is an additional prerequisite of `Ready`, and is deliberately kept + out of `GitTargetReady`, which stays the referenced target's own health. Its values are `True` and `False`, plus an `Unknown` that means only "not evaluated yet, because an earlier gate blocked this reconcile" (reason `Progressing`). `False` is a **refusal**: @@ -287,9 +287,9 @@ Canonical reads: about a mixed rule. First match wins: 1. any item **denied** → `False` / `SourceNamespaceNotAllowed` / `Stalled=True` - 2. every item admitted, at least one naming a namespace other than the rule's own → `True` / - `SourceNamespaceAllowed` - 3. every item on its own namespace → `True` / `LegacySourceNamespace` + 2. every item admitted → `True` / `SourceNamespaceAllowed` + + Every item needs a grant, the rule's own namespace included, so there is no ungated case. A **denied explicit name refuses the whole rule**; the item is never trimmed away so the rest can run, because mirroring two of the three namespaces a rule asked for is worse than a loud failure. @@ -302,7 +302,7 @@ Canonical reads: ### Applier claims -`FluxClaim` and `ArgoCDClaim` use the trio with `Configured` as their gate, and `Held` as a second +`FluxClaim` and `ArgoCDClaim` use the trio with `Configured` as their gate, and `InControl` as a second one. Canonical reads: @@ -311,15 +311,15 @@ Canonical reads: reason `NotReferenced`, `Reconciling=False`, `Stalled=False`. As for a `GitTarget` with nothing to mirror, doing nothing when nothing is asked is converged, and a Flux health check that waits on the applier claim must not wait for the `GitTarget` that will name it -- holding its applier: `Configured=True`, for a `FluxClaim` `Held=True` (`Suspended` or - `Applying`), and `Ready=True` +- in control of its applier: `Configured=True`, `InControl=True` (`Suspended` or `Applying` for a + `FluxClaim`, `AutomatedSyncOff` for an `ArgoCDClaim`), and `Ready=True` - applier or `KubeProvider` missing (`ApplierNotFound`, `KubeProviderNotFound`): `Ready=False`, `Reconciling=True`, `Stalled=False`, because Flux and Argo CD objects are often created after the applier claim. Every other `Configured=False` reason is `Stalled=True` - an operation past its time (`status.operation.phase: Unresolved`): `Ready=False`, `Reconciling=True`, reason `OperationUnresolved`. It clears when the operation completes - a `FluxClaim` whose suspend is not handled yet, or that is handing its `Kustomization` back: - `Held=False` (`Suspending`, `Releasing`), `Ready=False`, `Reconciling=True` + `InControl=False` (`Suspending`, `Releasing`), `Ready=False`, `Reconciling=True` A `GitTarget` naming an applier claim gains `ApplierClaimReady`, a gate on its own trio. `SourceMismatch`, or an applier claim that is itself `Stalled`, stalls the target; a missing applier diff --git a/docs/spec/where-validation-lives.md b/docs/spec/where-validation-lives.md index dc1da7b97..c003c44ad 100644 --- a/docs/spec/where-validation-lives.md +++ b/docs/spec/where-validation-lives.md @@ -37,7 +37,8 @@ Three things, in order of how often they get argued backwards: property, and it is worth less than the install cost of a webhook: cert wiring in the chart, a failure mode that can block tenant writes, and an extra moving part in every install path. -The worked example is `KubeProvider.spec.accessFrom`. An earlier design proposed +The worked example is the `KubeProvider`'s namespace admission (then `spec.accessFrom`, now +`spec.allowedGitTargets`). An earlier design proposed enforcing it "in two places", one of them a webhook. What shipped enforces it in **one**, on every reconcile, before `DeclareForGitTarget` ([`gittarget_controller.go:479`](../../internal/controller/gittarget_controller.go#L479), diff --git a/hack/crdfields/fields.yaml b/hack/crdfields/fields.yaml index 988001580..5774808d7 100644 --- a/hack/crdfields/fields.yaml +++ b/hack/crdfields/fields.yaml @@ -30,13 +30,12 @@ kinds: fields: - path: url doc: Repository URL, SSH or HTTPS - - path: allowedBranches - doc: Branches a `GitTarget` may write. A target naming any other is rejected - - path: accessFrom - default: "**no other namespace**" + - path: allowedGitBranches + default: "**no `GitBranch`**, its own namespace included" doc: >- - Other namespaces whose `GitTarget`s may write through this provider, by name or label - selector. Its own namespace is always admitted + Which namespaces' `GitBranch`es may claim which branches through this provider. Each grant + has a `consumerNamespaces` block (`from`: `Same`, `Names`, `Selector`, `All`) and the + `branchPatterns` it allows. See [the grants](#the-grants) - path: secretRef default: anonymous access doc: >- @@ -60,15 +59,21 @@ kinds: - kind: GitBranch title: "`GitBranch` (namespaced): one branch of the repository" - intro: "Every field is immutable: to change one, delete the `GitBranch` and create a new one." + intro: "Every field but `allowedGitTargets` is immutable: to change one, delete the `GitBranch` and create a new one." fields: - path: gitProviderRef doc: >- - The `GitProvider`, in this namespace, whose repository and credential write this branch + The `GitProvider` whose repository and credential write this branch: in this namespace, or + `namespace` names another. Its `allowedGitBranches` must admit this namespace and `branch` - path: branch doc: >- - Branch to write. Must be in the provider's `allowedBranches`. Only one `GitBranch` holds a - repository branch; see [who writes a repository branch](#who-writes-a-repository-branch) + Branch to write. Only one `GitBranch` holds a repository branch; see + [who writes a repository branch](#who-writes-a-repository-branch) + - path: allowedGitTargets + default: "**no `GitTarget`**, its own namespace included" + doc: >- + Which namespaces' `GitTarget`s may write through this branch, each grant with a + `consumerNamespaces` block. The only mutable field. See [the grants](#the-grants) - path: parentBranch default: the remote's default branch doc: >- @@ -103,22 +108,17 @@ kinds: `{}` uses the operator's own identity. Honored only for a provider listed in `--in-cluster-kube-providers`. See [the operator's own identity](#the-operators-own-identity-specincluster) - - path: accessFrom - default: "**no other namespace**" - doc: >- - Other control-cluster namespaces whose objects may use this provider, by name or label - selector. Its own namespace is always admitted; an empty `selector: {}` admits every - namespace - - path: allowCrossNamespace.sources - default: "`false`" + - path: allowedGitTargets + default: "**no `GitTarget`**, its own namespace included" doc: >- - Whether rules may watch namespaces other than their own, `*` included. See - [cross-namespace grants](#what-a-namespace-may-reach-outside-itself-specallowcrossnamespace) - - path: allowCrossNamespace.appliers - default: "`false`" + Which namespaces' `GitTarget`s may mirror through this provider, and what: each grant has a + `consumerNamespaces` block, and `sourceNamespaces`, `clusterResources` or both. See + [the grants](#the-grants) + - path: allowedApplierClaims + default: "**no applier claim**, its own namespace included" doc: >- - Whether applier claims may name a `Kustomization` or `Application` in another namespace than - their own + Which namespaces' applier claims may hold an applier through this provider, and the + `applierNamespaces` that applier may live in. See [the grants](#the-grants) - path: client.qps default: "`20`" doc: Client rate limit for this source cluster. Refused beside `inCluster` @@ -137,8 +137,8 @@ kinds: fields: - path: gitBranchRef doc: >- - The `GitBranch` this target writes: its own namespace, or `namespace` names one whose - `GitProvider`'s `accessFrom` admits it + The `GitBranch` this target writes: its own namespace, or `namespace` names another. Its + `allowedGitTargets` must admit this namespace - path: gitProviderRef skip: moved to `GitBranch`, kept in the schema only so admission refuses it, naming `gitBranchRef` - path: branch @@ -149,8 +149,8 @@ kinds: doc: Folder within the repository. `.` targets the root; empty is rejected - path: kubeProviderRef doc: >- - The `KubeProvider` to mirror from: its own namespace, or `namespace` names one whose - `accessFrom` admits it + The `KubeProvider` to mirror from: its own namespace, or `namespace` names another. Its + `allowedGitTargets` must admit this namespace - path: clusterProviderRef skip: replaced by `kubeProviderRef`, kept in the schema only so admission refuses it, naming `kubeProviderRef` - path: commit.window @@ -263,7 +263,7 @@ kinds: - path: kubeProviderRef doc: >- The `KubeProvider` whose cluster holds the `Kustomization`: the applier claim's own namespace, or - `namespace` names one whose `accessFrom` admits it + `namespace` names another. Its `allowedApplierClaims` must admit this namespace - path: clusterProviderRef skip: replaced by `kubeProviderRef`, kept in the schema only so admission refuses it, naming `kubeProviderRef` - path: kustomizationRef @@ -278,7 +278,7 @@ kinds: - path: kubeProviderRef doc: >- The `KubeProvider` whose cluster holds the `Application`: the applier claim's own namespace, or - `namespace` names one whose `accessFrom` admits it + `namespace` names another. Its `allowedApplierClaims` must admit this namespace - path: clusterProviderRef skip: replaced by `kubeProviderRef`, kept in the schema only so admission refuses it, naming `kubeProviderRef` - path: applicationRef diff --git a/hack/e2e/load-image.sh b/hack/e2e/load-image.sh index f675df583..66af1a42c 100755 --- a/hack/e2e/load-image.sh +++ b/hack/e2e/load-image.sh @@ -178,10 +178,17 @@ import_image() { # # Trust nothing either way: the pin step below verifies the image really # landed in every node, and any failed attempt (loud or silent) is retried. + # + # Direct mode can also hang: when the docker socket closes mid-stream ("Failed + # to copy read stream ... use of closed network connection"), the node's + # `ctr image import` waits on stdin forever and k3d never returns, so a run + # sat until the suite timeout without reaching attempt 2. A healthy import + # takes seconds; the timeout turns a hang into a failed attempt. for attempt in 1 2 3; do echo "Importing ${PROJECT_IMAGE} into k3d cluster ${CLUSTER_NAME} (attempt ${attempt}/3)" pinned=1 - if "${K3D}" image import -m direct -c "${CLUSTER_NAME}" "${PROJECT_IMAGE}"; then + if timeout --kill-after=10s "${IMAGE_IMPORT_TIMEOUT:-300s}" \ + "${K3D}" image import -m direct -c "${CLUSTER_NAME}" "${PROJECT_IMAGE}"; then while IFS= read -r node_name; do if ! pin_imported_image "${node_name}" "${ref}" "${PROJECT_IMAGE}" "${IMAGE_REPO}" "${IMAGE_TAG}"; then pinned=0 diff --git a/internal/applier/applier.go b/internal/applier/applier.go index f8fd5d00b..13bfeb522 100644 --- a/internal/applier/applier.go +++ b/internal/applier/applier.go @@ -74,11 +74,11 @@ type Ask struct { // Current is asked again after it, since it can wait on the status writer and the API server. // Nil records nothing. Record func(ctx context.Context) error - // Held reports, from a fresh read, whether the applier claim holds its Flux Kustomization suspended - // (Held=True), and errs when it does not: a resume while the applier claim's reconciler is still - // suspending it (an adoption, an external resume) would race that suspend. It is asked just - // before Record. Nil always holds; the Argo CD client never asks it. - Held func(ctx context.Context) error + // InControl reports, from a fresh read, whether the applier claim is in control of its Flux + // Kustomization (InControl=True), and errs when it is not: a resume while the applier claim's + // reconciler is still suspending it (an adoption, an external resume) would race that suspend. It + // is asked just before Record. Nil always is; the Argo CD client never asks it. + InControl func(ctx context.Context) error } // recordOnce is Record, called the first time only: a request that writes more than once (an Argo diff --git a/internal/applier/dispatcher.go b/internal/applier/dispatcher.go index 851ea2a85..c780c2358 100644 --- a/internal/applier/dispatcher.go +++ b/internal/applier/dispatcher.go @@ -126,7 +126,7 @@ type Dispatcher struct { // Now is the clock request tokens are taken from. Now func() time.Time // APIReader reads a FluxClaim uncached just before its Kustomization is resumed, so a suspend - // its reconciler is making is seen (Ask.Held). NewDispatcher sets it to the reader it was given. + // its reconciler is making is seen (Ask.InControl). NewDispatcher sets it to the reader it was given. APIReader client.Reader runMu sync.RWMutex @@ -374,7 +374,7 @@ func (d *Dispatcher) attempt(ctx context.Context, req git.ApplyRequest) (git.App applier := d.client(req.Applier.Kind, applierClient) started, err := applier.Request(ctx, applierName(req.Applier), Ask{ Want: want, Commit: req.Commit, Reason: req.Reason, Token: req.Token, Current: req.Current, Record: req.Record, - Held: func(ctx context.Context) error { return d.claimHolds(ctx, req.Applier.ApplierClaim) }, + InControl: func(ctx context.Context) error { return d.claimInControl(ctx, req.Applier.ApplierClaim) }, }) switch { case errors.Is(err, errStale): @@ -565,20 +565,20 @@ func (d *Dispatcher) progress(ctx context.Context, req git.ApplyRequest, op *ope return progress, err } -// ApplierClaimConditionHeld is a FluxClaim's Held condition: True (reason Suspended) once its -// Kustomization is suspended under GitOps Reverser's field manager and that suspend's token is -// handled, and True (reason Applying) while an operation of a branch worker has it resumed. The -// applier claim's reconciler sets it. -const ApplierClaimConditionHeld = "Held" +// ApplierClaimConditionInControl is an applier claim's InControl condition. For a FluxClaim: True +// (reason Suspended) once its Kustomization is suspended under GitOps Reverser's field manager and +// that suspend's token is handled, and True (reason Applying) while an operation of a branch worker +// has it resumed. The applier claim's reconciler sets it. +const ApplierClaimConditionInControl = "InControl" -// claimHolds is Ask.Held: it reads the FluxClaim uncached, and errs unless it is -// Held=True and not being deleted. Its reconciler is otherwise suspending the Kustomization, +// claimInControl is Ask.InControl: it reads the FluxClaim uncached, and errs unless it is +// InControl=True and not being deleted. Its reconciler is otherwise suspending the Kustomization, // or about to (an adoption, an external resume it suspends again, a hand-back), and that suspend would land // on the resume: Flux would then handle the resume's token on a suspended return, and apply // nothing. It also errs when another applier claim comes first in line for the Kustomization, read // uncached as well: the recheck's cached read can still miss one. The attempt fails, and the worker // asks again after its backoff. -func (d *Dispatcher) claimHolds(ctx context.Context, ref git.ApplierClaimRef) error { +func (d *Dispatcher) claimInControl(ctx context.Context, ref git.ApplierClaimRef) error { if ref.Kind != v1alpha3.ApplierClaimKindFlux { return nil } @@ -586,7 +586,7 @@ func (d *Dispatcher) claimHolds(ctx context.Context, ref git.ApplierClaimRef) er if err := d.APIReader.Get(ctx, client.ObjectKey{Namespace: ref.Namespace, Name: ref.Name}, claim); err != nil { return fmt.Errorf("read %s: %w", ref, err) } - held := apimeta.FindStatusCondition(claim.Status.Conditions, ApplierClaimConditionHeld) + inControl := apimeta.FindStatusCondition(claim.Status.Conditions, ApplierClaimConditionInControl) holder, err := HeldByAnother(ctx, d.APIReader, claim) switch { case err != nil: @@ -597,12 +597,12 @@ func (d *Dispatcher) claimHolds(ctx context.Context, ref git.ApplierClaimRef) er case !claim.DeletionTimestamp.IsZero(): return fmt.Errorf("FluxClaim %s/%s is being deleted, and hands its Kustomization back", ref.Namespace, ref.Name) - case held == nil || held.Status != metav1.ConditionTrue: - message := "it reports no Held condition yet" - if held != nil { - message = fmt.Sprintf("it is Held=%s, %s: %s", held.Status, held.Reason, held.Message) + case inControl == nil || inControl.Status != metav1.ConditionTrue: + message := "it reports no InControl condition yet" + if inControl != nil { + message = fmt.Sprintf("it is InControl=%s, %s: %s", inControl.Status, inControl.Reason, inControl.Message) } - return fmt.Errorf("FluxClaim %s/%s does not hold its Kustomization suspended: %s", + return fmt.Errorf("FluxClaim %s/%s is not in control of its Kustomization: %s", ref.Namespace, ref.Name, message) } return nil diff --git a/internal/applier/dispatcher_test.go b/internal/applier/dispatcher_test.go index 29d724fbf..75758728d 100644 --- a/internal/applier/dispatcher_test.go +++ b/internal/applier/dispatcher_test.go @@ -42,7 +42,7 @@ const ( ) // kubeProviderNS is where the tests' KubeProviders live: a namespace of their own, so whether one -// admits targetNS is its spec.accessFrom's decision rather than the own-namespace rule's. +// admits targetNS is decided by its grants. const kubeProviderNS = "clusters" // localCluster is the KubeProvider a test GitTarget mirrors through when it names none. @@ -135,12 +135,12 @@ func claimsFor(objects []client.Object) []client.Object { } if ks, ok := target.Annotations[testKustomizationAnnotation]; ok { namespace, name, _ := strings.Cut(ks, "/") - // Its reconciler holds the Kustomization (Ask.Held), unless a test says otherwise. + // Its reconciler is in control of the Kustomization (Ask.InControl), unless a test says otherwise. claims = append(claims, &v1alpha3.FluxClaim{ObjectMeta: meta, Spec: v1alpha3.FluxClaimSpec{ KubeProviderRef: provider, KustomizationRef: fluxmeta.NamespacedObjectReference{Namespace: namespace, Name: name}, }, Status: v1alpha3.FluxClaimStatus{Conditions: []metav1.Condition{{ - Type: ApplierClaimConditionHeld, Status: metav1.ConditionTrue, Reason: "Suspended", + Type: ApplierClaimConditionInControl, Status: metav1.ConditionTrue, Reason: "Suspended", LastTransitionTime: metav1.Now(), }}}}) } @@ -154,16 +154,26 @@ func reapplying(target *v1alpha3.GitTarget) *v1alpha3.GitTarget { return target } -// kubeProvider is the KubeProvider name in kubeProviderNS, admitting the GitTargets in the named -// namespaces. +// kubeProvider is the KubeProvider name in kubeProviderNS, admitting the GitTargets and applier +// claims in the named namespaces. func kubeProvider(name string, admits ...string) *v1alpha3.KubeProvider { + names := make([]v1alpha3.GrantNamespace, 0, len(admits)) + for _, ns := range admits { + names = append(names, v1alpha3.GrantNamespace(ns)) + } return &v1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: kubeProviderNS, Name: name}, Spec: v1alpha3.KubeProviderSpec{ - InCluster: &v1alpha3.KubeProviderInCluster{}, - AccessFrom: &v1alpha3.NamespaceMatcher{Names: admits}, + InCluster: &v1alpha3.KubeProviderInCluster{}, + AllowedGitTargets: []v1alpha3.KubeProviderGitTargetGrant{{ + ConsumerNamespaces: v1alpha3.ConsumerNamespaces{From: v1alpha3.GrantFromNames, Names: names}, + SourceNamespaces: []v1alpha3.ScopeNamespace{v1alpha3.ConsumerNamespaceVariable}, + }}, // The test appliers live in flux-system and argocd, not in the applier claims' namespace. - AllowCrossNamespace: &v1alpha3.KubeProviderCrossNamespace{Appliers: true}, + AllowedApplierClaims: []v1alpha3.KubeProviderApplierClaimGrant{{ + ConsumerNamespaces: v1alpha3.ConsumerNamespaces{From: v1alpha3.GrantFromNames, Names: names}, + ApplierNamespaces: []v1alpha3.ScopeNamespace{"*"}, + }}, }, } } @@ -179,7 +189,11 @@ func controlPlane() []client.Object { &v1alpha3.GitBranch{ ObjectMeta: metav1.ObjectMeta{Namespace: testGitBranch.Namespace, Name: testGitBranch.Name}, Spec: v1alpha3.GitBranchSpec{ - GitProviderRef: fluxmeta.LocalObjectReference{Name: testProvider.Name}, Branch: testBranch, + GitProviderRef: fluxmeta.NamespacedObjectReference{Name: testProvider.Name}, + Branch: testBranch, + AllowedGitTargets: []v1alpha3.GitBranchGitTargetGrant{ + {ConsumerNamespaces: v1alpha3.ConsumerNamespaces{From: v1alpha3.GrantFromAll}}, + }, }, }, kubeProvider(localCluster, targetNS), @@ -465,7 +479,7 @@ func TestDispatcher_AsksAnApplierInItsOwnCluster(t *testing.T) { } // TestDispatcher_AnApplierClusterThatDoesNotAdmitAsksNothing: the applier's KubeProvider gates who -// may wield its credential exactly as a source's does, so a GitTarget its accessFrom does not admit +// may wield its credential exactly as a source's does, so a GitTarget its grants do not admit // asks nothing, though its own source admits it. func TestDispatcher_AnApplierClusterThatDoesNotAdmitAsksNothing(t *testing.T) { hub := kustomizationApplier("hub", fluxSystemNS, "checkout") @@ -859,11 +873,11 @@ func TestDispatcher_AStopMidApplySuspendsTheKustomization(t *testing.T) { f.sim.Release() } -// TestDispatcher_AnUnheldClaimIsNotResumed is review 4's F: the applier claim's reconciler suspends the +// TestDispatcher_AClaimNotInControlIsNotResumed is review 4's F: the applier claim's reconciler suspends the // Kustomization on its own reads (an adoption, an external resume), and a resume between its read // and its suspend would be suspended under the apply. So a request resumes only a Kustomization its -// applier claim reports Held=True, read uncached; any other is a failed attempt the worker retries. -func TestDispatcher_AnUnheldClaimIsNotResumed(t *testing.T) { +// applier claim reports InControl=True, read uncached; any other is a failed attempt the worker retries. +func TestDispatcher_AClaimNotInControlIsNotResumed(t *testing.T) { f := newDispatcherFixture(t, []client.Object{gitTarget("a", checkoutRef(), "")}, behindSource()...) claim := &v1alpha3.FluxClaim{} require.NoError(t, f.control.Get(context.Background(), client.ObjectKey{Namespace: targetNS, Name: "checkout"}, @@ -874,7 +888,7 @@ func TestDispatcher_AnUnheldClaimIsNotResumed(t *testing.T) { res := f.attempt(request(checkout, v1alpha3.ApplyReasonForeignCommit, "a")) assert.Equal(t, git.ApplyRequestFailed, res.Outcome) - require.ErrorContains(t, res.Err, "does not hold its Kustomization suspended: it is Held=False, Suspending") + require.ErrorContains(t, res.Err, "is not in control of its Kustomization: it is InControl=False, Suspending") for _, write := range f.patches() { assert.NotContains(t, write, "suspend=false", "nothing is resumed") } diff --git a/internal/applier/flux.go b/internal/applier/flux.go index 9fa3fd9c9..215f5e84f 100644 --- a/internal/applier/flux.go +++ b/internal/applier/flux.go @@ -265,8 +265,8 @@ func (f *Flux) mayResume( if !artifactHolds(source, ask.Commit) || !ask.holds() { return errStale } - if ask.Held != nil { - if err := ask.Held(ctx); err != nil { + if ask.InControl != nil { + if err := ask.InControl(ctx); err != nil { return fmt.Errorf("not resuming %s %s: %w", kindKustomization, ref, err) } } diff --git a/internal/applier/flux_consumers.go b/internal/applier/flux_applied_by.go similarity index 87% rename from internal/applier/flux_consumers.go rename to internal/applier/flux_applied_by.go index e72fb38fd..07e5e0603 100644 --- a/internal/applier/flux_consumers.go +++ b/internal/applier/flux_applied_by.go @@ -16,9 +16,10 @@ import ( "k8s.io/apimachinery/pkg/types" ) -// A GitRepository's consumers (docs/design/apply-trigger.md, "Shared sources"). A suspended -// Kustomization ignores a new artifact; one consumer that is not held applies each new artifact the -// moment it arrives, so a FluxClaim lists the ones no applier claim holds in its status. +// What else applies a GitRepository (docs/design/apply-trigger.md, "Shared sources"). A suspended +// Kustomization ignores a new artifact; an object applying the source that is not held applies each +// new artifact the moment it arrives, so a FluxClaim lists the ones no applier claim holds in its +// status.alsoAppliedBy. // A HelmRelease applies a GitRepository through a HelmChart that source-controller builds from it: // one helm-controller creates from the release's spec.chart, or one the release names in @@ -44,15 +45,15 @@ func helmChartGVR() schema.GroupVersionResource { func olderHelmReleaseVersions() []string { return []string{"v2beta2", "v2beta1"} } func olderHelmChartVersions() []string { return []string{"v1beta2"} } -// Consumers lists the objects in the cluster that apply the GitRepository source, sorted: +// AppliedBy lists the objects in the cluster that apply the GitRepository source, sorted: // Kustomizations whose spec.sourceRef names it, and HelmReleases whose chart is built from it, // through spec.chart.spec.sourceRef or through a HelmChart spec.chartRef names. Any failure is an // error, and then nothing is known: the caller must treat the source as shared. // // A kind the cluster does not serve has no objects. For HelmReleases that holds only when no // HelmChart is built from the source either: a release at a version not listed here would still -// apply through one, so then the consumers are unknown and that is an error. -func (f *Flux) Consumers( +// apply through one, so then what applies the source is unknown and that is an error. +func (f *Flux) AppliedBy( ctx context.Context, source types.NamespacedName, ) ([]fluxmeta.NamespacedObjectKindReference, error) { @@ -60,22 +61,22 @@ func (f *Flux) Consumers( if err != nil { return nil, err } - var consumers []fluxmeta.NamespacedObjectKindReference + var appliedBy []fluxmeta.NamespacedObjectKindReference for i := range kustomizations { if consumes(&kustomizations[i], []string{"spec", "sourceRef"}, source) { - consumers = append(consumers, kindReference(kindKustomization, &kustomizations[i])) + appliedBy = append(appliedBy, kindReference(kindKustomization, &kustomizations[i])) } } releases, err := f.helmReleasesOf(ctx, source) if err != nil { return nil, err } - consumers = append(consumers, releases...) - sort.Slice(consumers, func(i, j int) bool { - a, b := consumers[i], consumers[j] + appliedBy = append(appliedBy, releases...) + sort.Slice(appliedBy, func(i, j int) bool { + a, b := appliedBy[i], appliedBy[j] return a.Kind+"/"+a.Namespace+"/"+a.Name < b.Kind+"/"+b.Namespace+"/"+b.Name }) - return consumers, nil + return appliedBy, nil } // helmReleasesOf is the HelmReleases whose chart is built from the GitRepository source. @@ -98,19 +99,19 @@ func (f *Flux) helmReleasesOf( } return nil, nil } - var consumers []fluxmeta.NamespacedObjectKindReference + var appliedBy []fluxmeta.NamespacedObjectKindReference var viaChart []*unstructured.Unstructured for i := range releases { release := &releases[i] switch { case consumes(release, []string{"spec", "chart", "spec", "sourceRef"}, source): - consumers = append(consumers, kindReference(kindHelmRelease, release)) + appliedBy = append(appliedBy, kindReference(kindHelmRelease, release)) case namesHelmChart(release): viaChart = append(viaChart, release) } } if len(viaChart) == 0 { - return consumers, nil + return appliedBy, nil } charts, served, err := f.chartsBuiltFrom(ctx, source) if err != nil { @@ -122,10 +123,10 @@ func (f *Flux) helmReleasesOf( } for _, release := range viaChart { if charts[chartRefOf(release)] { - consumers = append(consumers, kindReference(kindHelmRelease, release)) + appliedBy = append(appliedBy, kindReference(kindHelmRelease, release)) } } - return consumers, nil + return appliedBy, nil } // chartsBuiltFrom is the HelmCharts whose spec.sourceRef names the GitRepository source; a diff --git a/internal/applier/flux_consumers_test.go b/internal/applier/flux_applied_by_test.go similarity index 87% rename from internal/applier/flux_consumers_test.go rename to internal/applier/flux_applied_by_test.go index 5bbe09617..11704fbf3 100644 --- a/internal/applier/flux_consumers_test.go +++ b/internal/applier/flux_applied_by_test.go @@ -38,9 +38,9 @@ func helmReleaseObj(namespace, name, sourceNamespace string) *unstructured.Unstr }} } -// TestFluxConsumers_ListsEveryObjectApplyingTheSource: Kustomizations and HelmReleases, their source +// TestFluxAppliedBy_ListsEveryObjectApplyingTheSource: Kustomizations and HelmReleases, their source // namespace defaulted to their own, and nothing that applies another source. -func TestFluxConsumers_ListsEveryObjectApplyingTheSource(t *testing.T) { +func TestFluxAppliedBy_ListsEveryObjectApplyingTheSource(t *testing.T) { client := newSourceClient( kustomizationObj(fluxSystemNS, "checkout", gitRepoSourceRef("platform")), kustomizationObj("apps", "elsewhere", gitRepoSourceRef("platform")), @@ -51,14 +51,14 @@ func TestFluxConsumers_ListsEveryObjectApplyingTheSource(t *testing.T) { helmReleaseObj("apps", "podinfo", fluxSystemNS), helmReleaseObj(fluxSystemNS, "local", ""), ) - consumers, err := pinnedFlux(client).Consumers(context.Background(), platformKey) + appliedBy, err := pinnedFlux(client).AppliedBy(context.Background(), platformKey) require.NoError(t, err) assert.Equal(t, []fluxmeta.NamespacedObjectKindReference{ {Kind: kindHelmRelease, Namespace: "apps", Name: "podinfo"}, {Kind: kindHelmRelease, Namespace: fluxSystemNS, Name: "local"}, {Kind: kindKustomization, Namespace: "apps", Name: "across"}, {Kind: kindKustomization, Namespace: fluxSystemNS, Name: "checkout"}, - }, consumers) + }, appliedBy) } // helmChartObj is a HelmChart built from the GitRepository sourceName in its own namespace. @@ -117,9 +117,9 @@ func everyVersion(gvr schema.GroupVersionResource, older []string) []schema.Grou return gvrs } -// TestFluxConsumers_AHelmReleaseThroughAHelmChart is the fourth review's finding J: a HelmRelease +// TestFluxAppliedBy_AHelmReleaseThroughAHelmChart is the fourth review's finding J: a HelmRelease // whose spec.chartRef names a HelmChart built from the source applies it too. -func TestFluxConsumers_AHelmReleaseThroughAHelmChart(t *testing.T) { +func TestFluxAppliedBy_AHelmReleaseThroughAHelmChart(t *testing.T) { client := helmClient(nil, helmChartObj(fluxSystemNS, "podinfo", "platform"), helmChartObj(fluxSystemNS, "elsewhere", "another"), @@ -129,50 +129,50 @@ func TestFluxConsumers_AHelmReleaseThroughAHelmChart(t *testing.T) { chartRefRelease("apps", "missing-chart", kindHelmChart, "podinfo", ""), chartRefRelease("apps", "oci", "OCIRepository", "podinfo", fluxSystemNS), ) - consumers, err := pinnedFlux(client).Consumers(context.Background(), platformKey) + appliedBy, err := pinnedFlux(client).AppliedBy(context.Background(), platformKey) require.NoError(t, err) assert.Equal(t, []fluxmeta.NamespacedObjectKindReference{ {Kind: kindHelmRelease, Namespace: "apps", Name: "podinfo"}, {Kind: kindHelmRelease, Namespace: fluxSystemNS, Name: "local"}, - }, consumers) + }, appliedBy) } -// TestFluxConsumers_AClusterServingOnlyAnOlderHelmRelease: a Flux that serves HelmReleases only at +// TestFluxAppliedBy_AClusterServingOnlyAnOlderHelmRelease: a Flux that serves HelmReleases only at // v2beta2 has them listed there, not read as having none. -func TestFluxConsumers_AClusterServingOnlyAnOlderHelmRelease(t *testing.T) { +func TestFluxAppliedBy_AClusterServingOnlyAnOlderHelmRelease(t *testing.T) { release := helmReleaseObj("apps", "podinfo", fluxSystemNS) release.SetAPIVersion("helm.toolkit.fluxcd.io/v2beta2") client := helmClient([]schema.GroupVersionResource{helmReleaseGVR()}, release) - consumers, err := pinnedFlux(client).Consumers(context.Background(), platformKey) + appliedBy, err := pinnedFlux(client).AppliedBy(context.Background(), platformKey) require.NoError(t, err) assert.Equal(t, []fluxmeta.NamespacedObjectKindReference{ {Kind: kindHelmRelease, Namespace: "apps", Name: "podinfo"}, - }, consumers) + }, appliedBy) } -// TestFluxConsumers_NoHelmReleaseAPIIsNoneOnlyWithoutAChart: a cluster that serves HelmReleases at +// TestFluxAppliedBy_NoHelmReleaseAPIIsNoneOnlyWithoutAChart: a cluster that serves HelmReleases at // no version has none, unless a HelmChart is built from the source: then a release at some other -// version may apply it, and the consumers are unknown. -func TestFluxConsumers_NoHelmReleaseAPIIsNoneOnlyWithoutAChart(t *testing.T) { +// version may apply it, and the appliedBy are unknown. +func TestFluxAppliedBy_NoHelmReleaseAPIIsNoneOnlyWithoutAChart(t *testing.T) { unserved := everyVersion(helmReleaseGVR(), olderHelmReleaseVersions()) ks := kustomizationObj(fluxSystemNS, "checkout", gitRepoSourceRef("platform")) - consumers, err := pinnedFlux(helmClient(unserved, ks)).Consumers(context.Background(), platformKey) + appliedBy, err := pinnedFlux(helmClient(unserved, ks)).AppliedBy(context.Background(), platformKey) require.NoError(t, err, "no Helm at all: none") assert.Equal(t, []fluxmeta.NamespacedObjectKindReference{ {Kind: kindKustomization, Namespace: fluxSystemNS, Name: "checkout"}, - }, consumers) + }, appliedBy) _, err = pinnedFlux(helmClient(unserved, ks, helmChartObj(fluxSystemNS, "podinfo", "platform"))). - Consumers(context.Background(), platformKey) + AppliedBy(context.Background(), platformKey) require.Error(t, err, "a chart is built from the source, by a release that cannot be listed") } -// TestFluxProbe_ASharedSourceAppliesOnlyTheConsumerAsked is the third review's third finding: A and +// TestFluxProbe_ASharedSourceAppliesOnlyTheKustomizationAsked is the third review's third finding: A and // B share a GitRepository, and A's request fetched it, which woke B too, outside any window. With // both suspended, A's fetch and apply leave B alone: an edit to B's objects keeps its value. -func TestFluxProbe_ASharedSourceAppliesOnlyTheConsumerAsked(t *testing.T) { +func TestFluxProbe_ASharedSourceAppliesOnlyTheKustomizationAsked(t *testing.T) { a := fluxsim.OwnSuspend(t, kustomizationObj(fluxSystemNS, "checkout", gitRepoSourceRef("platform")), true, SuspendFieldManager, metav1.ManagedFieldsOperationApply) b := fluxsim.OwnSuspend(t, kustomizationObj(fluxSystemNS, "infra", gitRepoSourceRef("platform")), true, diff --git a/internal/applier/holder_test.go b/internal/applier/holder_test.go index 8afd21456..a5ed24ab0 100644 --- a/internal/applier/holder_test.go +++ b/internal/applier/holder_test.go @@ -61,7 +61,15 @@ func referenceFrom(t *testing.T, c client.Client, claim v1alpha3.ApplierClaim) { require.NoError(t, c.Create(ctx, kubeProvider(name, targetNS, claim.GetNamespace()))) continue } - provider.Spec.AccessFrom.Names = append(provider.Spec.AccessFrom.Names, claim.GetNamespace()) + ns := v1alpha3.GrantNamespace(claim.GetNamespace()) + provider.Spec.AllowedGitTargets[0].ConsumerNamespaces.Names = append( + provider.Spec.AllowedGitTargets[0].ConsumerNamespaces.Names, + ns, + ) + provider.Spec.AllowedApplierClaims[0].ConsumerNamespaces.Names = append( + provider.Spec.AllowedApplierClaims[0].ConsumerNamespaces.Names, + ns, + ) require.NoError(t, c.Update(ctx, provider)) } } diff --git a/internal/authz/gitbranch_admission_test.go b/internal/authz/gitbranch_admission_test.go index 56ab10a1c..b383e0f37 100644 --- a/internal/authz/gitbranch_admission_test.go +++ b/internal/authz/gitbranch_admission_test.go @@ -4,32 +4,32 @@ package authz import ( "context" + "errors" "testing" "github.com/fluxcd/pkg/apis/meta" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" ) -// sharedGitProvider is the GitProvider platform/monorepo, with policy as its spec.accessFrom. -func sharedGitProvider(policy *configv1alpha3.NamespaceMatcher) *configv1alpha3.GitProvider { - return &configv1alpha3.GitProvider{ - ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "monorepo"}, - Spec: configv1alpha3.GitProviderSpec{URL: "https://github.com/acme/platform", AccessFrom: policy}, +// branchIn is the GitBranch namespace/main, with one spec.allowedGitTargets grant per writers entry. +func branchIn(namespace string, writers ...configv1alpha3.ConsumerNamespaces) *configv1alpha3.GitBranch { + grants := make([]configv1alpha3.GitBranchGitTargetGrant, 0, len(writers)) + for _, w := range writers { + grants = append(grants, configv1alpha3.GitBranchGitTargetGrant{ConsumerNamespaces: w}) } -} - -// sharedGitBranch is the GitBranch platform/main, written with the GitProvider platform/provider. -func sharedGitBranch(provider string) *configv1alpha3.GitBranch { return &configv1alpha3.GitBranch{ - ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "main"}, + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "main"}, Spec: configv1alpha3.GitBranchSpec{ - GitProviderRef: meta.LocalObjectReference{Name: provider}, Branch: "main", + GitProviderRef: meta.NamespacedObjectReference{Name: "monorepo"}, Branch: "main", + AllowedGitTargets: grants, }, } } @@ -42,79 +42,69 @@ func targetWriting(namespace, name string) *configv1alpha3.GitTarget { return target } -// TestGitTargetAdmitted_GitBranchsProviderAccessFrom: a GitTarget writes with its GitBranch's -// GitProvider credential, so a GitBranch in another namespace is used only while that GitProvider's -// spec.accessFrom admits the GitTarget's namespace. A GitBranch in the GitTarget's own namespace -// needs no policy. -func TestGitTargetAdmitted_GitBranchsProviderAccessFrom(t *testing.T) { - // The KubeProvider gate passes in every case, so only the GitProvider's decides. - source := providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{Names: []string{testNS}}) +// TestGitTargetAdmitted_GitBranchAllowedGitTargets: a GitTarget writes through a GitBranch only while +// one of that GitBranch's spec.allowedGitTargets admits its namespace, the GitBranch's own namespace +// included. +func TestGitTargetAdmitted_GitBranchAllowedGitTargets(t *testing.T) { + // The KubeProvider gate passes in every case, so only the GitBranch's decides. + source := providerNamed("prod-eu-1", mirrorFrom(testNS)) + same := configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromSame} tests := []struct { - name string - objects []client.Object - target *configv1alpha3.GitTarget - allowed bool + name string + objects []client.Object + target *configv1alpha3.GitTarget + allowed bool + wantMessage string }{ { - name: "its own namespace, with no policy at all", - objects: []client.Object{source}, + name: "its own namespace, with no grant at all", + objects: []client.Object{source, branchIn(testNS)}, target: targetWriting("", "main"), - allowed: true, + wantMessage: `GitBranch team-a/main admits no namespace in spec.allowedGitTargets; namespace "team-a" ` + + "is not admitted", }, { - name: "its own namespace, named explicitly", - objects: []client.Object{source}, + name: "its own namespace, admitted by from: Same", + objects: []client.Object{source, branchIn(testNS, same)}, target: targetWriting(testNS, "main"), allowed: true, }, { - name: "another namespace, with no policy", - objects: []client.Object{ - source, - sharedGitProvider(nil), - sharedGitBranch("monorepo"), - namespaceLabeled(nil), - }, - target: targetWriting("platform", "main"), + name: "another namespace, not admitted by from: Same", + objects: []client.Object{source, namespaceLabeled(nil), branchIn("platform", same)}, + target: targetWriting("platform", "main"), + wantMessage: `GitBranch platform/main admits platform in spec.allowedGitTargets; namespace "team-a"`, }, { name: "another namespace, admitted by name", - objects: []client.Object{source, namespaceLabeled(nil), sharedGitBranch("monorepo"), - sharedGitProvider(&configv1alpha3.NamespaceMatcher{Names: []string{testNS}})}, + objects: []client.Object{source, namespaceLabeled(nil), branchIn("platform", same, + configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromNames, + Names: []configv1alpha3.GrantNamespace{testNS}})}, target: targetWriting("platform", "main"), allowed: true, }, { name: "another namespace, admitted by its labels", - objects: []client.Object{ - source, - namespaceLabeled(map[string]string{"tenant": "yes"}), - sharedGitBranch("monorepo"), - sharedGitProvider(&configv1alpha3.NamespaceMatcher{ - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tenant": "yes"}}, - }), - }, + objects: []client.Object{source, namespaceLabeled(map[string]string{"tenant": "yes"}), + branchIn("platform", configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tenant": "yes"}}})}, target: targetWriting("platform", "main"), allowed: true, }, { - name: "another namespace, not admitted", - objects: []client.Object{source, namespaceLabeled(nil), sharedGitBranch("monorepo"), - sharedGitProvider(&configv1alpha3.NamespaceMatcher{Names: []string{"team-b"}})}, - target: targetWriting("platform", "main"), + name: "a grant naming other namespaces, listed in the message", + objects: []client.Object{source, namespaceLabeled(nil), branchIn("platform", same, + configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromNames, + Names: []configv1alpha3.GrantNamespace{"team-b", "team-c"}})}, + target: targetWriting("platform", "main"), + wantMessage: "GitBranch platform/main admits platform, team-b, team-c in spec.allowedGitTargets", }, { - name: "another namespace's GitBranch that does not exist wields nothing", + name: "a GitBranch that does not exist lends nothing, and is reported elsewhere", objects: []client.Object{source}, target: targetWriting("platform", "missing"), allowed: true, }, - { - name: "another namespace's GitBranch whose GitProvider does not exist wields nothing", - objects: []client.Object{source, sharedGitBranch("missing")}, - target: targetWriting("platform", "main"), - allowed: true, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -126,9 +116,107 @@ func TestGitTargetAdmitted_GitBranchsProviderAccessFrom(t *testing.T) { assert.Equal(t, tt.allowed, decision.Allowed, decision.Message) if !tt.allowed { assert.Equal(t, ReasonNamespaceNotAuthorized, decision.Reason) - assert.Contains(t, decision.Message, "GitBranch platform/main") - assert.Contains(t, decision.Message, "GitProvider platform/monorepo") + assert.Contains(t, decision.Message, tt.wantMessage) } }) } } + +// sharedRepo is the GitProvider platform/shared-repo with grants as its spec.allowedGitBranches. +func sharedRepo(grants ...configv1alpha3.GitProviderGitBranchGrant) *configv1alpha3.GitProvider { + return &configv1alpha3.GitProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: platformNS, Name: "shared-repo"}, + Spec: configv1alpha3.GitProviderSpec{ + URL: "https://git.example.com/platform/config.git", + AllowedGitBranches: grants, + }, + } +} + +// claiming is a GitBranch in namespace claiming branch through platform/shared-repo. +func claiming(namespace, branch string) *configv1alpha3.GitBranch { + return &configv1alpha3.GitBranch{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "live"}, + Spec: configv1alpha3.GitBranchSpec{ + GitProviderRef: meta.NamespacedObjectReference{Name: "shared-repo", Namespace: platformNS}, + Branch: branch, + }, + } +} + +// TestGitBranchClaimAdmitted is the walkthrough of docs/finished/provider-access-rules.md: tenants may +// claim their own prefix, and the platform keeps main. +func TestGitBranchClaimAdmitted(t *testing.T) { + tenant := map[string]string{"platform.example.com/tenant": "true"} + provider := sharedRepo( + configv1alpha3.GitProviderGitBranchGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromSame}, + BranchPatterns: []configv1alpha3.BranchPattern{"main"}, + }, + configv1alpha3.GitProviderGitBranchGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{ + From: configv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: tenant}, + }, + BranchPatterns: []configv1alpha3.BranchPattern{"teams/{consumer.namespace}/*", "feature/*"}, + }, + ) + namespaces := []client.Object{ + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: testNS, Labels: tenant}}, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-c"}}, + } + tests := []struct { + name string + branch *configv1alpha3.GitBranch + provider *configv1alpha3.GitProvider + wantReason string + wantMessage string + }{ + {"a tenant claims its own prefix", claiming(testNS, "teams/team-a/live"), provider, "", ""}, + {"a tenant claims a feature branch", claiming(testNS, "feature/checkout/v2"), provider, "", ""}, + {"a tenant may not claim main", claiming(testNS, "main"), provider, ReasonBranchNotAllowed, + `namespace "team-a" may claim "feature/*", "teams/team-a/*" through GitProvider platform/shared-repo ` + + `(spec.allowedGitBranches); requested branch "main" is not allowed`}, + {"a tenant may not claim another team's branch", claiming(testNS, "teams/team-b/live"), provider, + ReasonBranchNotAllowed, `requested branch "teams/team-b/live" is not allowed`}, + { + "{consumer.namespace} is a whole segment: team-a never matches team-ab", + claiming(testNS, "teams/team-ab/live"), + provider, + ReasonBranchNotAllowed, + "", + }, + {"an unlabelled namespace is not admitted at all", claiming("team-c", "teams/team-c/live"), provider, + ReasonNamespaceNotAuthorized, `GitProvider platform/shared-repo admits platform, namespaces labelled ` + + `platform.example.com/tenant=true in spec.allowedGitBranches; namespace "team-c" is not admitted`}, + {"the platform claims main through from: Same", claiming(platformNS, "main"), provider, "", ""}, + {"the provider's own namespace needs a grant too", claiming(platformNS, "main"), sharedRepo(), + ReasonNamespaceNotAuthorized, "admits no namespace in spec.allowedGitBranches"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader := fake.NewClientBuilder().WithScheme(admissionScheme(t)).WithObjects(namespaces...).Build() + decision, err := GitBranchClaimAdmitted(context.Background(), reader, tt.branch, tt.provider) + require.NoError(t, err) + assert.Equal(t, tt.wantReason == "", decision.Allowed, decision.Message) + assert.Equal(t, tt.wantReason, decision.Reason) + assert.Contains(t, decision.Message, tt.wantMessage) + }) + } +} + +// TestGitBranchClaimAdmitted_ReadErrorIsNoRevocation: a transient read keeps the caller's last +// answer; it must never come back as a denial. +func TestGitBranchClaimAdmitted_ReadErrorIsNoRevocation(t *testing.T) { + boom := errors.New("apiserver unavailable") + reader := fake.NewClientBuilder().WithScheme(admissionScheme(t)). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func(context.Context, client.WithWatch, client.ObjectKey, client.Object, ...client.GetOption) error { + return boom + }, + }).Build() + decision, err := GitBranchClaimAdmitted(context.Background(), reader, claiming(testNS, "main"), sharedRepo()) + require.ErrorIs(t, err, boom) + assert.False(t, decision.Allowed) + assert.Empty(t, decision.Reason) +} diff --git a/internal/authz/kubeprovider_admission.go b/internal/authz/kubeprovider_admission.go index bea6959f3..e00dba11b 100644 --- a/internal/authz/kubeprovider_admission.go +++ b/internal/authz/kubeprovider_admission.go @@ -1,10 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 -// Package authz holds the namespace-admission decisions for the providers a GitTarget uses. -// -// A KubeProvider holds a credential that can read a lot of a source cluster; it admits its own -// namespace, and its spec.accessFrom admits the OTHER control-cluster namespaces permitted to use -// it. A GitProvider holds a write credential, and admits namespaces the same way. +// Package authz holds the admission decisions for the objects that lend something: a GitProvider's +// credential (spec.allowedGitBranches), a GitBranch (spec.allowedGitTargets), and a KubeProvider's +// cluster connection (spec.allowedGitTargets, spec.allowedApplierClaims). Each grant list lives on +// the referenced object, and nothing is admitted automatically, the object's own namespace included. // // The decision lives in its own package — outside both internal/controller and internal/watch — // because more than one call site compiles rules or starts a data plane for a GitTarget: the @@ -16,6 +15,8 @@ package authz import ( "context" "fmt" + "strconv" + "strings" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -32,14 +33,17 @@ const ( // is denied rather than mirroring on an implicit identity. ReasonKubeProviderNotFound = "KubeProviderNotFound" - // ReasonNamespaceNotAuthorized is the denial reason when the consumer's namespace is neither the - // KubeProvider's own nor admitted by its spec.accessFrom — including the case where that policy - // carries a selector the apiserver accepted but that does not convert to a selector. + // ReasonNamespaceNotAuthorized is the denial reason when no grant on the referenced object admits + // the referring object's namespace — including a grant whose selector the apiserver accepted but + // that does not convert to a selector. ReasonNamespaceNotAuthorized = "NamespaceNotAuthorized" + // ReasonBranchNotAllowed is the denial reason when a GitProvider admits a GitBranch's namespace, + // but none of the grants that admit it allows the branch the GitBranch claims. + ReasonBranchNotAllowed = "BranchNotAllowed" + // ReasonApplierNamespaceNotAllowed is the denial reason when an applier claim names an applier in - // another namespace than its own and its KubeProvider does not set - // spec.allowCrossNamespace.appliers. + // a namespace outside the applierNamespaces its KubeProvider grants it. ReasonApplierNamespaceNotAllowed = "ApplierNamespaceNotAllowed" // ReasonAuditRouteConflict is the denial reason when a KubeProvider's audit route is held by a @@ -59,9 +63,10 @@ type Decision struct { Message string } -// GitTargetAdmitted reports whether target's namespace is admitted by the KubeProvider that -// target references, reading both the provider and the target's Namespace through reader, and by -// the GitProvider of the GitBranch it writes through (gitBranchAdmitted). +// GitTargetAdmitted reports whether target may mirror through the KubeProvider it references +// (spec.allowedGitTargets) and write through the GitBranch it references (that GitBranch's +// spec.allowedGitTargets), reading the provider, the GitBranch and the target's Namespace through +// reader. // // It is evaluated on every reconcile rather than only at admission, so a policy TIGHTENED after a // GitTarget was created revokes it too. The two denial paths are deliberate and ordered: a missing @@ -70,36 +75,62 @@ type Decision struct { // // A non-NotFound read error is returned as err so the caller requeues instead of tearing down a // running data plane on a transient apiserver failure. A NotFound Namespace is NOT an error: the -// policy is then evaluated against empty labels, so a `names` entry still admits a namespace that -// has not been created yet while a selector correctly does not match it. +// policy is then evaluated against empty labels, so a Names grant still admits a namespace that has +// not been created yet while a selector correctly does not match it. func GitTargetAdmitted( ctx context.Context, reader client.Reader, target *configv1alpha3.GitTarget, ) (Decision, error) { + decision, _, err := GitTargetSourceScope(ctx, reader, target) + return decision, err +} + +// GitTargetSourceScope is GitTargetAdmitted, plus what the grants that admit target let it mirror: +// the source namespaces its WatchRules may watch and the cluster-scoped resources a ClusterWatchRule +// may mirror through it. The scope is meaningful only when the decision allows. +func GitTargetSourceScope( + ctx context.Context, + reader client.Reader, + target *configv1alpha3.GitTarget, +) (Decision, configv1alpha3.KubeProviderGitTargetScope, error) { key := target.KubeProviderKey() - decision, _, err := namespaceAdmitted(ctx, reader, target.Namespace, key, fmt.Sprintf( + decision, provider, err := kubeProviderUsable(ctx, reader, key, fmt.Sprintf( "referenced KubeProvider %s was not found; a GitTarget may mirror a source cluster only "+ "through an existing KubeProvider, which the operator never creates", key)) if err != nil || !decision.Allowed { - return decision, err + return decision, configv1alpha3.KubeProviderGitTargetScope{}, err + } + nsLabels, err := namespaceLabels(ctx, reader, target.Namespace) + if err != nil { + return Decision{}, configv1alpha3.KubeProviderGitTargetScope{}, err + } + scope, selErr := provider.AdmitsGitTarget(client.ObjectKeyFromObject(target), nsLabels) + if !scope.Admitted { + return notAdmitted("KubeProvider", key, provider.DescribeAllowedGitTargets(), "spec.allowedGitTargets", + target.Namespace, selErr), configv1alpha3.KubeProviderGitTargetScope{}, nil + } + if decision, err = AuditRouteHeld(ctx, reader, provider); err != nil || !decision.Allowed { + return decision, configv1alpha3.KubeProviderGitTargetScope{}, err } - return gitBranchAdmitted(ctx, reader, target) + if decision, err = gitBranchAdmitted(ctx, reader, target, nsLabels); err != nil || !decision.Allowed { + return decision, configv1alpha3.KubeProviderGitTargetScope{}, err + } + return Decision{Allowed: true}, scope, nil } -// gitBranchAdmitted reports whether target's namespace may write through the GitBranch it -// references. One in its own namespace always may. One in another namespace may only while the -// GitBranch's GitProvider admits target's namespace in its spec.accessFrom, because the GitTarget -// then writes with that provider's credential: one policy governs both hops, so it cannot be split -// across two objects that disagree. A GitBranch or GitProvider that does not exist admits nothing -// and denies nothing here: there is no credential to wield, and their status reports them missing. +// gitBranchAdmitted reports whether target may write through the GitBranch it references: one of +// that GitBranch's spec.allowedGitTargets admits target's namespace. The GitBranch's own namespace +// is admitted only by a grant too. A GitBranch that does not exist admits nothing and denies nothing +// here: there is nothing to write through, and the GitTarget's status reports it missing. func gitBranchAdmitted( ctx context.Context, reader client.Reader, target *configv1alpha3.GitTarget, + nsLabels map[string]string, ) (Decision, error) { key := target.GitBranchKey() - if key.Name == "" || key.Namespace == target.Namespace { + if key.Name == "" { return Decision{Allowed: true}, nil } branch := &configv1alpha3.GitBranch{} @@ -109,72 +140,94 @@ func gitBranchAdmitted( } return Decision{}, fmt.Errorf("read GitBranch %s: %w", key, err) } - providerKey := branch.GitProviderKey() - provider := &configv1alpha3.GitProvider{} - if err := reader.Get(ctx, providerKey, provider); err != nil { - if apierrors.IsNotFound(err) { - return Decision{Allowed: true}, nil - } - return Decision{}, fmt.Errorf("read GitProvider %s: %w", providerKey, err) + admitted, selErr := branch.AdmitsGitTarget(target.Namespace, nsLabels) + if !admitted { + return notAdmitted("GitBranch", key, branch.DescribeAllowedGitTargets(), "spec.allowedGitTargets", + target.Namespace, selErr), nil } - nsLabels, err := namespaceLabels(ctx, reader, target.Namespace) + return Decision{Allowed: true}, nil +} + +// GitBranchClaimAdmitted reports whether branch may claim its spec.branch through provider, the +// GitProvider it references: a grant in provider's spec.allowedGitBranches admits the GitBranch's +// namespace, and one of the patterns those grants give it matches the branch. A namespace no grant +// admits is ReasonNamespaceNotAuthorized; an admitted one asking for a branch outside its patterns +// is ReasonBranchNotAllowed. A non-NotFound read of the Namespace is returned as err, so a caller +// keeps its last answer rather than revoking on a transient failure. +func GitBranchClaimAdmitted( + ctx context.Context, + reader client.Reader, + branch *configv1alpha3.GitBranch, + provider *configv1alpha3.GitProvider, +) (Decision, error) { + nsLabels, err := namespaceLabels(ctx, reader, branch.Namespace) if err != nil { return Decision{}, err } - allowed, selErr := provider.AdmitsNamespace(target.Namespace, nsLabels) - if selErr != nil { - return Decision{ - Reason: ReasonNamespaceNotAuthorized, - Message: fmt.Sprintf("GitProvider %s accessFrom selector is invalid: %v", providerKey, selErr), - }, nil - } - if !allowed { + key := client.ObjectKeyFromObject(provider) + admission, selErr := provider.AdmitsGitBranch(client.ObjectKeyFromObject(branch), nsLabels, branch.Spec.Branch) + switch { + case !admission.Admitted: + return notAdmitted("GitProvider", key, provider.DescribeAllowedGitBranches(), "spec.allowedGitBranches", + branch.Namespace, selErr), nil + case !admission.Allowed: return Decision{ - Reason: ReasonNamespaceNotAuthorized, + Reason: ReasonBranchNotAllowed, Message: fmt.Sprintf( - "namespace %q is not permitted to reference GitBranch %s: its GitProvider %s does not admit it "+ - "(spec.accessFrom)", target.Namespace, key, providerKey), + "namespace %q may claim %s through GitProvider %s (spec.allowedGitBranches); requested branch %q "+ + "is not allowed", branch.Namespace, quoteAll(admission.BranchPatterns), key, branch.Spec.Branch), }, nil } return Decision{Allowed: true}, nil } -// ApplierClaimAdmitted reports whether an applier claim's namespace is admitted by the KubeProvider it -// names (spec.kubeProviderRef), and whether it may reach its applier there. The applier is -// reached with that provider's credential, so the provider's accessFrom decides who may wield it, -// exactly as for mirroring, and its allowCrossNamespace.appliers whether a namespace may suspend -// and apply another namespace's applier. The applier claim's own namespace is always allowed; in a remote -// cluster that is the namespace of the same name, as it is for mirroring. +// ApplierClaimAdmitted reports whether an applier claim may hold its applier through the KubeProvider +// it names (spec.kubeProviderRef): a grant in that provider's spec.allowedApplierClaims admits the +// applier claim's namespace, and the applier lives in one of the applierNamespaces those grants +// give it. The applier is reached with that provider's credential, so admission to mirror through +// it is no admission to apply. func ApplierClaimAdmitted( ctx context.Context, reader client.Reader, claim configv1alpha3.ApplierClaim, ) (Decision, error) { key := claim.KubeProviderKey() - decision, provider, err := namespaceAdmitted(ctx, reader, claim.GetNamespace(), key, fmt.Sprintf( + decision, provider, err := kubeProviderUsable(ctx, reader, key, fmt.Sprintf( "KubeProvider %s named by %s %s/%s spec.kubeProviderRef was not found", key, claim.ClaimKind(), claim.GetNamespace(), claim.GetName())) if err != nil || !decision.Allowed { return decision, err } + nsLabels, err := namespaceLabels(ctx, reader, claim.GetNamespace()) + if err != nil { + return Decision{}, err + } + scope, selErr := provider.AdmitsApplierClaim(client.ObjectKeyFromObject(claim), nsLabels) + if !scope.Admitted { + return notAdmitted("KubeProvider", key, provider.DescribeAllowedApplierClaims(), "spec.allowedApplierClaims", + claim.GetNamespace(), selErr), nil + } + if decision, err = AuditRouteHeld(ctx, reader, provider); err != nil || !decision.Allowed { + return decision, err + } applier := claim.Applier() - if applier.Namespace != claim.GetNamespace() && !provider.AllowsCrossNamespaceAppliers() { + if !scope.AllowsApplierNamespace(applier.Namespace) { return Decision{ Reason: ReasonApplierNamespaceNotAllowed, Message: fmt.Sprintf( - "%s %s is in another namespace than the applier claim's own %q, and KubeProvider %s does not "+ - "set spec.allowCrossNamespace.appliers", claim.ApplierKind(), applier, claim.GetNamespace(), - key), + "KubeProvider %s lets namespace %q hold appliers in %s (spec.allowedApplierClaims); %s %s is in "+ + "namespace %q, which is not allowed", key, claim.GetNamespace(), + quoteAll(scope.ApplierNamespaces), claim.ApplierKind(), applier, applier.Namespace), }, nil } - return decision, nil + return Decision{Allowed: true}, nil } -// ApplierAdmitted reports whether target may ask the applier its applier claim holds to apply: its -// namespace is admitted by its own KubeProvider (GitTargetAdmitted) and by the applier claim's -// (ApplierClaimAdmitted). A GitTarget names an applier claim in its own namespace only, so the second -// answers for the GitTarget's namespace too. A KubeProvider's client is shared by every object -// that uses the cluster, so a client being available is no permission to use it. +// ApplierAdmitted reports whether target may ask the applier its applier claim holds to apply: +// target may mirror (GitTargetAdmitted), and the applier claim may hold its applier +// (ApplierClaimAdmitted). Both are always checked, also when the two name one KubeProvider from one +// namespace: mirroring and applying are granted separately. A KubeProvider's client is shared by +// every object that uses the cluster, so a client being available is no permission to use it. func ApplierAdmitted( ctx context.Context, reader client.Reader, @@ -185,19 +238,14 @@ func ApplierAdmitted( if err != nil || !decision.Allowed { return decision, err } - if claim.KubeProviderKey() == target.KubeProviderKey() && claim.GetNamespace() == target.Namespace { - return decision, nil - } return ApplierClaimAdmitted(ctx, reader, claim) } -// namespaceAdmitted is the check both admissions share: the provider exists, it admits namespace -// (its own, or through spec.accessFrom), and it holds its audit route. notFound is the denial -// message for a provider that does not exist. It returns the provider it read once it exists. -func namespaceAdmitted( +// kubeProviderUsable reads the KubeProvider at key. notFound is the denial message for a provider +// that does not exist. It returns the provider it read once it exists. +func kubeProviderUsable( ctx context.Context, reader client.Reader, - namespace string, key k8stypes.NamespacedName, notFound string, ) (Decision, *configv1alpha3.KubeProvider, error) { @@ -214,33 +262,31 @@ func namespaceAdmitted( } return Decision{}, nil, fmt.Errorf("read KubeProvider %s: %w", key, err) } + return Decision{Allowed: true}, provider, nil +} - nsLabels, err := namespaceLabels(ctx, reader, namespace) - if err != nil { - return Decision{}, nil, err - } - - allowed, selErr := provider.AdmitsNamespace(namespace, nsLabels) +// notAdmitted is the NamespaceNotAuthorized refusal of a grant list: what it admits, the field, and +// the namespace that asked. selErr is a grant whose selector does not convert, which admitted nobody. +func notAdmitted( + kind string, key k8stypes.NamespacedName, admits, field, namespace string, selErr error, +) Decision { + message := fmt.Sprintf("%s %s %s in %s; namespace %q is not admitted", kind, key, admits, field, namespace) if selErr != nil { - return Decision{ - Reason: ReasonNamespaceNotAuthorized, - Message: fmt.Sprintf("KubeProvider %s accessFrom selector is invalid: %v", key, selErr), - }, provider, nil - } - if !allowed { - return Decision{ - Reason: ReasonNamespaceNotAuthorized, - Message: fmt.Sprintf( - "namespace %q is not permitted to reference KubeProvider %s: it is not the provider's own "+ - "namespace, and spec.accessFrom does not admit it", namespace, key), - }, provider, nil + message += fmt.Sprintf(" (a grant's selector is invalid: %v)", selErr) } + return Decision{Reason: ReasonNamespaceNotAuthorized, Message: message} +} - routeDecision, err := AuditRouteHeld(ctx, reader, provider) - if err != nil || !routeDecision.Allowed { - return routeDecision, provider, err +// quoteAll renders a scope as quoted, comma-separated values, or "nothing" when it is empty. +func quoteAll(values []string) string { + if len(values) == 0 { + return "nothing" } - return Decision{Allowed: true}, provider, nil + quoted := make([]string, 0, len(values)) + for _, value := range values { + quoted = append(quoted, strconv.Quote(value)) + } + return strings.Join(quoted, ", ") } // AuditRouteHeld reports whether provider's namespace holds the audit route provider carries @@ -277,7 +323,7 @@ func AuditRouteHeld( } // namespaceLabels reads a control-cluster Namespace's labels. A Namespace that does not exist has -// none: a names entry still admits it, and a selector does not. +// none: a Names grant still admits it, and a selector does not. func namespaceLabels(ctx context.Context, reader client.Reader, namespace string) (map[string]string, error) { var ns corev1.Namespace if err := reader.Get(ctx, k8stypes.NamespacedName{Name: namespace}, &ns); err != nil { diff --git a/internal/authz/kubeprovider_admission_test.go b/internal/authz/kubeprovider_admission_test.go index c326ce666..7e6dca474 100644 --- a/internal/authz/kubeprovider_admission_test.go +++ b/internal/authz/kubeprovider_admission_test.go @@ -47,12 +47,32 @@ func targetIn(providerName string) *configv1alpha3.GitTarget { return t } -// providerNamed builds a KubeProvider in platformNS, so the GitTarget in testNS is admitted only -// through policy. -func providerNamed(name string, policy *configv1alpha3.NamespaceMatcher) *configv1alpha3.KubeProvider { +// providerNamed builds a KubeProvider in platformNS with grants as its spec.allowedGitTargets, so +// the GitTarget in testNS is admitted only through a grant. +func providerNamed(name string, grants ...configv1alpha3.KubeProviderGitTargetGrant) *configv1alpha3.KubeProvider { return &configv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: platformNS}, - Spec: configv1alpha3.KubeProviderSpec{AccessFrom: policy}, + Spec: configv1alpha3.KubeProviderSpec{AllowedGitTargets: grants}, + } +} + +// grantNames converts namespace names into a grant's names. +func grantNames(namespaces ...string) []configv1alpha3.GrantNamespace { + names := make([]configv1alpha3.GrantNamespace, 0, len(namespaces)) + for _, ns := range namespaces { + names = append(names, configv1alpha3.GrantNamespace(ns)) + } + return names +} + +// mirrorFrom is a grant that admits namespaces by name to mirror their own namespace. +func mirrorFrom(namespaces ...string) configv1alpha3.KubeProviderGitTargetGrant { + return configv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{ + From: configv1alpha3.GrantFromNames, + Names: grantNames(namespaces...), + }, + SourceNamespaces: []configv1alpha3.ScopeNamespace{configv1alpha3.ConsumerNamespaceVariable}, } } @@ -61,6 +81,17 @@ func namespaceLabeled(labels map[string]string) *corev1.Namespace { } func TestGitTargetAdmitted_Policy(t *testing.T) { + ownNamespace := func(grants ...configv1alpha3.KubeProviderGitTargetGrant) *configv1alpha3.KubeProvider { + return &configv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: "local", Namespace: testNS}, + Spec: configv1alpha3.KubeProviderSpec{AllowedGitTargets: grants}, + } + } + ownTarget := &configv1alpha3.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: "mirror", Namespace: testNS}, + Spec: configv1alpha3.GitTargetSpec{KubeProviderRef: meta.NamespacedObjectReference{Name: "local"}}, + } + tenant := map[string]string{"tenant": "true"} tests := []struct { name string objects []client.Object @@ -87,157 +118,127 @@ func TestGitTargetAdmitted_Policy(t *testing.T) { wantMessage: "spec.kubeProviderRef names no KubeProvider", }, { - // A provider admits its own namespace without any accessFrom, like a GitProvider. - name: "a provider in the target's own namespace is admitted without accessFrom", - objects: []client.Object{ - &configv1alpha3.KubeProvider{ObjectMeta: metav1.ObjectMeta{Name: "local", Namespace: testNS}}, - namespaceLabeled(nil), - }, - target: &configv1alpha3.GitTarget{ - ObjectMeta: metav1.ObjectMeta{Name: "mirror", Namespace: testNS}, - Spec: configv1alpha3.GitTargetSpec{ - KubeProviderRef: meta.NamespacedObjectReference{Name: "local"}, - }, - }, + // Nothing is automatic, the provider's own namespace included. + name: "the provider's own namespace is refused when no grant names it", + objects: []client.Object{ownNamespace(), namespaceLabeled(nil)}, + target: ownTarget, + wantReason: ReasonNamespaceNotAuthorized, + wantMessage: `KubeProvider team-a/local admits no namespace in spec.allowedGitTargets; namespace "team-a"`, + }, + { + name: "from: Same admits the provider's own namespace", + objects: []client.Object{ownNamespace(configv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromSame}, + SourceNamespaces: []configv1alpha3.ScopeNamespace{"{consumer.namespace}"}, + }), namespaceLabeled(nil)}, + target: ownTarget, wantAllowed: true, }, { - name: "nil accessFrom denies another namespace by default", - objects: []client.Object{ - providerNamed("prod-eu-1", nil), - namespaceLabeled(nil), - }, + name: "from: Same admits nothing else", + objects: []client.Object{providerNamed("prod-eu-1", configv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromSame}, + SourceNamespaces: []configv1alpha3.ScopeNamespace{"*"}, + }), + namespaceLabeled(nil)}, target: targetIn("prod-eu-1"), - wantAllowed: false, wantReason: ReasonNamespaceNotAuthorized, - wantMessage: `namespace "team-a" is not permitted to reference KubeProvider platform/prod-eu-1`, + wantMessage: `KubeProvider platform/prod-eu-1 admits platform in spec.allowedGitTargets; namespace "team-a"`, }, { - name: "empty policy struct denies by default", - objects: []client.Object{ - providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{}), - namespaceLabeled(nil), - }, + name: "an omitted list denies every namespace", + objects: []client.Object{providerNamed("prod-eu-1"), namespaceLabeled(nil)}, target: targetIn("prod-eu-1"), - wantAllowed: false, wantReason: ReasonNamespaceNotAuthorized, + wantMessage: "admits no namespace in spec.allowedGitTargets", }, { - name: "names entry admits", + name: "a Names grant admits", objects: []client.Object{ - providerNamed("prod-eu-1", - &configv1alpha3.NamespaceMatcher{Names: []string{"team-b", "team-a"}}), + providerNamed("prod-eu-1", mirrorFrom("team-b", "team-a")), namespaceLabeled(nil), }, target: targetIn("prod-eu-1"), wantAllowed: true, }, { - name: "names list not containing the namespace denies", - objects: []client.Object{ - providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{Names: []string{"team-b"}}), - namespaceLabeled(nil), - }, + name: "a Names grant not listing the namespace denies, naming what it admits", + objects: []client.Object{providerNamed("prod-eu-1", mirrorFrom("team-b")), namespaceLabeled(nil)}, target: targetIn("prod-eu-1"), - wantAllowed: false, wantReason: ReasonNamespaceNotAuthorized, + wantMessage: `KubeProvider platform/prod-eu-1 admits team-b in spec.allowedGitTargets; namespace "team-a" is not admitted`, }, { - // The chart default. An empty selector matches every label set, so it admits every - // namespace — the single-cluster install's "no policy configured" shape. - name: "empty selector admits every namespace", - objects: []client.Object{ - providerNamed("prod-eu-1", - &configv1alpha3.NamespaceMatcher{Selector: &metav1.LabelSelector{}}), - namespaceLabeled(nil), - }, - target: targetIn("prod-eu-1"), - wantAllowed: true, - }, - { - name: "selector matching namespace labels admits", - objects: []client.Object{ - providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{ - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "prod"}}, - }), - namespaceLabeled(map[string]string{"tier": "prod"}), - }, + name: "a Selector grant admits a namespace by its labels", + objects: []client.Object{providerNamed("prod-eu-1", configv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{ + From: configv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: tenant}, + }, + SourceNamespaces: []configv1alpha3.ScopeNamespace{"{consumer.namespace}"}, + }), namespaceLabeled(tenant)}, target: targetIn("prod-eu-1"), wantAllowed: true, }, { - name: "selector not matching namespace labels denies", - objects: []client.Object{ - providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{ - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "prod"}}, - }), - namespaceLabeled(map[string]string{"tier": "dev"}), - }, + name: "a Selector grant does not admit a namespace without the labels", + objects: []client.Object{providerNamed("prod-eu-1", configv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{ + From: configv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: tenant}, + }, + SourceNamespaces: []configv1alpha3.ScopeNamespace{"{consumer.namespace}"}, + }), namespaceLabeled(nil)}, target: targetIn("prod-eu-1"), - wantAllowed: false, wantReason: ReasonNamespaceNotAuthorized, + wantMessage: "admits namespaces labelled tenant=true", }, { - // Names and selector are ORed: a listed namespace is admitted even when the selector - // rejects it. Guards against a future refactor turning the OR into an AND. - name: "names admits even when the selector does not match", - objects: []client.Object{ - providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{ - Names: []string{"team-a"}, - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "prod"}}, - }), - namespaceLabeled(map[string]string{"tier": "dev"}), - }, + // A namespace not created yet has no labels: a Names grant still admits it. + name: "a Names grant admits a namespace that does not exist yet", + objects: []client.Object{providerNamed("prod-eu-1", mirrorFrom("team-a"))}, target: targetIn("prod-eu-1"), wantAllowed: true, }, { - name: "invalid selector denies with a legible message rather than admitting", - objects: []client.Object{ - providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{ - Selector: &metav1.LabelSelector{MatchExpressions: []metav1.LabelSelectorRequirement{{ - Key: "tier", Operator: "NotARealOperator", - }}}, - }), - namespaceLabeled(nil), - }, + // A malformed selector must FAIL CLOSED, and say so. + name: "an invalid selector denies, and the message says why", + objects: []client.Object{providerNamed("prod-eu-1", configv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{ + From: configv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "tier", Operator: "NotAnOperator"}}, + }, + }, + SourceNamespaces: []configv1alpha3.ScopeNamespace{"*"}, + }), namespaceLabeled(nil)}, target: targetIn("prod-eu-1"), - wantAllowed: false, wantReason: ReasonNamespaceNotAuthorized, - wantMessage: "accessFrom selector is invalid", + wantMessage: "a grant's selector is invalid", }, { - // A missing Namespace object is not an error: `names` is evaluated against the NAME, - // which exists in the reference even before the namespace object does. - name: "absent namespace object still admitted by a names entry", - objects: []client.Object{ - providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{Names: []string{"team-a"}}), - }, + name: "from: All admits every namespace", + objects: []client.Object{providerNamed("prod-eu-1", configv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromAll}, + SourceNamespaces: []configv1alpha3.ScopeNamespace{"{consumer.namespace}"}, + }), + namespaceLabeled(nil)}, target: targetIn("prod-eu-1"), wantAllowed: true, }, - { - // ...but a selector has no labels to match against, so it correctly does not admit. - name: "absent namespace object is not admitted by a label selector", - objects: []client.Object{ - providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{ - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "prod"}}, - }), - }, - target: targetIn("prod-eu-1"), - wantAllowed: false, - wantReason: ReasonNamespaceNotAuthorized, - }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - cl := fake.NewClientBuilder().WithScheme(admissionScheme(t)).WithObjects(tc.objects...).Build() - - decision, err := GitTargetAdmitted(context.Background(), cl, tc.target) + reader := fake.NewClientBuilder(). + WithScheme(admissionScheme(t)). + WithObjects(tc.objects...). + Build() + decision, err := GitTargetAdmitted(context.Background(), reader, tc.target) require.NoError(t, err) - assert.Equal(t, tc.wantAllowed, decision.Allowed) + + assert.Equal(t, tc.wantAllowed, decision.Allowed, decision.Message) if tc.wantAllowed { assert.Empty(t, decision.Reason, "an admission carries no reason") assert.Empty(t, decision.Message, "an admission carries no message") @@ -285,7 +286,7 @@ func TestGitTargetAdmitted_ReadErrorsSurfaceAsError(t *testing.T) { t.Run(tc.name, func(t *testing.T) { cl := fake.NewClientBuilder().WithScheme(admissionScheme(t)). WithObjects( - providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{Names: []string{"team-a"}}), + providerNamed("prod-eu-1", mirrorFrom("team-a")), namespaceLabeled(nil), ). WithInterceptorFuncs(interceptor.Funcs{ @@ -312,19 +313,125 @@ func TestGitTargetAdmitted_ReadErrorsSurfaceAsError(t *testing.T) { } } -// TestApplierAdmitted: an applier is reached with its applier claim's KubeProvider's credential, so -// that provider must admit the namespace too, and the source's denial comes first. -func TestApplierAdmitted(t *testing.T) { - admits := func(name string, namespaces ...string) *configv1alpha3.KubeProvider { - return providerNamed(name, &configv1alpha3.NamespaceMatcher{Names: namespaces}) +// claimOn is a FluxClaim in testNS on the KubeProvider cluster, holding the Kustomization +// applierNamespace/apps. +func claimOn(cluster, applierNamespace string) configv1alpha3.ApplierClaim { + return &configv1alpha3.FluxClaim{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNS, Name: "apps"}, + Spec: configv1alpha3.FluxClaimSpec{ + KubeProviderRef: meta.NamespacedObjectReference{Name: cluster, Namespace: platformNS}, + KustomizationRef: meta.NamespacedObjectReference{Name: "apps", Namespace: applierNamespace}, + }, + } +} + +// appliersFrom is a grant that admits namespaces by name to hold appliers in scope. +func appliersFrom( + scope []configv1alpha3.ScopeNamespace, + namespaces ...string, +) configv1alpha3.KubeProviderApplierClaimGrant { + return configv1alpha3.KubeProviderApplierClaimGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{ + From: configv1alpha3.GrantFromNames, + Names: grantNames(namespaces...), + }, + ApplierNamespaces: scope, } - claimOn := func(cluster string) configv1alpha3.ApplierClaim { - return &configv1alpha3.FluxClaim{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNS, Name: "apps"}, - Spec: configv1alpha3.FluxClaimSpec{ - KubeProviderRef: meta.NamespacedObjectReference{Name: cluster, Namespace: platformNS}, +} + +// TestApplierClaimAdmitted: an applier claim needs its own grant, and the applier must live in the +// scope that grant gives. An explicit list is the whole scope. +func TestApplierClaimAdmitted(t *testing.T) { + own := []configv1alpha3.ScopeNamespace{"{consumer.namespace}"} + provider := func(grants ...configv1alpha3.KubeProviderApplierClaimGrant) *configv1alpha3.KubeProvider { + p := providerNamed("hub", mirrorFrom(testNS)) + p.Spec.AllowedApplierClaims = grants + return p + } + tests := []struct { + name string + provider *configv1alpha3.KubeProvider + applierNS string + wantAllowed bool + wantReason string + wantMessage string + }{ + {"admitted to mirror is not admitted to apply", provider(), testNS, false, ReasonNamespaceNotAuthorized, + `KubeProvider platform/hub admits no namespace in spec.allowedApplierClaims; namespace "team-a"`}, + {"{consumer.namespace} admits an applier in the claim's own namespace", + provider(appliersFrom(own, testNS)), testNS, true, "", ""}, + {"{consumer.namespace} refuses an applier elsewhere", provider(appliersFrom(own, testNS)), "flux-system", false, + ReasonApplierNamespaceNotAllowed, `KubeProvider platform/hub lets namespace "team-a" hold appliers in ` + + `"team-a" (spec.allowedApplierClaims); Kustomization flux-system/apps is in namespace "flux-system"`}, + {"an explicit list refuses the claim's own namespace", + provider(appliersFrom([]configv1alpha3.ScopeNamespace{"flux-system"}, testNS)), testNS, false, + ReasonApplierNamespaceNotAllowed, `hold appliers in "flux-system"`}, + { + "an explicit list allows what it lists", + provider( + appliersFrom([]configv1alpha3.ScopeNamespace{"flux-system"}, testNS), + ), + "flux-system", + true, + "", + "", + }, + {"* allows every namespace", + provider(appliersFrom([]configv1alpha3.ScopeNamespace{"*"}, testNS)), "anywhere", true, "", ""}, + {"a grant for another namespace admits nothing here", + provider(appliersFrom(own, "team-b")), testNS, false, ReasonNamespaceNotAuthorized, + "admits team-b in spec.allowedApplierClaims"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + reader := fake.NewClientBuilder().WithScheme(admissionScheme(t)). + WithObjects(tc.provider, namespaceLabeled(nil)).Build() + decision, err := ApplierClaimAdmitted(context.Background(), reader, claimOn("hub", tc.applierNS)) + require.NoError(t, err) + assert.Equal(t, tc.wantAllowed, decision.Allowed, decision.Message) + assert.Equal(t, tc.wantReason, decision.Reason) + assert.Contains(t, decision.Message, tc.wantMessage) + }) + } +} + +// TestApplierClaimAdmitted_OwnNamespaceNeedsAGrant: a provider admits no applier claim in its own +// namespace by itself, also one it admits to mirror. +func TestApplierClaimAdmitted_OwnNamespaceNeedsAGrant(t *testing.T) { + provider := &configv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: "local", Namespace: testNS}, + Spec: configv1alpha3.KubeProviderSpec{AllowedGitTargets: []configv1alpha3.KubeProviderGitTargetGrant{ + { + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromSame}, + SourceNamespaces: []configv1alpha3.ScopeNamespace{"{consumer.namespace}"}, }, - } + }}, + } + claim := &configv1alpha3.FluxClaim{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNS, Name: "apps"}, + Spec: configv1alpha3.FluxClaimSpec{ + KubeProviderRef: meta.NamespacedObjectReference{Name: "local"}, + KustomizationRef: meta.NamespacedObjectReference{Name: "apps"}, + }, + } + reader := fake.NewClientBuilder(). + WithScheme(admissionScheme(t)). + WithObjects(provider, namespaceLabeled(nil)). + Build() + decision, err := ApplierClaimAdmitted(context.Background(), reader, claim) + require.NoError(t, err) + assert.False(t, decision.Allowed) + assert.Equal(t, ReasonNamespaceNotAuthorized, decision.Reason) + assert.Contains(t, decision.Message, "KubeProvider team-a/local admits no namespace in spec.allowedApplierClaims") +} + +// TestApplierAdmitted: asking an applier to apply needs both admissions, always. Mirroring and +// applying are granted separately, also on one KubeProvider from one namespace. +func TestApplierAdmitted(t *testing.T) { + own := []configv1alpha3.ScopeNamespace{"{consumer.namespace}"} + withAppliers := func(p *configv1alpha3.KubeProvider, grants ...configv1alpha3.KubeProviderApplierClaimGrant) *configv1alpha3.KubeProvider { + p.Spec.AllowedApplierClaims = grants + return p } tests := []struct { name string @@ -335,35 +442,41 @@ func TestApplierAdmitted(t *testing.T) { wantMessage string }{ { - name: "the applier claim's provider is the source's", - objects: []client.Object{admits("prod-eu-1", testNS)}, - claim: claimOn("prod-eu-1"), + name: "one provider granting both", + objects: []client.Object{ + withAppliers(providerNamed("prod-eu-1", mirrorFrom(testNS)), appliersFrom(own, testNS)), + }, + claim: claimOn("prod-eu-1", testNS), wantAllowed: true, }, { - name: "another provider that admits the namespace", - objects: []client.Object{admits("prod-eu-1", testNS), admits("hub", testNS)}, - claim: claimOn("hub"), - wantAllowed: true, + // The GitTarget and its applier claim are in one namespace on one KubeProvider; the + // shortcut that once admitted this without reading allowedApplierClaims is gone. + name: "mirroring allowed, applying denied, on one provider", + objects: []client.Object{providerNamed("prod-eu-1", mirrorFrom(testNS))}, + claim: claimOn("prod-eu-1", testNS), + wantReason: ReasonNamespaceNotAuthorized, + wantMessage: "spec.allowedApplierClaims", }, { - name: "another provider that does not admit the namespace", - objects: []client.Object{admits("prod-eu-1", testNS), admits("hub", "elsewhere")}, - claim: claimOn("hub"), - wantReason: ReasonNamespaceNotAuthorized, - wantMessage: "KubeProvider platform/hub", + name: "another provider that admits the applier claim", + objects: []client.Object{providerNamed("prod-eu-1", mirrorFrom(testNS)), + withAppliers(providerNamed("hub"), appliersFrom(own, testNS))}, + claim: claimOn("hub", testNS), + wantAllowed: true, }, { name: "another provider that does not exist", - objects: []client.Object{admits("prod-eu-1", testNS)}, - claim: claimOn("hub"), + objects: []client.Object{providerNamed("prod-eu-1", mirrorFrom(testNS))}, + claim: claimOn("hub", testNS), wantReason: ReasonKubeProviderNotFound, wantMessage: "FluxClaim " + testNS + "/apps spec.kubeProviderRef", }, { - name: "the source's denial comes first", - objects: []client.Object{admits("prod-eu-1", "elsewhere"), admits("hub", testNS)}, - claim: claimOn("hub"), + name: "the source's denial comes first", + objects: []client.Object{providerNamed("prod-eu-1", mirrorFrom("elsewhere")), + withAppliers(providerNamed("hub"), appliersFrom(own, testNS))}, + claim: claimOn("hub", testNS), wantReason: ReasonNamespaceNotAuthorized, wantMessage: "KubeProvider platform/prod-eu-1", }, @@ -388,7 +501,7 @@ func TestAuditRouteHeld(t *testing.T) { p := &configv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, CreationTimestamp: created}, Spec: configv1alpha3.KubeProviderSpec{ - AccessFrom: &configv1alpha3.NamespaceMatcher{Names: []string{testNS}}, + AllowedGitTargets: []configv1alpha3.KubeProviderGitTargetGrant{mirrorFrom(testNS)}, }, } if route != "" { @@ -432,7 +545,7 @@ func TestAuditRouteHeld_ListErrorSurfacesAsError(t *testing.T) { return boom }, }).Build() - decision, err := AuditRouteHeld(context.Background(), reader, providerNamed("prod-eu-1", nil)) + decision, err := AuditRouteHeld(context.Background(), reader, providerNamed("prod-eu-1")) require.ErrorIs(t, err, boom) assert.False(t, decision.Allowed) assert.Empty(t, decision.Reason) diff --git a/internal/authz/source_namespace.go b/internal/authz/source_namespace.go index 661d4fc11..0d97c950e 100644 --- a/internal/authz/source_namespace.go +++ b/internal/authz/source_namespace.go @@ -5,10 +5,9 @@ package authz import ( "context" "fmt" - "sort" + "slices" "strings" - apierrors "k8s.io/apimachinery/pkg/api/errors" "sigs.k8s.io/controller-runtime/pkg/client" configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" @@ -17,26 +16,16 @@ import ( // Reasons for the WatchRule SourceNamespaceAuthorized condition. They are the rule-side names, so a // reader never has to know which gate input produced the verdict — the Message carries that. // -// There used to be five, and three of them existed only because the deleted -// GitTarget.spec.allowedSourceNamespaces had a SELECTOR half evaluated against Namespace labels in -// ANOTHER cluster: that read could be Forbidden, or still syncing, or permanently unevaluatable, so -// the verdict had to be three-valued and two of the reasons described an inability to decide rather -// than a decision. Nothing here reads another cluster any more. Every input is on the WatchRule, the -// GitTarget and the KubeProvider, all in the control plane, so the gate is a comparison and its -// answer is always allowed or denied. +// Every input is on the WatchRule, the GitTarget, the KubeProvider and the GitTarget's Namespace, all +// in the control plane, so the gate is a comparison and its answer is always allowed or denied. const ( - // ReasonLegacySourceNamespace is the True reason when EVERY item watches the rule's OWN - // namespace. No authorization was needed. - ReasonLegacySourceNamespace = "LegacySourceNamespace" - - // ReasonSourceNamespaceAllowed is the True reason when at least one item names a namespace - // other than the rule's own — an authorized override or the cluster-wide wildcard — and every - // item passed the gate. + // ReasonSourceNamespaceAllowed is the True reason when every item watches a namespace the + // KubeProvider's grants give the GitTarget's namespace. ReasonSourceNamespaceAllowed = "SourceNamespaceAllowed" // ReasonSourceNamespaceNotAllowed is the TERMINAL False reason for a refusal: the referenced - // KubeProvider does not exist, does not admit this GitTarget's namespace, or does not set - // spec.allowCrossNamespace.sources. It is a decision, never an inability to decide. + // KubeProvider does not exist, does not admit this GitTarget's namespace, or its grants do not + // give it the namespace an item asks for. It is a decision, never an inability to decide. ReasonSourceNamespaceNotAllowed = "SourceNamespaceNotAllowed" ) @@ -62,9 +51,9 @@ type SourceNamespaceDecision struct { // ResolvedSourceScope is a WHOLE WatchRule's source-namespace verdict: one decision per spec.rules // item, index-aligned, plus the aggregate the SourceNamespaceAuthorized condition publishes. // -// It is a pure function of (rule spec, provider flags) and is recomputed on every compile. Nothing -// per-item is persisted across a spec change, which is what lets rule items have no stable API -// identity: no state outlives the spec that produced it. +// It is a pure function of (rule spec, the grants that admit the GitTarget) and is recomputed on +// every compile. Nothing per-item is persisted across a spec change, which is what lets rule items +// have no stable API identity: no state outlives the spec that produced it. type ResolvedSourceScope struct { // Items is index-aligned with spec.rules. Items []SourceNamespaceDecision @@ -90,10 +79,9 @@ func (s ResolvedSourceScope) NamespacesFor(index int) []string { // Fingerprint renders the resolved scope as a stable string, per item, for the watched-type // re-projection gate. // -// It is now derivable from the rule spec alone, since the resolution reads no cluster state. It is -// still computed from the RESOLVED set rather than the requested one, because the two differ for a -// wildcard — "*" resolves to the empty string — and the fingerprint's job is to describe what is -// actually watched. +// It is computed from the RESOLVED set rather than the requested one, because the two differ for a +// wildcard — "*" resolves to the empty string — and for a denied item, which resolves to nothing: +// the fingerprint's job is to describe what is actually watched. func (s ResolvedSourceScope) Fingerprint() string { parts := make([]string, 0, len(s.Items)) for _, item := range s.Items { @@ -110,78 +98,61 @@ func (s ResolvedSourceScope) Fingerprint() string { // (docs/spec/where-validation-lives.md). Like GitTargetAdmitted it runs on every reconcile, so a // policy TIGHTENED after a rule was accepted revokes it. // -// The gate is two rules: -// -// 1. An item watching the rule's OWN namespace is allowed, with no provider involvement at all. -// This is the legacy case and it must stay free: gating it would break every existing WatchRule -// on upgrade. -// 2. Any OTHER namespace — a name, or "*" for every namespace the credential can read — -// additionally requires the GitTarget's namespace to be admitted by its KubeProvider, and -// that provider to set spec.allowCrossNamespace.sources. -// -// There is no third rule. GitTarget.spec.allowedSourceNamespaces used to supply one, and what it -// bounded is now bounded by the source credential's own RBAC: a request this gate permits still -// fails 403 at the apiserver if the credential cannot read that namespace, which is a better -// answer than a policy field that restates the credential in the one place that cannot revoke it. +// An item may watch a namespace only while the KubeProvider's spec.allowedGitTargets admit the +// GitTarget's namespace and give it that namespace in sourceNamespaces: a name, '{consumer.namespace}' +// for the GitTarget's own namespace (which is the WatchRule's), or "*" for every namespace, which is +// also what an item asking for "*" needs. The rule's own namespace is in scope only when a grant lists it. +// The source credential's RBAC stays the hard maximum: a request this gate permits still fails 403 +// at the apiserver if the credential cannot read that namespace. // -// A non-NotFound KubeProvider read error is returned as err so the caller requeues instead of -// tearing down a running stream on a transient apiserver failure. +// A non-NotFound read error is returned as err so the caller requeues instead of tearing down a +// running stream on a transient apiserver failure. func ResolveWatchRuleSourceScope( ctx context.Context, reader client.Reader, rule *configv1alpha3.WatchRule, target *configv1alpha3.GitTarget, ) (ResolvedSourceScope, error) { - gate := &itemGate{reader: reader, rule: rule, target: target} + decision, scope, err := GitTargetSourceScope(ctx, reader, target) + if err != nil { + return ResolvedSourceScope{}, err + } + gate := &itemGate{rule: rule, target: target, decision: decision, scope: scope} items := make([]SourceNamespaceDecision, 0, len(rule.Spec.Rules)) for i := range rule.Spec.Rules { - decision, err := gate.decide(ctx, i, &rule.Spec.Rules[i]) - if err != nil { - return ResolvedSourceScope{}, err - } - items = append(items, decision) + items = append(items, gate.decide(i, &rule.Spec.Rules[i])) } return aggregateSourceScope(items), nil } -// itemGate carries the per-rule inputs so each item's decision reads as one step. The -// KubeProvider verdict is memoised: every overriding item asks the same question of the same -// provider, and re-reading it per item would multiply apiserver reads by the rule's item count for -// an answer that cannot differ within one compile. +// itemGate carries the per-rule inputs so each item's decision reads as one step. The KubeProvider +// is read once per rule: every item asks the same provider about the same GitTarget. type itemGate struct { - reader client.Reader - rule *configv1alpha3.WatchRule - target *configv1alpha3.GitTarget - - delegation *SourceNamespaceDecision - delegationAsked bool + rule *configv1alpha3.WatchRule + target *configv1alpha3.GitTarget + decision Decision + scope configv1alpha3.KubeProviderGitTargetScope } // decide resolves ONE rule item. -func (g *itemGate) decide( - ctx context.Context, - index int, - item *configv1alpha3.ResourceRule, -) (SourceNamespaceDecision, error) { +func (g *itemGate) decide(index int, item *configv1alpha3.ResourceRule) SourceNamespaceDecision { base := SourceNamespaceDecision{Index: index, Requested: item.SourceNamespace} - - if !item.OverridesSourceNamespace(g.rule.Namespace) { - own := item.EffectiveSourceNamespace(g.rule.Namespace) - base.Namespaces = []string{own} - base.Allowed = true - base.Reason = ReasonLegacySourceNamespace - base.Message = fmt.Sprintf( - "%s watches this WatchRule's own namespace %q, which needs no authorization", - g.describeItem(index, item), own) - return base, nil + provider := g.target.KubeProviderKey() + if !g.decision.Allowed { + base.Reason = ReasonSourceNamespaceNotAllowed + base.Message = fmt.Sprintf("%s: GitTarget %s/%s may not mirror through KubeProvider %s at all: %s", + g.describeItem(index, item), g.target.Namespace, g.target.Name, provider, g.decision.Message) + return base } - refusal, refused, err := g.overrideDelegated(ctx, index, item) - if err != nil { - return SourceNamespaceDecision{}, err - } - if refused { - return refusal, nil + requested := item.EffectiveSourceNamespace(g.rule.Namespace) + if !g.scope.AllowsSourceNamespace(requested) { + base.Reason = ReasonSourceNamespaceNotAllowed + base.Message = fmt.Sprintf( + "KubeProvider %s lets namespace %q watch %s (spec.allowedGitTargets[].sourceNamespaces); %s is not "+ + "allowed", provider, g.target.Namespace, describeScope(g.scope.SourceNamespaces), + g.describeItem(index, item)) + return base } base.Allowed = true @@ -192,97 +163,23 @@ func (g *itemGate) decide( // named-namespace collection on the same type, never a replacement for one. base.Namespaces = []string{""} base.Message = fmt.Sprintf( - "%s: KubeProvider %s sets spec.allowCrossNamespace.sources, so this item watches every "+ - "namespace its source credential can read, as one cluster-wide stream", - g.describeItem(index, item), g.target.SourceCluster()) - return base, nil + "%s: KubeProvider %s grants sourceNamespaces \"*\", so this item watches every namespace its source "+ + "credential can read, as one cluster-wide stream", g.describeItem(index, item), provider) + return base } - - candidate := item.EffectiveSourceNamespace(g.rule.Namespace) - base.Namespaces = []string{candidate} + base.Namespaces = []string{requested} base.Message = fmt.Sprintf( - "%s: KubeProvider %s sets spec.allowCrossNamespace.sources, so this item may watch source "+ - "namespace %q; whether it can is decided by that credential's RBAC", - g.describeItem(index, item), g.target.SourceCluster(), candidate) - return base, nil + "%s: KubeProvider %s lets this item watch source namespace %q; whether it can is decided by that "+ + "credential's RBAC", g.describeItem(index, item), provider, requested) + return base } -// overrideDelegated applies the two provider-side halves of the gate: the provider must admit the -// GitTarget's own namespace, and it must set the delegation flag. Its verdict is identical for -// every item, so it is computed once per rule. -// -// It returns refused=true with the refusal to publish (retargeted at this item), or refused=false -// when the item is authorized. A read error is returned as err so the caller requeues. -func (g *itemGate) overrideDelegated( - ctx context.Context, - index int, - item *configv1alpha3.ResourceRule, -) (SourceNamespaceDecision, bool, error) { - if !g.delegationAsked { - refusal, err := g.evaluateDelegation(ctx) - if err != nil { - return SourceNamespaceDecision{}, false, err - } - g.delegation = refusal - g.delegationAsked = true +// describeScope renders a source-namespace scope for a message. +func describeScope(namespaces []string) string { + if len(namespaces) == 0 { + return "no namespace" } - if g.delegation == nil { - return SourceNamespaceDecision{}, false, nil - } - - refusal := *g.delegation - refusal.Index = index - refusal.Requested = item.SourceNamespace - refusal.Message = fmt.Sprintf("%s: %s", g.describeItem(index, item), refusal.Message) - return refusal, true, nil -} - -// evaluateDelegation returns a refusal template when the provider side of the gate denies, or nil -// when it permits. The message is item-agnostic; decide prefixes the item that asked. -func (g *itemGate) evaluateDelegation(ctx context.Context) (*SourceNamespaceDecision, error) { - providerName := g.target.KubeProviderKey() - - var provider configv1alpha3.KubeProvider - if err := g.reader.Get(ctx, providerName, &provider); err != nil { - if apierrors.IsNotFound(err) { - return &SourceNamespaceDecision{ - Reason: ReasonSourceNamespaceNotAllowed, - Message: fmt.Sprintf( - "referenced KubeProvider %s was not found, so it delegates nothing; a "+ - "WatchRule may watch a namespace other than its own only through an "+ - "existing provider that sets spec.allowCrossNamespace.sources", - providerName), - }, nil - } - // Transient: requeue rather than tear down a running stream. - return nil, fmt.Errorf("read KubeProvider %s: %w", providerName, err) - } - - // The GitTarget itself must be admitted by that provider before it can delegate anything. - admitted, err := GitTargetAdmitted(ctx, g.reader, g.target) - if err != nil { - return nil, err - } - if !admitted.Allowed { - return &SourceNamespaceDecision{ - Reason: ReasonSourceNamespaceNotAllowed, - Message: fmt.Sprintf( - "GitTarget %s/%s may not mirror through KubeProvider %s at all: %s", - g.target.Namespace, g.target.Name, providerName, admitted.Message), - }, nil - } - - if !provider.AllowsCrossNamespaceSources() { - return &SourceNamespaceDecision{ - Reason: ReasonSourceNamespaceNotAllowed, - Message: fmt.Sprintf( - "KubeProvider %s does not set spec.allowCrossNamespace.sources; a WatchRule may "+ - "watch only its own namespace %q until a platform admin delegates that choice", - providerName, g.rule.Namespace), - }, nil - } - - return nil, nil //nolint:nilnil // nil refusal means "the provider side permits"; see the godoc. + return quoteAll(namespaces) } // describeItem names an item by index AND by what it selects. The index alone goes stale the moment @@ -293,13 +190,8 @@ func (g *itemGate) describeItem(index int, item *configv1alpha3.ResourceRule) st } // aggregateSourceScope folds the per-item decisions into the one SourceNamespaceAuthorized verdict -// the object publishes. The precedence is stated rather than derived, because two implementations -// of "worst wins" would otherwise disagree about mixed rules: -// -// 1. any item denied → False / SourceNamespaceNotAllowed / Stalled=True -// 2. every item admitted, at least one naming a namespace other than the rule's own → True / -// SourceNamespaceAllowed -// 3. every item on its own namespace → True / LegacySourceNamespace +// the object publishes: any item denied is False / SourceNamespaceNotAllowed / Stalled=True, naming +// the first denied item; otherwise True / SourceNamespaceAllowed. // // There is no "cannot say yet" row. Every input is a control-plane object this reconcile already // read, so an item that is not denied is decided. @@ -307,7 +199,7 @@ func aggregateSourceScope(items []SourceNamespaceDecision) ResolvedSourceScope { out := ResolvedSourceScope{Items: items} if len(items) == 0 { out.Allowed = true - out.Reason = ReasonLegacySourceNamespace + out.Reason = ReasonSourceNamespaceAllowed out.Message = "no rule items to authorize" return out } @@ -321,24 +213,11 @@ func aggregateSourceScope(items []SourceNamespaceDecision) ResolvedSourceScope { } out.Allowed = true - out.Reason, out.Message = admittedAggregate(items) + out.Reason = ReasonSourceNamespaceAllowed + out.Message = summariseAdmitted(items) return out } -// admittedAggregate picks the True reason once every item was admitted. -func admittedAggregate(items []SourceNamespaceDecision) (string, string) { - legacy := true - for _, item := range items { - if item.Reason != ReasonLegacySourceNamespace { - legacy = false - } - } - if legacy { - return ReasonLegacySourceNamespace, items[0].Message - } - return ReasonSourceNamespaceAllowed, summariseAdmitted(items) -} - func summariseAdmitted(items []SourceNamespaceDecision) string { all := make([]string, 0, len(items)) for _, item := range items { @@ -350,21 +229,8 @@ func summariseAdmitted(items []SourceNamespaceDecision) string { all = append(all, ns) } } - all = sortedUnique(all) + slices.Sort(all) + all = slices.Compact(all) return fmt.Sprintf("all %d rule item(s) are authorized; watching source namespace(s) %s", len(items), strings.Join(all, ", ")) } - -func sortedUnique(in []string) []string { - seen := make(map[string]struct{}, len(in)) - out := make([]string, 0, len(in)) - for _, v := range in { - if _, dup := seen[v]; dup { - continue - } - seen[v] = struct{}{} - out = append(out, v) - } - sort.Strings(out) - return out -} diff --git a/internal/authz/source_namespace_test.go b/internal/authz/source_namespace_test.go index f25c027ad..c49210b1a 100644 --- a/internal/authz/source_namespace_test.go +++ b/internal/authz/source_namespace_test.go @@ -69,18 +69,32 @@ func snRule(sourceNamespaces ...string) *configv1alpha3.WatchRule { } } -// snKubeProvider admits snTenantNS by name, so the "provider admits the target's namespace" leg -// of the gate passes and each case isolates the leg it is actually about. -func snKubeProvider(delegate bool) *configv1alpha3.KubeProvider { +// snKubeProvider admits snTenantNS by name with scope as its sourceNamespaces, so the "provider +// admits the target's namespace" leg of the gate passes and each case isolates the scope. +func snKubeProvider(scope ...configv1alpha3.ScopeNamespace) *configv1alpha3.KubeProvider { + grant := configv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{ + From: configv1alpha3.GrantFromNames, + Names: []configv1alpha3.GrantNamespace{snTenantNS}, + }, + SourceNamespaces: scope, + } + if len(scope) == 0 { + grant.ClusterResources = []configv1alpha3.ClusterResourceIdentity{"*"} + } return &configv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: snProvider, Namespace: snProviderNS}, Spec: configv1alpha3.KubeProviderSpec{ - AccessFrom: &configv1alpha3.NamespaceMatcher{Names: []string{snTenantNS}}, - AllowCrossNamespace: &configv1alpha3.KubeProviderCrossNamespace{Sources: delegate}, + AllowedGitTargets: []configv1alpha3.KubeProviderGitTargetGrant{grant}, }, } } +const ( + own = configv1alpha3.ScopeNamespace(configv1alpha3.ConsumerNamespaceVariable) + all = configv1alpha3.ScopeNamespace("*") +) + func snReader(t *testing.T, objects ...client.Object) client.Reader { t.Helper() objects = append(objects, @@ -88,8 +102,8 @@ func snReader(t *testing.T, objects ...client.Object) client.Reader { return fake.NewClientBuilder().WithScheme(snScheme(t)).WithObjects(objects...).Build() } -// The gate is two rules and this is the table for both of them: an item on the rule's own namespace -// is free, and anything else needs the KubeProvider's delegation. +// The gate is one rule and this is its table: an item may watch a namespace only while the grants +// that admit the GitTarget give it that namespace. The rule's own namespace is no exception. // // There is deliberately no row for a GitTarget policy. There is no longer such a field: what it // bounded is bounded by the source credential's own RBAC, which this gate cannot and does not try @@ -98,74 +112,94 @@ func TestResolveWatchRuleSourceScope(t *testing.T) { tests := []struct { name string items []string - delegate bool + scope []configv1alpha3.ScopeNamespace admitted bool - reason string namespaces [][]string says string }{ { - name: "an omitted item watches the rule's own namespace, ungated", + name: "an omitted item watches the rule's own namespace, granted by {consumer.namespace}", items: []string{""}, - delegate: false, + scope: []configv1alpha3.ScopeNamespace{own}, admitted: true, - reason: authz.ReasonLegacySourceNamespace, namespaces: [][]string{{snTenantNS}}, - says: "gating the legacy case would break every existing WatchRule on upgrade", }, { - name: "restating the rule's own namespace is not an override", + name: "an explicit scope is the whole scope, the rule's own namespace excluded", + items: []string{""}, + scope: []configv1alpha3.ScopeNamespace{"flux-system"}, + admitted: false, + says: `sourceNamespaces: [flux-system] means flux-system only`, + }, + { + name: "a grant with clusterResources alone gives no namespace", + items: []string{""}, + admitted: false, + }, + { + name: "restating the rule's own namespace is the same request", items: []string{snTenantNS}, - delegate: false, + scope: []configv1alpha3.ScopeNamespace{own}, admitted: true, - reason: authz.ReasonLegacySourceNamespace, namespaces: [][]string{{snTenantNS}}, - says: "spelling out what omission means must not need a platform-admin flag", }, { - name: "a named override is allowed once the provider delegates", + name: "a named namespace is allowed once a grant lists it", items: []string{snSourceNS}, - delegate: true, + scope: []configv1alpha3.ScopeNamespace{own, configv1alpha3.ScopeNamespace(snSourceNS)}, admitted: true, - reason: authz.ReasonSourceNamespaceAllowed, namespaces: [][]string{{snSourceNS}}, }, { - name: "a named override is refused while the provider does not delegate", + name: "a named namespace is refused under {consumer.namespace}", items: []string{snSourceNS}, - delegate: false, + scope: []configv1alpha3.ScopeNamespace{own}, admitted: false, - reason: authz.ReasonSourceNamespaceNotAllowed, }, { - name: "the wildcard resolves to the cluster-wide collection", + name: "the wildcard resolves to the cluster-wide collection under *", items: []string{snWildcard}, - delegate: true, + scope: []configv1alpha3.ScopeNamespace{all}, admitted: true, - reason: authz.ReasonSourceNamespaceAllowed, namespaces: [][]string{{""}}, says: `"*" is one cluster-wide list and watch, which is the empty namespace`, }, { - name: "the wildcard is refused while the provider does not delegate", + name: "the wildcard is refused under {consumer.namespace}", items: []string{snWildcard}, - delegate: false, + scope: []configv1alpha3.ScopeNamespace{own}, admitted: false, - reason: authz.ReasonSourceNamespaceNotAllowed, says: "the widest request in the API must not be the one that slips through", }, + { + name: "the wildcard is refused under a list of names", + items: []string{snWildcard}, + scope: []configv1alpha3.ScopeNamespace{own, configv1alpha3.ScopeNamespace(snSourceNS)}, + admitted: false, + }, + { + name: "* allows a named namespace", + items: []string{snSourceNS}, + scope: []configv1alpha3.ScopeNamespace{all}, + admitted: true, + namespaces: [][]string{{snSourceNS}}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - reader := snReader(t, snTarget(), snKubeProvider(tt.delegate)) + reader := snReader(t, snTarget(), snKubeProvider(tt.scope...)) resolved, err := authz.ResolveWatchRuleSourceScope( context.Background(), reader, snRule(tt.items...), snTarget()) require.NoError(t, err) assert.Equal(t, tt.admitted, resolved.Admitted(), tt.says) - assert.Equal(t, tt.reason, resolved.Reason, tt.says) + reason := authz.ReasonSourceNamespaceNotAllowed + if tt.admitted { + reason = authz.ReasonSourceNamespaceAllowed + } + assert.Equal(t, reason, resolved.Reason, tt.says) if tt.namespaces != nil { for i, want := range tt.namespaces { assert.Equal(t, want, resolved.NamespacesFor(i), tt.says) @@ -176,10 +210,10 @@ func TestResolveWatchRuleSourceScope(t *testing.T) { } } -// A rule mixing a legacy item with an authorized override resolves each independently, and the +// A rule mixing its own namespace with other ones resolves each item independently, and the // aggregate is the one condition the object publishes. func TestResolveWatchRuleSourceScope_MixedItemsResolveIndependently(t *testing.T) { - reader := snReader(t, snTarget(), snKubeProvider(true)) + reader := snReader(t, snTarget(), snKubeProvider(own, all)) resolved, err := authz.ResolveWatchRuleSourceScope( context.Background(), reader, snRule("", snSourceNS, snWildcard), snTarget()) @@ -189,15 +223,14 @@ func TestResolveWatchRuleSourceScope_MixedItemsResolveIndependently(t *testing.T assert.Equal(t, []string{snTenantNS}, resolved.NamespacesFor(0)) assert.Equal(t, []string{snSourceNS}, resolved.NamespacesFor(1)) assert.Equal(t, []string{""}, resolved.NamespacesFor(2)) - assert.Equal(t, authz.ReasonSourceNamespaceAllowed, resolved.Reason, - "one overriding item makes the whole rule an override for reporting purposes") + assert.Equal(t, authz.ReasonSourceNamespaceAllowed, resolved.Reason) } // A denied item refuses the WHOLE rule rather than being trimmed away. Mirroring two of the three // namespaces a rule asked for is worse than a loud failure: the operator would have no way to see // that the third was dropped. func TestResolveWatchRuleSourceScope_DeniedItemRefusesTheWholeRule(t *testing.T) { - reader := snReader(t, snTarget(), snKubeProvider(false)) + reader := snReader(t, snTarget(), snKubeProvider(own)) resolved, err := authz.ResolveWatchRuleSourceScope( context.Background(), reader, snRule("", snSourceNS), snTarget()) @@ -208,14 +241,16 @@ func TestResolveWatchRuleSourceScope_DeniedItemRefusesTheWholeRule(t *testing.T) assert.Equal(t, authz.ReasonSourceNamespaceNotAllowed, resolved.Reason) assert.Contains(t, resolved.Message, "spec.rules[1]", "the message must name the item that decided it, since the index is what gets edited") + assert.Contains(t, resolved.Message, `KubeProvider platform/workspaces lets namespace "tenant-acme" watch `+ + `"tenant-acme" (spec.allowedGitTargets[].sourceNamespaces); spec.rules[1] (resources configmaps, `+ + `sourceNamespace "repo-config") is not allowed`, "the refusal names the grant and the request") } -// A GitTarget the provider does not admit at all cannot delegate anything to its rules: the -// provider-side leg is checked before the flag, so a target outside accessFrom is refused even -// through a provider that delegates freely. -func TestResolveWatchRuleSourceScope_UnadmittedGitTargetCannotDelegate(t *testing.T) { - denying := snKubeProvider(true) - denying.Spec.AccessFrom = &configv1alpha3.NamespaceMatcher{Names: []string{"some-other-tenant"}} +// A GitTarget the provider does not admit at all gets no scope, however wide the grant it is not +// admitted by. +func TestResolveWatchRuleSourceScope_UnadmittedGitTargetGetsNoScope(t *testing.T) { + denying := snKubeProvider(own, all) + denying.Spec.AllowedGitTargets[0].ConsumerNamespaces.Names = []configv1alpha3.GrantNamespace{"some-other-tenant"} reader := snReader(t, snTarget(), denying) resolved, err := authz.ResolveWatchRuleSourceScope( @@ -227,9 +262,9 @@ func TestResolveWatchRuleSourceScope_UnadmittedGitTargetCannotDelegate(t *testin assert.Contains(t, resolved.Message, "may not mirror through KubeProvider") } -// A missing KubeProvider denies an override rather than erroring: a provider that does not exist -// delegates nothing, which is a decision the rule's owner can act on. -func TestResolveWatchRuleSourceScope_MissingKubeProviderDeniesOverride(t *testing.T) { +// A missing KubeProvider denies rather than erroring: a provider that does not exist grants nothing, +// which is a decision the rule's owner can act on. +func TestResolveWatchRuleSourceScope_MissingKubeProviderDenies(t *testing.T) { reader := snReader(t, snTarget()) resolved, err := authz.ResolveWatchRuleSourceScope( @@ -246,7 +281,7 @@ func TestResolveWatchRuleSourceScope_ProviderReadErrorIsRequeued(t *testing.T) { boom := errors.New("apiserver unavailable") reader := fake.NewClientBuilder(). WithScheme(snScheme(t)). - WithObjects(snTarget(), snKubeProvider(true)). + WithObjects(snTarget(), snKubeProvider(own, all)). WithInterceptorFuncs(interceptor.Funcs{ Get: func( _ context.Context, _ client.WithWatch, key client.ObjectKey, @@ -270,7 +305,7 @@ func TestResolveWatchRuleSourceScope_ProviderIsReadOncePerRule(t *testing.T) { reads := 0 reader := fake.NewClientBuilder(). WithScheme(snScheme(t)). - WithObjects(snTarget(), snKubeProvider(true), + WithObjects(snTarget(), snKubeProvider(own, all), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snTenantNS}}). WithInterceptorFuncs(interceptor.Funcs{ Get: func( @@ -290,7 +325,7 @@ func TestResolveWatchRuleSourceScope_ProviderIsReadOncePerRule(t *testing.T) { require.NoError(t, err) require.True(t, resolved.Admitted()) assert.LessOrEqual(t, reads, 2, - "the delegation verdict cannot differ within one compile, so it is memoised rather than "+ + "the provider's verdict cannot differ within one compile, so it is read once rather than "+ "multiplying apiserver reads by the rule's item count") } @@ -298,7 +333,7 @@ func TestResolveWatchRuleSourceScope_ProviderIsReadOncePerRule(t *testing.T) { // the RESOLVED set moves — including for the wildcard, whose resolved value is the empty string. func TestResolvedSourceScope_Fingerprint(t *testing.T) { ctx := context.Background() - reader := snReader(t, snTarget(), snKubeProvider(true)) + reader := snReader(t, snTarget(), snKubeProvider(own, all)) named, err := authz.ResolveWatchRuleSourceScope(ctx, reader, snRule(snSourceNS), snTarget()) require.NoError(t, err) @@ -316,21 +351,21 @@ func TestResolvedSourceScope_Fingerprint(t *testing.T) { // An empty rule is vacuously authorized. It is reachable through the API only transiently, and a // gate that errored on it would turn an empty list into a stall. func TestResolveWatchRuleSourceScope_NoItems(t *testing.T) { - reader := snReader(t, snTarget(), snKubeProvider(false)) + reader := snReader(t, snTarget(), snKubeProvider(own)) resolved, err := authz.ResolveWatchRuleSourceScope( context.Background(), reader, snRule(), snTarget()) require.NoError(t, err) assert.True(t, resolved.Admitted()) - assert.Equal(t, authz.ReasonLegacySourceNamespace, resolved.Reason) + assert.Equal(t, authz.ReasonSourceNamespaceAllowed, resolved.Reason) } // NamespacesFor is indexed by the item's position in spec.rules, and the store compiles from it // position by position. An out-of-range index returns nil rather than panicking: the resolved scope // and the spec are two objects, and a caller reading them apart must not take the process down. func TestResolvedSourceScope_NamespacesForIsBoundsChecked(t *testing.T) { - reader := snReader(t, snTarget(), snKubeProvider(true)) + reader := snReader(t, snTarget(), snKubeProvider(own, all)) resolved, err := authz.ResolveWatchRuleSourceScope( context.Background(), reader, snRule("", snSourceNS), snTarget()) @@ -345,7 +380,7 @@ func TestResolvedSourceScope_NamespacesForIsBoundsChecked(t *testing.T) { // same namespace twice, and the cluster-wide collection is spelled out rather than shown as the empty // string an operator would read as a missing value. func TestResolveWatchRuleSourceScope_AggregateMessageIsDeduplicatedAndLegible(t *testing.T) { - reader := snReader(t, snTarget(), snKubeProvider(true)) + reader := snReader(t, snTarget(), snKubeProvider(own, all)) resolved, err := authz.ResolveWatchRuleSourceScope( context.Background(), reader, snRule(snSourceNS, snSourceNS, snWildcard), snTarget()) diff --git a/internal/controller/access_grants_admission_test.go b/internal/controller/access_grants_admission_test.go new file mode 100644 index 000000000..97cd1bafe --- /dev/null +++ b/internal/controller/access_grants_admission_test.go @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "fmt" + "maps" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// The grant grammar is checked by the API server, so a refusal names the mistake at apply time. Each +// case is sent as unstructured, because a typed object drops the empty lists and the empty +// selector these rules exist to refuse. +var _ = Describe("Access grants at admission", func() { + var counter int + object := func(kind string, spec map[string]any) *unstructured.Unstructured { + counter++ + u := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "configbutler.ai/v1alpha3", + "kind": kind, + "metadata": map[string]any{"name": fmt.Sprintf("grant-cel-%d", counter), "namespace": "default"}, + "spec": spec, + }} + return u + } + gitProvider := func(grants ...any) *unstructured.Unstructured { + return object("GitProvider", map[string]any{ + "url": "https://example.com/repo.git", "allowedGitBranches": grants, + }) + } + gitBranch := func(grants ...any) *unstructured.Unstructured { + return object("GitBranch", map[string]any{ + "gitProviderRef": map[string]any{"name": "p"}, "branch": "main", "allowedGitTargets": grants, + }) + } + kubeProvider := func(field string, grants ...any) *unstructured.Unstructured { + return object("KubeProvider", map[string]any{ + "secretRef": map[string]any{"name": "kubeconfig"}, field: grants, + }) + } + create := func(obj client.Object) error { + err := k8sClient.Create(context.Background(), obj) + if err == nil { + DeferCleanup(func() { _ = k8sClient.Delete(context.Background(), obj) }) + } + return err + } + refused := func(obj client.Object, message string) { + GinkgoHelper() + err := create(obj) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(message)) + } + accepted := func(obj client.Object) { + GinkgoHelper() + Expect(create(obj)).To(Succeed()) + } + same := map[string]any{"from": "Same"} + branches := func(patterns ...any) map[string]any { + return map[string]any{"consumerNamespaces": same, "branchPatterns": patterns} + } + // consumers is a grant holding only its consumerNamespaces block, the whole of a GitBranch grant. + consumers := func(block map[string]any) map[string]any { + return map[string]any{"consumerNamespaces": block} + } + // withConsumers is scope plus a consumerNamespaces block. + withConsumers := func(block map[string]any, scope map[string]any) map[string]any { + grant := maps.Clone(scope) + grant["consumerNamespaces"] = block + return grant + } + + It("accepts the branch patterns the grammar allows", func() { + accepted(gitProvider(branches("main", "release/*", "teams/{consumer.namespace}/*", "{consumer.namespace}", + "teams/{consumer.namespace}/{consumer.name}", "{consumer.namespace}/{consumer.name}/*", "*", + "feature/*/v2"))) + }) + + It("refuses {consumer.name} without {consumer.namespace} in the same pattern", func() { + refused(gitProvider(branches("env/{consumer.name}")), "{consumer.name} needs {consumer.namespace}") + refused(gitProvider(branches("{consumer.name}/*")), "{consumer.name} needs {consumer.namespace}") + }) + + It("refuses a consumer variable that is not a whole path segment", func() { + refused(gitProvider(branches("teams/{consumer.namespace}*")), "must each be a whole path segment") + refused(gitProvider(branches("teams/x{consumer.namespace}/live")), "must each be a whole path segment") + refused(gitProvider(branches("teams/{consumer.namespace}-{consumer.name}")), + "must each be a whole path segment") + refused(gitProvider(branches("{consumer.namespace}{consumer.name}")), "must each be a whole path segment") + }) + + It("refuses an unknown variable, naming it and the ones that exist", func() { + refused(gitProvider(branches("teams/{namespace}/*")), + "branch pattern teams/{namespace}/* references unknown variable {namespace}; "+ + "the variables are {consumer.namespace} and {consumer.name}") + refused(gitProvider(branches("teams/{consumer.namespace")), "unmatched brace") + }) + + It("refuses what no branch name can hold", func() { + refused(gitProvider(branches("feature/?")), "spec.allowedGitBranches[0].branchPatterns[0]") + refused(gitProvider(branches("feature/[ab]")), "spec.allowedGitBranches[0].branchPatterns[0]") + refused(gitProvider(branches("a..b")), "can match no branch name") + }) + + It("refuses an empty scope or names list", func() { + refused(gitProvider(branches()), "spec.allowedGitBranches[0].branchPatterns") + refused(gitProvider(consumers(same)), "branchPatterns: Required value") + refused(gitBranch(consumers(map[string]any{"from": "Names", "names": []any{}})), "names") + refused(kubeProvider("allowedGitTargets", withConsumers(same, map[string]any{"sourceNamespaces": []any{}})), + "sourceNamespaces") + refused(kubeProvider("allowedGitTargets", withConsumers(same, map[string]any{"clusterResources": []any{}})), + "clusterResources") + refused(kubeProvider("allowedApplierClaims", withConsumers(same, map[string]any{"applierNamespaces": []any{}})), + "applierNamespaces") + refused(kubeProvider("allowedApplierClaims", consumers(same)), "applierNamespaces: Required value") + }) + + // consumerNamespaces is required on every grant type and from is required inside it, with no default + // for either, so a grant that leaves the "who" out admits nobody instead of its holder's namespace. + // The block's own rules cannot make it required, so each list is checked here. + for _, list := range []struct { + name string + build func(grant map[string]any) *unstructured.Unstructured + scope map[string]any + }{ + {"GitProvider allowedGitBranches", + func(g map[string]any) *unstructured.Unstructured { return gitProvider(g) }, + map[string]any{"branchPatterns": []any{"main"}}}, + {"GitBranch allowedGitTargets", + func(g map[string]any) *unstructured.Unstructured { return gitBranch(g) }, + map[string]any{}}, + {"KubeProvider allowedGitTargets", + func(g map[string]any) *unstructured.Unstructured { return kubeProvider("allowedGitTargets", g) }, + map[string]any{"sourceNamespaces": []any{"{consumer.namespace}"}}}, + {"KubeProvider allowedApplierClaims", + func(g map[string]any) *unstructured.Unstructured { return kubeProvider("allowedApplierClaims", g) }, + map[string]any{"applierNamespaces": []any{"flux-system"}}}, + } { + It("refuses a "+list.name+" grant without a complete consumerNamespaces block", func() { + refused(list.build(maps.Clone(list.scope)), "consumerNamespaces: Required value") + nullBlock := maps.Clone(list.scope) + nullBlock["consumerNamespaces"] = nil + refused(list.build(nullBlock), "consumerNamespaces: Required value") + refused(list.build(withConsumers(map[string]any{}, list.scope)), "from: Required value") + refused(list.build(withConsumers(map[string]any{"names": []any{"team-a"}}, list.scope)), + "from: Required value") + accepted(list.build(withConsumers(same, list.scope))) + }) + } + + It("refuses a grant without from, or with names or a selector beside the wrong from", func() { + refused(gitBranch(consumers(map[string]any{})), "from: Required value") + refused(gitBranch(consumers(map[string]any{"from": "Everyone"})), "Unsupported value") + refused(gitBranch(consumers(map[string]any{"from": "Names"})), "from: Names needs names") + refused(gitBranch(consumers(map[string]any{"from": "Same", "names": []any{"team-b"}})), + "names goes only with from: Names") + refused(gitBranch(consumers(map[string]any{"from": "All", + "selector": map[string]any{"matchLabels": map[string]any{"a": "b"}}})), + "selector goes only with from: Selector") + }) + + It("refuses a KubeProvider GitTarget grant that grants nothing", func() { + refused(kubeProvider("allowedGitTargets", consumers(same)), + "sets sourceNamespaces, clusterResources, or both") + accepted(kubeProvider("allowedGitTargets", + withConsumers(same, map[string]any{"sourceNamespaces": []any{"{consumer.namespace}"}}))) + accepted(kubeProvider("allowedGitTargets", withConsumers(same, + map[string]any{"clusterResources": []any{"clusterroles.rbac.authorization.k8s.io", "nodes", "*"}}))) + }) + + It("refuses a variable in names, pointing to from: All and Same, and * pointing to from: All", func() { + names := func(name string) map[string]any { + return consumers(map[string]any{"from": "Names", "names": []any{name}}) + } + refused(gitBranch(names("{consumer.namespace}")), "use from: All") + refused(gitBranch(names("{consumer.name}")), "or from: Same") + refused(gitBranch(names("*")), "use from: All") + refused(gitBranch(names("Team_A")), "namespace names") + }) + + It("refuses a Selector grant without a selector or with an empty one, and accepts a real one", func() { + refused(gitBranch(consumers(map[string]any{"from": "Selector"})), "from: Selector needs a selector") + refused(gitBranch(consumers(map[string]any{"from": "Selector", "selector": map[string]any{}})), + "selector: {} is refused; to admit every namespace, use from: All") + refused(gitBranch(consumers(map[string]any{"from": "Selector", + "selector": map[string]any{"matchLabels": map[string]any{}}})), "selector: {} is refused") + accepted(gitBranch(consumers(map[string]any{"from": "Selector", + "selector": map[string]any{"matchLabels": map[string]any{"tenant": "true"}}}))) + accepted(gitBranch(consumers(map[string]any{"from": "Selector", "selector": map[string]any{ + "matchExpressions": []any{map[string]any{"key": "tenant", "operator": "Exists"}}}}))) + accepted(gitBranch(consumers(map[string]any{"from": "All"}), consumers(same))) + }) + + It("refuses a scope entry that is neither a name, a consumer variable alone, nor *", func() { + alone := "'{consumer.namespace}' alone, or '*' alone" + sources := func(entry string) map[string]any { + return withConsumers(same, map[string]any{"sourceNamespaces": []any{entry}}) + } + appliers := func(entry string) map[string]any { + return withConsumers(same, map[string]any{"applierNamespaces": []any{entry}}) + } + refused(kubeProvider("allowedGitTargets", sources("team-{consumer.namespace}")), alone) + refused(kubeProvider("allowedGitTargets", sources("{namespace}")), alone) + refused(kubeProvider("allowedApplierClaims", appliers("flux-*")), alone) + refused(kubeProvider("allowedGitTargets", sources("{consumer.name}")), "the consumer picks its own name") + refused(kubeProvider("allowedApplierClaims", appliers("{consumer.name}")), "the consumer picks its own name") + refused(kubeProvider("allowedGitTargets", withConsumers(same, + map[string]any{"clusterResources": []any{"cluster*"}})), "clusterResources[0]") + }) +}) diff --git a/internal/controller/applierclaim_controller.go b/internal/controller/applierclaim_controller.go index da32c3bd8..ee0df400a 100644 --- a/internal/controller/applierclaim_controller.go +++ b/internal/controller/applierclaim_controller.go @@ -43,30 +43,36 @@ const ApplierClaimConditionConfigured = "Configured" // time it was given. It clears when the operation completes. const ReasonOperationUnresolved = "OperationUnresolved" -// ApplierClaimConditionHeld reports whether an applier claim holds its applier. A FluxClaim holds its -// Kustomization suspended under GitOps Reverser's field manager, with the last suspend's token -// handled, so nothing that started before the suspend is still running, or resumed for an apply of -// GitOps Reverser's own. An ArgoCDClaim holds its Application's automated sync off -// (argocdclaim_hold.go). An applier claim is Ready only once it holds its applier. -const ApplierClaimConditionHeld = applier.ApplierClaimConditionHeld - -// Held reasons, and the Events of the hold lifecycle on an applier claim. +// ApplierClaimConditionInControl reports whether an applier claim decides, right now, when its applier +// applies. A FluxClaim is in control while its Kustomization is suspended under GitOps Reverser's +// field manager, with the last suspend's token handled, so nothing that started before the suspend +// is still running, or resumed for an apply of GitOps Reverser's own. An ArgoCDClaim is in control +// while its Application's automated sync is off (argocdclaim_hold.go). Holding the applier is +// something else: an applier claim handing its applier back still holds it, and is not in control. +// An applier claim is Ready only once it is in control. +const ApplierClaimConditionInControl = applier.ApplierClaimConditionInControl + +// InControl reasons, and the Events of the hold lifecycle on an applier claim. const ( - // ReasonSuspended is Held=True: the Kustomization is suspended, the last suspend handled, and + // ReasonSuspended is InControl=True: the Kustomization is suspended, the last suspend handled, and // nothing applies. ReasonSuspended = "Suspended" - // ReasonApplying is Held=True while an operation of a branch worker is in flight and followed: + // ReasonApplying is InControl=True while an operation of a branch worker is in flight and followed: // the Kustomization is resumed for it, and suspended again when it ends. ReasonApplying = "Applying" - // ReasonSuspending is Held=False while the token of the suspend is not handled yet. + // ReasonSuspending is InControl=False while the token of the suspend is not handled yet. ReasonSuspending = "Suspending" - // ReasonNotConfigured is Held=Unknown on an applier claim that is not Configured: nothing is held, or + // ReasonNotConfigured is InControl=Unknown on an applier claim that is not Configured: nothing is held, or // the Kustomization is left suspended as the applier claim last held it (the message says which). ReasonNotConfigured = "NotConfigured" - // ReasonReleasing is Held=False on an applier claim that held its Kustomization and is handing it back: - // it is being deleted, or no eligible GitTarget references it any more. Held=False with + // ReasonReleasing is InControl=False on an applier claim that held its Kustomization and is handing + // it back: it is being deleted, or no eligible GitTarget references it any more. InControl=False with // applier.ReasonNotReferenced is an applier claim with nothing to hold. ReasonReleasing = "Releasing" + // ReasonRevoking is InControl=False on an applier claim that held its applier and is handing it back + // because its KubeProvider's spec.allowedApplierClaims no longer admit it. The hand-back finishes + // even if a grant admits the applier claim again meanwhile, which then adopts its applier anew. + ReasonRevoking = "Revoking" // EventReasonAdopted is a Kustomization suspended, or an Application's automated sync switched // off, for the first time. EventReasonAdopted = "Adopted" @@ -150,7 +156,7 @@ type ApplierClaimReconciler struct { // +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch -// Reconcile publishes one applier claim's Configured and Held conditions, its source and the readiness +// Reconcile publishes one applier claim's Configured and InControl conditions, its source and the readiness // trio. func (r *ApplierClaimReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { claim := configbutleraiv1alpha3.NewApplierClaim(r.Kind) @@ -191,12 +197,12 @@ func (r *ApplierClaimReconciler) reconcileFlux( if check.retiring(claim) { return r.retire(ctx, st, claim, &check) } - held, err := r.holdFlux(ctx, st, claim, check) + inControl, err := r.holdFlux(ctx, st, claim, check) if err != nil { return ctrl.Result{}, err } - st.setValue(ApplierClaimConditionHeld, held) - return r.commitClaim(ctx, st, r.claimReadiness(claim, check.configured, &held), &held, + st.setValue(ApplierClaimConditionInControl, inControl) + return r.commitClaim(ctx, st, r.claimReadiness(claim, check.configured, &inControl), &inControl, heldClaimSteadyInterval) } @@ -212,7 +218,7 @@ func publishCheck(st *reconcileStatus, claim configbutleraiv1alpha3.ApplierClaim // applier claim again on: steady, or sooner while a suspend is waited for or something may clear on its // own. func (r *ApplierClaimReconciler) commitClaim( - ctx context.Context, st *reconcileStatus, rd *readiness, held *conditionValue, steady time.Duration, + ctx context.Context, st *reconcileStatus, rd *readiness, inControl *conditionValue, steady time.Duration, ) (ctrl.Result, error) { st.applyReadiness(rd) if err := st.commit(ctx); err != nil { @@ -220,7 +226,7 @@ func (r *ApplierClaimReconciler) commitClaim( } requeue := steady switch { - case held != nil && held.Reason == ReasonSuspending: + case inControl != nil && inControl.Reason == ReasonSuspending: requeue = applierClaimSuspendingInterval case rd.converging(): requeue = applierClaimSettleInterval @@ -228,7 +234,7 @@ func (r *ApplierClaimReconciler) commitClaim( return ctrl.Result{RequeueAfter: st.requeueAfter(requeue)}, nil } -// holdFlux returns a FluxClaim's Held condition. A Configured applier claim holds its Kustomization, +// holdFlux returns a FluxClaim's InControl condition. A Configured applier claim holds its Kustomization, // with its finalizer put in place before the first adoption write, and then publishes its source // consumers. One that is not Configured holds nothing, // and one in line behind another holder lets go of a finalizer it carries, handing nothing back. @@ -242,11 +248,11 @@ func (r *ApplierClaimReconciler) holdFlux( if err := r.setFinalizer(ctx, st, claim, true); err != nil { return conditionValue{}, err } - held := r.holdKustomization(ctx, st, claim) + inControl := r.holdKustomization(ctx, st, claim) if check.checked { - r.publishConsumers(ctx, claim) + r.publishAlsoAppliedBy(ctx, claim) } - return held, nil + return inControl, nil case configured.Reason == applier.ReasonNotReferenced: return conditionValue{Status: metav1.ConditionFalse, Reason: applier.ReasonNotReferenced, Message: "nothing to hold: no GitTarget names this applier claim"}, nil @@ -279,13 +285,22 @@ type claimCheck struct { // refs are the GitTargets naming the applier claim, judged; judged is false when they could not be. refs applier.References judged bool + // denied is a conclusive refusal by the KubeProvider's spec.allowedApplierClaims: the applier + // claim's namespace is not admitted, or its applier is outside the applierNamespaces it is given. + // A read error is never a denial. + denied bool } -// retiring reports whether claim holds its applier and no eligible GitTarget references it any -// more, conclusively: none names it, or each is refused. A reference that cannot be judged yet -// keeps the hold, since a release is not undone: Flux applies at once, and Argo CD syncs. +// retiring reports whether claim holds its applier and must hand it back, conclusively: its +// KubeProvider's grants refuse it, or no eligible GitTarget references it any more (none names it, +// or each is refused). A hand-back that began on a denial finishes even once a grant admits the +// applier claim again; it then adopts the applier anew. A reference that cannot be judged yet keeps +// the hold, since a release is not undone: Flux applies at once, and Argo CD syncs. func (c claimCheck) retiring(claim configbutleraiv1alpha3.ApplierClaim) bool { - return applier.Holding(claim) && c.judged && (!c.refs.Named() || c.refs.Refused()) + if !applier.Holding(claim) { + return false + } + return c.denied || revoking(claim) || (c.judged && (!c.refs.Named() || c.refs.Refused())) } // configuredVerdict judges the GitTargets naming the applier claim, and, while one may be eligible, @@ -321,7 +336,10 @@ func (r *ApplierClaimReconciler) configuredVerdict( case err != nil: return kept(fmt.Sprintf("Cannot check KubeProvider %s: %v", cluster, err)) case !decision.Allowed: - return falseCheck(decision.Reason, decision.Message, refs) + check := falseCheck(decision.Reason, decision.Message, refs) + check.denied = decision.Reason == authz.ReasonNamespaceNotAuthorized || + decision.Reason == authz.ReasonApplierNamespaceNotAllowed + return check case refs.Refused(): return falseCheck(refs.Reason(), refs.Message(), refs) } @@ -448,15 +466,15 @@ func claimSourceSummary(claim configbutleraiv1alpha3.ApplierClaim) string { // claimReadiness is the trio's accumulator for an applier claim, its gates contributed. func (r *ApplierClaimReconciler) claimReadiness( - claim configbutleraiv1alpha3.ApplierClaim, configured conditionValue, held *conditionValue, + claim configbutleraiv1alpha3.ApplierClaim, configured conditionValue, inControl *conditionValue, ) *readiness { rd := newReadiness(fmt.Sprintf("%s %s %s can be asked to apply", claim.ApplierKind(), claim.Applier(), claimSourceSummary(claim)), claim.ClaimKind()+" is not stalled") - applierClaimReadinessGates(rd, configured, held, claim.Operation()) + applierClaimReadinessGates(rd, configured, inControl, claim.Operation()) return rd } -// applierClaimReadinessGates contributes Configured, the operation, and Held to the trio, in that +// applierClaimReadinessGates contributes Configured, the operation, and InControl to the trio, in that // order. // An applier that is missing, or a KubeProvider that is, may be on its way (Flux and Argo CD // objects are often created after the applier claim); anything else Configured=False needs a human. An @@ -464,7 +482,8 @@ func (r *ApplierClaimReconciler) claimReadiness( // names is Ready (NotReferenced): it does all it is asked to, which is nothing, and a Flux health // check that waits on it must not wait for the GitTarget that will name it. func applierClaimReadinessGates( - rd *readiness, configured conditionValue, held *conditionValue, operation *configbutleraiv1alpha3.ApplyOperation, + rd *readiness, configured conditionValue, inControl *conditionValue, + operation *configbutleraiv1alpha3.ApplyOperation, ) { if configured.Reason == applier.ReasonNotReferenced { rd.convergesAs(conditionValue{Status: metav1.ConditionTrue, Reason: applier.ReasonNotReferenced, @@ -486,9 +505,9 @@ func applierClaimReadinessGates( rd.progressing(metav1.ConditionFalse, ReasonOperationUnresolved, fmt.Sprintf( "%s %s, has not completed: %s", what, operation.RequestedAt.UTC().Format(time.RFC3339), operation.Message)) } - if held != nil { - rd.progressingIf(held.Status != metav1.ConditionTrue && held.Reason != applier.ReasonNotReferenced, - held.Status, held.Reason, held.Message) + if inControl != nil { + rd.progressingIf(inControl.Status != metav1.ConditionTrue && inControl.Reason != applier.ReasonNotReferenced, + inControl.Status, inControl.Reason, inControl.Message) } } @@ -524,7 +543,7 @@ func (r *ApplierClaimReconciler) SetupWithManager(mgr ctrl.Manager) error { handler.EnqueueRequestsFromMapFunc(r.kubeProviderToClaims), builder.WithPredicates(predicate.GenerationChangedPredicate{}), ). - // An accessFrom selector reads namespace labels. + // A Selector grant reads namespace labels. Watches( &corev1.Namespace{}, handler.EnqueueRequestsFromMapFunc(r.namespaceToClaims), @@ -581,7 +600,7 @@ func (r *ApplierClaimReconciler) listClaims( } // claimLineChanged passes the event of an applier claim that is gone, and an update that can move it in -// or out of line for its applier: its finalizer, its Configured condition, or its Held one. +// or out of line for its applier: its finalizer, its Configured condition, or its InControl one. func claimLineChanged() predicate.Predicate { return predicate.Funcs{ CreateFunc: func(event.CreateEvent) bool { return false }, @@ -599,7 +618,7 @@ func claimLineChanged() predicate.Predicate { func lineSummary(claim configbutleraiv1alpha3.ApplierClaim) string { var summary strings.Builder summary.WriteString(strconv.FormatBool(applier.Holding(claim))) - for _, conditionType := range []string{ApplierClaimConditionConfigured, ApplierClaimConditionHeld} { + for _, conditionType := range []string{ApplierClaimConditionConfigured, ApplierClaimConditionInControl} { if cond := findCondition(*claim.StatusConditions(), conditionType); cond != nil { summary.WriteString("|" + string(cond.Status) + "/" + cond.Reason) } diff --git a/internal/controller/applierclaim_controller_test.go b/internal/controller/applierclaim_controller_test.go index b51150b85..1def4d895 100644 --- a/internal/controller/applierclaim_controller_test.go +++ b/internal/controller/applierclaim_controller_test.go @@ -101,7 +101,7 @@ func applierSim(t *testing.T, objs ...runtime.Object) *fluxsim.Sim { } // The applier claim tests reach their appliers through KubeProviders in claimProviderNamespace, a -// namespace of their own, so a provider admits team-a only through spec.accessFrom: the namespace +// namespace of their own, so a provider admits team-a only through a grant: the namespace // policy is what those tests exercise. claimLocalProvider is the operator's own cluster there. const ( claimProviderNamespace = "clusters" @@ -119,16 +119,16 @@ func claimProviderKey(name string) string { return claimProviderNamespace + "/" + name } -// admitting is a KubeProvider in claimProviderNamespace whose accessFrom admits namespaces, and -// which lets an applier claim name an applier in another namespace (the test appliers live in flux-system -// and argocd). +// admitting is a KubeProvider in claimProviderNamespace that admits namespaces to mirror their own +// namespace, and to hold appliers in any namespace (the test appliers live in flux-system and +// argocd). func admitting(name string, namespaces ...string) *configbutleraiv1alpha3.KubeProvider { return &configbutleraiv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: claimProviderNamespace}, Spec: configbutleraiv1alpha3.KubeProviderSpec{ - InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, - AccessFrom: &configbutleraiv1alpha3.NamespaceMatcher{Names: namespaces}, - AllowCrossNamespace: &configbutleraiv1alpha3.KubeProviderCrossNamespace{Appliers: true}, + InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, + AllowedGitTargets: mirrorGrants(namespaces...), + AllowedApplierClaims: applierGrants([]configbutleraiv1alpha3.ScopeNamespace{"*"}, namespaces...), }, } } @@ -235,6 +235,7 @@ func claimTestClient(t *testing.T, objects ...ctrlclient.Object) ctrlclient.Clie referencingTarget("checkout-argo", configbutleraiv1alpha3.ApplierClaimKindArgoCD)) } return fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...). + WithIndex(&configbutleraiv1alpha3.GitBranch{}, GitBranchProviderIndex, IndexGitBranchProvider). WithStatusSubresource(&configbutleraiv1alpha3.FluxClaim{}, &configbutleraiv1alpha3.ArgoCDClaim{}). Build() } @@ -280,13 +281,13 @@ func reconcileClaim(t *testing.T, r *ApplierClaimReconciler) configbutleraiv1alp require.NoError(t, r.Get(context.Background(), claimKey, claim)) require.NotNil(t, findCondition(*claim.StatusConditions(), ConditionTypeReady), "every pass publishes Ready") - held := findCondition(*claim.StatusConditions(), ApplierClaimConditionHeld) - if conditionIsTrue(*claim.StatusConditions(), ApplierClaimConditionConfigured) && held != nil && - !strings.Contains(held.Message, "which owns the switch") { + inControl := findCondition(*claim.StatusConditions(), ApplierClaimConditionInControl) + if conditionIsTrue(*claim.StatusConditions(), ApplierClaimConditionConfigured) && inControl != nil && + !strings.Contains(inControl.Message, "which owns the switch") { require.True(t, controllerutil.ContainsFinalizer(claim, claimFinalizer(claim)), "an applier claim that holds its applier carries the finalizer") } - if held != nil && held.Reason == ReasonSuspending { + if inControl != nil && inControl.Reason == ReasonSuspending { assert.Equal(t, applierClaimSuspendingInterval, result.RequeueAfter, "a suspend is waited for closely") return false } @@ -463,7 +464,7 @@ func TestApplierClaimReconcile_AHangingClusterIsBounded(t *testing.T) { } // TestApplierClaimReconcile_TheClaimsKubeProviderAdmitsIt: the applier is reached with the -// applier claim's KubeProvider's credential, so that provider's accessFrom gates the check before the +// applier claim's KubeProvider's credential, so that provider's allowedApplierClaims gate the check before the // cluster is ever asked. func TestApplierClaimReconcile_TheClaimsKubeProviderAdmitsIt(t *testing.T) { cases := []struct { diff --git a/internal/controller/applierclaim_holder_test.go b/internal/controller/applierclaim_holder_test.go index a6b9f0206..214751cc9 100644 --- a/internal/controller/applierclaim_holder_test.go +++ b/internal/controller/applierclaim_holder_test.go @@ -64,8 +64,8 @@ func (f *holdFixture) reconcileKey(t *testing.T, key k8stypes.NamespacedName) *c _, err := f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: key}) require.NoError(t, err) require.NoError(t, f.r.Get(context.Background(), key, claim)) - held := findCondition(claim.Status.Conditions, ApplierClaimConditionHeld) - return held == nil || held.Reason != ReasonSuspending + inControl := findCondition(claim.Status.Conditions, ApplierClaimConditionInControl) + return inControl == nil || inControl.Reason != ReasonSuspending }, 5*time.Second, 5*time.Millisecond) return claim } @@ -94,7 +94,7 @@ func TestFluxClaim_OneClaimHoldsAKustomization(t *testing.T) { second := f.reconcileKey(t, inLineKey) first := f.reconcileKey(t, claimKey) - assert.True(t, conditionIsTrue(first.Status.Conditions, ApplierClaimConditionHeld), "the first holds it") + assert.True(t, conditionIsTrue(first.Status.Conditions, ApplierClaimConditionInControl), "the first holds it") assert.True(t, conditionIsTrue(first.Status.Conditions, ConditionTypeReady)) configured := findCondition(second.Status.Conditions, ApplierClaimConditionConfigured) require.NotNil(t, configured) @@ -108,10 +108,10 @@ func TestFluxClaim_OneClaimHoldsAKustomization(t *testing.T) { assert.Equal(t, metav1.ConditionFalse, ready.Status) assert.Equal(t, applier.ReasonHeldByAnotherClaim, ready.Reason) assert.True(t, conditionIsTrue(second.Status.Conditions, ConditionTypeStalled), "a human removes one of them") - held := findCondition(second.Status.Conditions, ApplierClaimConditionHeld) - require.NotNil(t, held) - assert.Equal(t, metav1.ConditionUnknown, held.Status) - assert.Equal(t, ReasonNotConfigured, held.Reason) + inControl := findCondition(second.Status.Conditions, ApplierClaimConditionInControl) + require.NotNil(t, inControl) + assert.Equal(t, metav1.ConditionUnknown, inControl.Status) + assert.Equal(t, ReasonNotConfigured, inControl.Reason) events := f.events() require.Len(t, events, 1, "one adoption, by the holder: %v", events) @@ -174,7 +174,7 @@ func TestFluxClaim_DeletingTheClaimInLineHandsNothingBack(t *testing.T) { assert.Contains(t, kustomizationManagers(ks), applier.SuspendFieldManager) assert.Empty(t, f.events(), "nothing released") holder := f.reconcileKey(t, claimKey) - assert.True(t, conditionIsTrue(holder.Status.Conditions, ApplierClaimConditionHeld)) + assert.True(t, conditionIsTrue(holder.Status.Conditions, ApplierClaimConditionInControl)) } // TestFluxClaim_DeletingTheHolderHandsOverToTheNextInLine: the holder suspends the Kustomization as @@ -205,7 +205,7 @@ func TestFluxClaim_DeletingTheHolderHandsOverToTheNextInLine(t *testing.T) { second := f.reconcileKey(t, inLineKey) assert.True(t, conditionIsTrue(second.Status.Conditions, ApplierClaimConditionConfigured)) - assert.True(t, conditionIsTrue(second.Status.Conditions, ApplierClaimConditionHeld)) + assert.True(t, conditionIsTrue(second.Status.Conditions, ApplierClaimConditionInControl)) assert.True(t, conditionIsTrue(second.Status.Conditions, ConditionTypeReady)) assert.True(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization))) assert.Equal(t, []string{"Normal Released left Kustomization flux-system/checkout suspended for FluxClaim " + diff --git a/internal/controller/applierclaim_revocation_test.go b/internal/controller/applierclaim_revocation_test.go new file mode 100644 index 000000000..feb70768f --- /dev/null +++ b/internal/controller/applierclaim_revocation_test.go @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + k8stypes "k8s.io/apimachinery/pkg/types" + k8stesting "k8s.io/client-go/testing" + ctrl "sigs.k8s.io/controller-runtime" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/applier/fluxsim" + "github.com/ConfigButler/gitops-reverser/internal/authz" +) + +var localProviderKey = k8stypes.NamespacedName{Namespace: claimProviderNamespace, Name: claimLocalProvider} + +// setApplierGrants replaces the local KubeProvider's allowedApplierClaims. +func (f *holdFixture) setApplierGrants(t *testing.T, grants []configbutleraiv1alpha3.KubeProviderApplierClaimGrant) { + t.Helper() + provider := &configbutleraiv1alpha3.KubeProvider{} + require.NoError(t, f.r.Get(context.Background(), localProviderKey, provider)) + provider.Spec.AllowedApplierClaims = grants + require.NoError(t, f.r.Update(context.Background(), provider)) +} + +// reconcileUntil reconciles the applier claim until done reports true for it. +func (f *holdFixture) reconcileUntil(t *testing.T, done func(*configbutleraiv1alpha3.FluxClaim) bool) { + t.Helper() + require.Eventually(t, func() bool { + _, err := f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) + require.NoError(t, err) + return done(f.claim(t)) + }, 5*time.Second, 5*time.Millisecond) +} + +func handedBack(claim *configbutleraiv1alpha3.FluxClaim) bool { + return !controllerutil.ContainsFinalizer(claim, configbutleraiv1alpha3.FluxClaimFinalizer) +} + +// TestFluxClaim_ADenialHandsTheKustomizationBack: an applier claim its KubeProvider's +// allowedApplierClaims no longer admit takes the retire path: it hands its Kustomization back and lets +// go, and says why. +func TestFluxClaim_ADenialHandsTheKustomizationBack(t *testing.T) { + f := newHoldFixture(t, testFluxClaim(claimLocalProvider)) + reconcileClaim(t, f.r) + require.True(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization))) + f.events() + + f.setApplierGrants(t, nil) + f.reconcileUntil(t, handedBack) + + got := f.claim(t) + assert.False(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization)), "handed back to Flux") + configured := findCondition(got.Status.Conditions, ApplierClaimConditionConfigured) + require.NotNil(t, configured) + assert.Equal(t, authz.ReasonNamespaceNotAuthorized, configured.Reason) + assert.Contains(t, configured.Message, "spec.allowedApplierClaims") + inControl := findCondition(got.Status.Conditions, ApplierClaimConditionInControl) + require.NotNil(t, inControl) + assert.Contains(t, inControl.Message, "held again once the KubeProvider's spec.allowedApplierClaims admit") + events := f.events() + require.Len(t, events, 1) + assert.Contains(t, events[0], EventReasonReleased) +} + +// TestFluxClaim_AReadErrorKeepsTheHold: a KubeProvider that cannot be read is no denial, so nothing +// is handed back. +func TestFluxClaim_AReadErrorKeepsTheHold(t *testing.T) { + f := newHoldFixture(t, testFluxClaim(claimLocalProvider)) + reconcileClaim(t, f.r) + f.events() + f.r.Client = interceptor.NewClient(f.r.Client.(ctrlclient.WithWatch), interceptor.Funcs{ + Get: func(ctx context.Context, c ctrlclient.WithWatch, key ctrlclient.ObjectKey, obj ctrlclient.Object, + opts ...ctrlclient.GetOption) error { + if _, ok := obj.(*configbutleraiv1alpha3.KubeProvider); ok { + return errors.New("apiserver unavailable") + } + return c.Get(ctx, key, obj, opts...) + }, + }) + + _, err := f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) + require.NoError(t, err) + + got := f.claim(t) + assert.True(t, controllerutil.ContainsFinalizer(got, configbutleraiv1alpha3.FluxClaimFinalizer)) + assert.True(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization)), "still held") + assert.Empty(t, f.events()) +} + +// TestFluxClaim_ADenialKeepsASuspendedKustomizationSuspended: the hand-back restores the state +// recorded before adoption. +func TestFluxClaim_ADenialKeepsASuspendedKustomizationSuspended(t *testing.T) { + objs := applierFluxObjects() + objs[0] = fluxsim.OwnSuspend(t, objs[0].(*unstructured.Unstructured), true, "flux", + metav1.ManagedFieldsOperationUpdate) + f := newHoldFixture(t, testFluxClaim(claimLocalProvider), objs...) + got := reconcileClaim(t, f.r).(*configbutleraiv1alpha3.FluxClaim) + require.Equal(t, configbutleraiv1alpha3.SuspendStateSuspended, got.Status.PreviousSuspendState) + + f.setApplierGrants(t, nil) + f.reconcileUntil(t, handedBack) + + assert.True(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization)), + "suspended before adoption, suspended after the hand-back") +} + +// TestFluxClaim_ADenialThatCannotHandBackGivesUp takes the retire path's two ways out. A Kustomization +// the identity may no longer patch is let go at once; a cluster that answers with errors keeps the +// applier claim Releasing in status until releaseDeadline. Both say so in a Warning Event. +func TestFluxClaim_ADenialThatCannotHandBackGivesUp(t *testing.T) { + for name, tc := range map[string]struct { + err error + atOnce bool + wantEvent string + }{ + "the identity lost its RBAC": { + err: apierrors.NewForbidden(fluxsim.KustomizationGVR().GroupResource(), "checkout", + errors.New("no patch for you")), + atOnce: true, wantEvent: "Warning ReleaseFailed could not hand Kustomization", + }, + "the cluster keeps failing": { + err: apierrors.NewServiceUnavailable("unavailable"), + wantEvent: "Warning ReleaseFailed gave up handing Kustomization", + }, + } { + t.Run(name, func(t *testing.T) { + f := newHoldFixture(t, testFluxClaim(claimLocalProvider)) + reconcileClaim(t, f.r) + resume(t, f) // the hand-back must suspend once more, and that patch fails + f.events() + f.sim.Client.PrependReactor( + "patch", + "kustomizations", + func(k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, tc.err + }, + ) + f.setApplierGrants(t, nil) + + _, _ = f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) + got := f.claim(t) + if !tc.atOnce { + require.False(t, handedBack(got)) + assert.Equal(t, ReasonRevoking, inControlReason(t, got), "the hand-back shows in status") + inControl := findCondition(got.Status.Conditions, ApplierClaimConditionInControl) + inControl.LastTransitionTime = metav1.NewTime(time.Now().Add(-releaseDeadline - time.Minute)) + require.NoError(t, f.r.Status().Update(context.Background(), got)) + _, _ = f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) + got = f.claim(t) + } + assert.True(t, handedBack(got), "it lets go") + events := f.events() + require.NotEmpty(t, events) + assert.True(t, strings.HasPrefix(events[len(events)-1], tc.wantEvent), events) + }) + } +} + +// TestFluxClaim_ARegrantDuringTheHandBackLetsItFinish: a regrant while the hand-back waits on its +// suspend lets the hand-back finish, and then the applier claim adopts the Kustomization anew. +func TestFluxClaim_ARegrantDuringTheHandBackLetsItFinish(t *testing.T) { + f := ruleFixture(t, testFluxClaim(claimLocalProvider), fluxTarget()) + f.settle(t, claimKey) + f.events() + grants := applierGrants([]configbutleraiv1alpha3.ScopeNamespace{"*"}, "team-a") + resume(t, f) + unpause := f.sim.PauseKustomizations() + f.setApplierGrants(t, nil) + _, err := f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) + require.NoError(t, err) + require.Equal(t, ReasonRevoking, inControlReason(t, f.claim(t))) + + f.setApplierGrants(t, grants) + _, err = f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) + require.NoError(t, err) + assert.Equal(t, ReasonRevoking, inControlReason(t, f.claim(t)), "the hand-back is not abandoned") + + unpause() + f.reconcileUntil(t, func(claim *configbutleraiv1alpha3.FluxClaim) bool { + return inControlReason(t, claim) == ReasonSuspended + }) + assert.True(t, controllerutil.ContainsFinalizer(f.claim(t), configbutleraiv1alpha3.FluxClaimFinalizer)) + var reasons []string + for _, event := range f.events() { + for _, reason := range []string{EventReasonReleased, EventReasonAdopted} { + if strings.Contains(event, " "+reason+" ") { + reasons = append(reasons, reason) + } + } + } + assert.Equal(t, []string{EventReasonReleased, EventReasonAdopted}, reasons, + "handed back first, then adopted anew") +} diff --git a/internal/controller/applierclaim_rule_test.go b/internal/controller/applierclaim_rule_test.go index d143bb521..15bac30a4 100644 --- a/internal/controller/applierclaim_rule_test.go +++ b/internal/controller/applierclaim_rule_test.go @@ -47,7 +47,7 @@ func fluxTarget() *configbutleraiv1alpha3.GitTarget { return referencingTarget("checkout-flux", configbutleraiv1alpha3.ApplierClaimKindFlux) } -// settle reconciles the applier claim at key until its Held condition is neither Suspending nor +// settle reconciles the applier claim at key until its InControl condition is neither Suspending nor // Releasing, and returns it. func (f *holdFixture) settle(t *testing.T, key k8stypes.NamespacedName) *configbutleraiv1alpha3.FluxClaim { t.Helper() @@ -56,18 +56,18 @@ func (f *holdFixture) settle(t *testing.T, key k8stypes.NamespacedName) *configb _, err := f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: key}) require.NoError(t, err) require.NoError(t, f.r.Get(context.Background(), key, claim)) - held := findCondition(claim.Status.Conditions, ApplierClaimConditionHeld) - return held != nil && held.Reason != ReasonSuspending && held.Reason != ReasonReleasing + inControl := findCondition(claim.Status.Conditions, ApplierClaimConditionInControl) + return inControl != nil && inControl.Reason != ReasonSuspending && !handingBack(inControl) }, 5*time.Second, 5*time.Millisecond) return claim } -// heldReason is the applier claim's Held reason. -func heldReason(t *testing.T, claim *configbutleraiv1alpha3.FluxClaim) string { +// inControlReason is the applier claim's InControl reason. +func inControlReason(t *testing.T, claim *configbutleraiv1alpha3.FluxClaim) string { t.Helper() - held := findCondition(claim.Status.Conditions, ApplierClaimConditionHeld) - require.NotNil(t, held) - return held.Reason + inControl := findCondition(claim.Status.Conditions, ApplierClaimConditionInControl) + require.NotNil(t, inControl) + return inControl.Reason } // TestApplierClaimRule_AnUnreferencedClaimDoesNothingAndIsReady: no call reaches the applier's @@ -103,7 +103,7 @@ func TestApplierClaimRule_AnUnreferencedClaimDoesNothingAndIsReady(t *testing.T) url, _, known := got.SourceBinding() assert.False(t, known, "no source is published: %s", url) if flux, ok := got.(*configbutleraiv1alpha3.FluxClaim); ok { - assert.Equal(t, applier.ReasonNotReferenced, heldReason(t, flux)) + assert.Equal(t, applier.ReasonNotReferenced, inControlReason(t, flux)) } }) } @@ -114,13 +114,13 @@ func TestApplierClaimRule_AnUnreferencedClaimDoesNothingAndIsReady(t *testing.T) func TestApplierClaimRule_TheFirstEligibleReferenceAdoptsAndTheLastReleases(t *testing.T) { f := ruleFixture(t, testFluxClaim(claimLocalProvider)) idle := f.settle(t, claimKey) - assert.Equal(t, applier.ReasonNotReferenced, heldReason(t, idle)) + assert.Equal(t, applier.ReasonNotReferenced, inControlReason(t, idle)) assert.False(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization)), "Flux applies it on its own") target := fluxTarget() require.NoError(t, f.r.Create(context.Background(), target)) held := f.settle(t, claimKey) - assert.Equal(t, ReasonSuspended, heldReason(t, held)) + assert.Equal(t, ReasonSuspended, inControlReason(t, held)) assert.True(t, conditionIsTrue(held.Status.Conditions, ConditionTypeReady)) assert.True(t, controllerutil.ContainsFinalizer(held, configbutleraiv1alpha3.FluxClaimFinalizer)) assert.True(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization))) @@ -130,7 +130,7 @@ func TestApplierClaimRule_TheFirstEligibleReferenceAdoptsAndTheLastReleases(t *t require.NoError(t, f.r.Delete(context.Background(), target)) released := f.settle(t, claimKey) - assert.Equal(t, applier.ReasonNotReferenced, heldReason(t, released)) + assert.Equal(t, applier.ReasonNotReferenced, inControlReason(t, released)) ready := findCondition(released.Status.Conditions, ConditionTypeReady) require.NotNil(t, ready) assert.Equal(t, metav1.ConditionTrue, ready.Status) @@ -160,7 +160,7 @@ func TestApplierClaimRule_ARecreatedGitTargetReleasesAndReadopts(t *testing.T) { require.NoError(t, f.r.Create(context.Background(), fluxTarget())) again := f.settle(t, claimKey) - assert.Equal(t, ReasonSuspended, heldReason(t, again)) + assert.Equal(t, ReasonSuspended, inControlReason(t, again)) assert.True(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization))) var reasons []string for _, event := range f.events() { @@ -264,7 +264,7 @@ func TestApplierClaimRule_AnOlderClaimNotInLineKeepsNoneOut(t *testing.T) { assert.True(t, conditionIsTrue(got.Status.Conditions, ApplierClaimConditionConfigured), findCondition(got.Status.Conditions, ApplierClaimConditionConfigured).Message) - assert.Equal(t, ReasonSuspended, heldReason(t, got)) + assert.Equal(t, ReasonSuspended, inControlReason(t, got)) strayConfigured := findCondition(f.settle(t, inLineKey).Status.Conditions, ApplierClaimConditionConfigured) require.NotNil(t, strayConfigured) assert.NotEqual(t, metav1.ConditionTrue, strayConfigured.Status) @@ -276,7 +276,7 @@ func TestApplierClaimRule_AnOlderClaimNotInLineKeepsNoneOut(t *testing.T) { // TestApplierClaimRule_ARetiringHolderStaysInLineUntilItsReleaseIsDone: the holder's GitTarget goes // while a successor waits. Until the holder has suspended the Kustomization and seen that handled, -// it stays first in line, Held=False (Releasing), and the successor does nothing; then the holder +// it stays first in line, InControl=False (Releasing), and the successor does nothing; then the holder // leaves the Kustomization suspended for it, and the successor takes it over. func TestApplierClaimRule_ARetiringHolderStaysInLineUntilItsReleaseIsDone(t *testing.T) { holder := testFluxClaim(claimLocalProvider) @@ -304,7 +304,7 @@ func TestApplierClaimRule_ARetiringHolderStaysInLineUntilItsReleaseIsDone(t *tes _, err := f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) require.NoError(t, err) retiring := f.claim(t) - assert.Equal(t, ReasonReleasing, heldReason(t, retiring)) + assert.Equal(t, ReasonReleasing, inControlReason(t, retiring)) assert.True(t, controllerutil.ContainsFinalizer(retiring, configbutleraiv1alpha3.FluxClaimFinalizer)) assert.True(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization)), "suspended first") _, err = f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: inLineKey}) @@ -322,7 +322,7 @@ func TestApplierClaimRule_ARetiringHolderStaysInLineUntilItsReleaseIsDone(t *tes assert.True(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization)), "left suspended for the successor") taken := f.settle(t, inLineKey) assert.True(t, conditionIsTrue(taken.Status.Conditions, ApplierClaimConditionConfigured)) - assert.Equal(t, ReasonSuspended, heldReason(t, taken)) + assert.Equal(t, ReasonSuspended, inControlReason(t, taken)) assert.Equal(t, configbutleraiv1alpha3.SuspendStateNotSuspended, taken.Status.PreviousSuspendState, "passed on by the holder: the successor took the Kustomization over suspended already") events := f.events() @@ -355,13 +355,13 @@ func TestApplierClaimRule_AReferenceReturningDuringTheReleaseHoldsAgain(t *testi require.NoError(t, f.r.Delete(context.Background(), target)) _, err := f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) require.NoError(t, err) - assert.Equal(t, ReasonReleasing, heldReason(t, f.claim(t))) + assert.Equal(t, ReasonReleasing, inControlReason(t, f.claim(t))) require.NoError(t, f.r.Create(context.Background(), fluxTarget())) unpause() back := f.settle(t, claimKey) - assert.Equal(t, ReasonSuspended, heldReason(t, back)) + assert.Equal(t, ReasonSuspended, inControlReason(t, back)) assert.True(t, controllerutil.ContainsFinalizer(back, configbutleraiv1alpha3.FluxClaimFinalizer)) assert.True(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization)), "never handed back") for _, event := range f.events() { @@ -413,11 +413,11 @@ func TestApplierClaimRule_AHolderLeftUnconfiguredSaysTheKustomizationIsSuspended configured := findCondition(got.Status.Conditions, ApplierClaimConditionConfigured) require.NotNil(t, configured) assert.Equal(t, applier.ReasonForbidden, configured.Reason) - held := findCondition(got.Status.Conditions, ApplierClaimConditionHeld) - require.NotNil(t, held) - assert.Equal(t, metav1.ConditionUnknown, held.Status) - assert.Equal(t, ReasonNotConfigured, held.Reason) - assert.Contains(t, held.Message, "is left suspended under GitOps Reverser's field manager") + inControl := findCondition(got.Status.Conditions, ApplierClaimConditionInControl) + require.NotNil(t, inControl) + assert.Equal(t, metav1.ConditionUnknown, inControl.Status) + assert.Equal(t, ReasonNotConfigured, inControl.Reason) + assert.Contains(t, inControl.Message, "is left suspended under GitOps Reverser's field manager") assert.True(t, controllerutil.ContainsFinalizer(got, configbutleraiv1alpha3.FluxClaimFinalizer)) assert.True(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization))) } @@ -437,27 +437,27 @@ func TestApplierClaimRule_AGitTargetEventRerunsTheClaimsItNames(t *testing.T) { assert.Empty(t, argo.gitDestinationToClaims(context.Background(), platformProvider()), "another kind's") } -// TestApplierClaimRule_AnApplierInAnotherNamespaceNeedsTheOptIn is B1: a namespace a KubeProvider -// admits may suspend and apply an applier in another namespace only when the provider sets -// spec.allowCrossNamespace.appliers; one in the applier claim's own namespace needs nothing. Refused, the -// applier claim never asks the applier's cluster, and holds nothing. -func TestApplierClaimRule_AnApplierInAnotherNamespaceNeedsTheOptIn(t *testing.T) { +// TestApplierClaimRule_AnApplierInAnotherNamespaceNeedsItInTheGrant is B1: a namespace a KubeProvider +// admits may suspend and apply an applier in another namespace only when the applierNamespaces its +// spec.allowedApplierClaims give it include that namespace; '{consumer.namespace}' covers the applier claim's own. +// Refused, the applier claim never asks the applier's cluster, and holds nothing. +func TestApplierClaimRule_AnApplierInAnotherNamespaceNeedsItInTheGrant(t *testing.T) { for _, kind := range []string{configbutleraiv1alpha3.ApplierClaimKindFlux, configbutleraiv1alpha3.ApplierClaimKindArgoCD} { for name, tc := range map[string]struct { - sameNamespace, optIn bool - wantStatus metav1.ConditionStatus - wantReason string + sameNamespace, granted bool + wantStatus metav1.ConditionStatus + wantReason string }{ - "another namespace, without the opt-in": { + "another namespace, outside applierNamespaces": { wantStatus: metav1.ConditionFalse, wantReason: authz.ReasonApplierNamespaceNotAllowed, }, - "another namespace, with the opt-in": {optIn: true, wantStatus: metav1.ConditionTrue, wantReason: ReasonSucceeded}, - "its own namespace, which needs nothing": { + "another namespace, granted": {granted: true, wantStatus: metav1.ConditionTrue, wantReason: ReasonSucceeded}, + "its own namespace, which '{consumer.namespace}' grants": { sameNamespace: true, wantStatus: metav1.ConditionTrue, wantReason: ReasonSucceeded, }, } { t.Run(kind+"/"+name, func(t *testing.T) { - r, calls := applierNamespaceReconciler(t, kind, tc.sameNamespace, tc.optIn) + r, calls := applierNamespaceReconciler(t, kind, tc.sameNamespace, tc.granted) got := reconcileClaim(t, r) @@ -466,7 +466,7 @@ func TestApplierClaimRule_AnApplierInAnotherNamespaceNeedsTheOptIn(t *testing.T) assert.Equal(t, tc.wantStatus, configured.Status, configured.Message) assert.Equal(t, tc.wantReason, configured.Reason) if tc.wantStatus == metav1.ConditionFalse { - assert.Contains(t, configured.Message, "spec.allowCrossNamespace.appliers") + assert.Contains(t, configured.Message, "spec.allowedApplierClaims") assert.True(t, conditionIsTrue(*got.StatusConditions(), ConditionTypeStalled)) assert.Zero(t, *calls, "the applier's cluster is never asked") assert.Empty(t, got.GetFinalizers(), "and nothing is held") @@ -478,10 +478,10 @@ func TestApplierClaimRule_AnApplierInAnotherNamespaceNeedsTheOptIn(t *testing.T) // applierNamespaceReconciler is a reconciler of kind over the applier claim team-a/checkout, whose // applier is in team-a when sameNamespace is set and in flux-system or argocd otherwise, with the -// KubeProvider claimLocalProvider setting allowCrossNamespace.appliers when optIn is set. It counts the -// lookups of the applier's cluster. +// KubeProvider claimLocalProvider giving applierNamespaces "*" when granted is set, and only +// '{consumer.namespace}' otherwise. It counts the lookups of the applier's cluster. func applierNamespaceReconciler( - t *testing.T, kind string, sameNamespace, optIn bool, + t *testing.T, kind string, sameNamespace, granted bool, ) (*ApplierClaimReconciler, *int) { t.Helper() claim := ctrlclient.Object(testFluxClaim(claimLocalProvider)) @@ -502,8 +502,9 @@ func applierNamespaceReconciler( } } provider := admitting(claimLocalProvider, "team-a") - if !optIn { - provider.Spec.AllowCrossNamespace = nil + if !granted { + provider.Spec.AllowedApplierClaims = applierGrants( + []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.ConsumerNamespaceVariable}, "team-a") } r := claimReconciler(claimTestClient(t, claim, provider), kind, applierFakeSource(t, appliers...), nil, true) calls := new(int) diff --git a/internal/controller/applierclaim_schema_test.go b/internal/controller/applierclaim_schema_test.go index 014b50a0a..ed7c2b7b9 100644 --- a/internal/controller/applierclaim_schema_test.go +++ b/internal/controller/applierclaim_schema_test.go @@ -77,9 +77,9 @@ var _ = Describe("Applier claim schema", func() { g.Expect(ready.Reason).To(Equal(applier.ReasonNotReferenced)) }, 20*time.Second, 100*time.Millisecond).Should(Succeed()) Expect(claim.Finalizers).NotTo(ContainElement(configbutleraiv1alpha3.FluxClaimFinalizer)) - held := findCondition(claim.Status.Conditions, ApplierClaimConditionHeld) - Expect(held).NotTo(BeNil()) - Expect(held.Reason).To(Equal(applier.ReasonNotReferenced)) + inControl := findCondition(claim.Status.Conditions, ApplierClaimConditionInControl) + Expect(inControl).NotTo(BeNil()) + Expect(inControl.Reason).To(Equal(applier.ReasonNotReferenced)) }) It("never points an applier claim at another applier or cluster", func() { diff --git a/internal/controller/argocdclaim_hold.go b/internal/controller/argocdclaim_hold.go index a173097a5..909d37d27 100644 --- a/internal/controller/argocdclaim_hold.go +++ b/internal/controller/argocdclaim_hold.go @@ -35,9 +35,9 @@ import ( // The switch of an Application an ApplicationSet generated, or whose switch another field manager // applies, is not the applier claim's (applier.AutomatedSync.Owner): the applier claim never writes it and // carries no finalizer for it. Configured refuses such an Application while its automated sync is -// on, and the applier claim is Held while the owner keeps it off. +// on, and the applier claim is InControl while the owner keeps it off. -// ReasonAutomatedSyncOff is Held=True on an ArgoCDClaim whose Application has automated sync off: +// ReasonAutomatedSyncOff is InControl=True on an ArgoCDClaim whose Application has automated sync off: // held off by GitOps Reverser, or kept off by the switch's owner (the message says which). const ReasonAutomatedSyncOff = "AutomatedSyncOff" @@ -58,16 +58,16 @@ func (r *ApplierClaimReconciler) reconcileArgoCD( if check.retiring(claim) { return r.retire(ctx, st, claim, &check) } - held, err := r.holdArgoCD(ctx, st, claim, check) + inControl, err := r.holdArgoCD(ctx, st, claim, check) if err != nil { return ctrl.Result{}, err } - st.setValue(ApplierClaimConditionHeld, held) - return r.commitClaim(ctx, st, r.claimReadiness(claim, check.configured, &held), &held, + st.setValue(ApplierClaimConditionInControl, inControl) + return r.commitClaim(ctx, st, r.claimReadiness(claim, check.configured, &inControl), &inControl, heldClaimSteadyInterval) } -// holdArgoCD returns an ArgoCDClaim's Held condition. A Configured applier claim holds its +// holdArgoCD returns an ArgoCDClaim's InControl condition. A Configured applier claim holds its // Application's automated sync off. One that is not Configured holds nothing, and one in line // behind another holder lets go of a finalizer it carries, handing nothing back. func (r *ApplierClaimReconciler) holdArgoCD( @@ -97,8 +97,8 @@ func (r *ApplierClaimReconciler) holdArgoCD( } // holdAutomatedSync holds a Configured ArgoCDClaim's Application's automated sync off, with the -// applier claim's finalizer put in place before the first write, and returns the Held condition. A switch -// somebody else owns is left to them: Configured is True only while they keep it off. +// applier claim's finalizer put in place before the first write, and returns the InControl condition. +// A switch somebody else owns is left to them: Configured is True only while they keep it off. func (r *ApplierClaimReconciler) holdAutomatedSync( ctx context.Context, st *reconcileStatus, claim *configbutleraiv1alpha3.ArgoCDClaim, ) (conditionValue, error) { @@ -107,12 +107,12 @@ func (r *ApplierClaimReconciler) holdAutomatedSync( ref := claim.Applier() argo, err := r.argoClient(ctx, claim) if err != nil { - return keptCondition(claim.Status.Conditions, ApplierClaimConditionHeld, + return keptCondition(claim.Status.Conditions, ApplierClaimConditionInControl, fmt.Sprintf("cannot reach the cluster of Application %s: %v", ref, err)), nil } state, err := argo.AutomatedSync(ctx, ref) if err != nil { - return keptCondition(claim.Status.Conditions, ApplierClaimConditionHeld, + return keptCondition(claim.Status.Conditions, ApplierClaimConditionInControl, fmt.Sprintf("cannot read Application %s: %v", ref, err)), nil } if state.Owner != "" { @@ -138,20 +138,20 @@ func (r *ApplierClaimReconciler) holdAutomatedSync( } // switchOff switches an ArgoCDClaim's Application's automated sync off, having recorded how it stood -// when the applier claim adopts it, and reports false with the Held condition when it could not. +// when the applier claim adopts it, and reports false with the InControl condition when it could not. func (r *ApplierClaimReconciler) switchOff( ctx context.Context, st *reconcileStatus, claim *configbutleraiv1alpha3.ArgoCDClaim, argo *applier.ArgoCD, state applier.AutomatedSync, adopt bool, ) (conditionValue, bool) { ref := claim.Applier() if err := r.recordPreviousSuspendState(ctx, st, claim, state.Held && !adopt, !state.On); err != nil { - return keptCondition(claim.Status.Conditions, ApplierClaimConditionHeld, fmt.Sprintf( + return keptCondition(claim.Status.Conditions, ApplierClaimConditionInControl, fmt.Sprintf( "cannot record how Application %s stood before it is held: %v", ref, err)), false } written, err := argo.SwitchOff(ctx, ref) switch { case err != nil: - return keptCondition(claim.Status.Conditions, ApplierClaimConditionHeld, err.Error()), false + return keptCondition(claim.Status.Conditions, ApplierClaimConditionInControl, err.Error()), false case !written: // Configured checks this first; an Argo CD downgraded since is caught here, before a write. return conditionValue{Status: metav1.ConditionFalse, Reason: applier.ReasonArgoCDVersionNotSupported, @@ -159,7 +159,7 @@ func (r *ApplierClaimReconciler) switchOff( "before 3.1), so GitOps Reverser writes nothing", ref)}, false } switch { - case !heldBefore(claim): + case !wasInControl(claim): r.event(claim, corev1.EventTypeNormal, EventReasonAdopted, "switched automated sync of Application %s off: from now on GitOps Reverser starts every sync", ref) case state.On: @@ -170,9 +170,9 @@ func (r *ApplierClaimReconciler) switchOff( return conditionValue{}, true } -// ownedSwitchCondition is the Held condition of an applier claim whose Application's switch somebody else -// owns. Nothing is written: a share of the switch the applier claim took before the owner did is handed -// back by the finalizer it carries. +// ownedSwitchCondition is the InControl condition of an applier claim whose Application's switch +// somebody else owns. Nothing is written: a share of the switch the applier claim took before the owner +// did is handed back by the finalizer it carries. func ownedSwitchCondition(ref k8stypes.NamespacedName, state applier.AutomatedSync) conditionValue { if state.On { // Switched on since Configured read it: the next reconcile refuses it. diff --git a/internal/controller/argocdclaim_hold_test.go b/internal/controller/argocdclaim_hold_test.go index 885e25b67..83e52d2b3 100644 --- a/internal/controller/argocdclaim_hold_test.go +++ b/internal/controller/argocdclaim_hold_test.go @@ -124,11 +124,11 @@ func TestArgoCDClaim_SwitchesAutomatedSyncOff(t *testing.T) { got := reconcileClaim(t, f.r).(*configbutleraiv1alpha3.ArgoCDClaim) - held := findCondition(got.Status.Conditions, ApplierClaimConditionHeld) - require.NotNil(t, held) - assert.Equal(t, metav1.ConditionTrue, held.Status, held.Message) - assert.Equal(t, ReasonAutomatedSyncOff, held.Reason) - assert.Contains(t, held.Message, "held off by GitOps Reverser") + inControl := findCondition(got.Status.Conditions, ApplierClaimConditionInControl) + require.NotNil(t, inControl) + assert.Equal(t, metav1.ConditionTrue, inControl.Status, inControl.Message) + assert.Equal(t, ReasonAutomatedSyncOff, inControl.Reason) + assert.Contains(t, inControl.Message, "held off by GitOps Reverser") assert.True(t, conditionIsTrue(got.Status.Conditions, ConditionTypeReady)) assert.True(t, controllerutil.ContainsFinalizer(got, configbutleraiv1alpha3.ArgoCDClaimFinalizer)) automated, _ := f.automated(t) @@ -221,9 +221,9 @@ func TestArgoCDClaim_NoLongerReferencedHandsTheSwitchBack(t *testing.T) { got := reconcileClaim(t, f.r) assert.False(t, controllerutil.ContainsFinalizer(got, configbutleraiv1alpha3.ArgoCDClaimFinalizer)) - held := findCondition(*got.StatusConditions(), ApplierClaimConditionHeld) - require.NotNil(t, held) - assert.Equal(t, applier.ReasonNotReferenced, held.Reason) + inControl := findCondition(*got.StatusConditions(), ApplierClaimConditionInControl) + require.NotNil(t, inControl) + assert.Equal(t, applier.ReasonNotReferenced, inControl.Reason) automated, _ := f.automated(t) assert.NotContains(t, automated, "enabled", "automated sync is on again") events := f.events() @@ -266,7 +266,7 @@ func TestArgoCDClaim_ASuccessorInLineKeepsTheSwitchOff(t *testing.T) { } // TestArgoCDClaim_AnApplicationSetOwnsTheSwitch: the switch of an Application an ApplicationSet -// generated is the ApplicationSet's template's. With automated sync off the applier claim is Held and +// generated is the ApplicationSet's template's. With automated sync off the applier claim is InControl and // writes nothing; with it on, it is refused with a reason and message that say what to change. func TestArgoCDClaim_AnApplicationSetOwnsTheSwitch(t *testing.T) { t.Run("kept off by the ApplicationSet", func(t *testing.T) { @@ -277,10 +277,10 @@ func TestArgoCDClaim_AnApplicationSetOwnsTheSwitch(t *testing.T) { got := reconcileClaim(t, f.r).(*configbutleraiv1alpha3.ArgoCDClaim) assert.True(t, conditionIsTrue(got.Status.Conditions, ConditionTypeReady)) - held := findCondition(got.Status.Conditions, ApplierClaimConditionHeld) - require.NotNil(t, held) - assert.Equal(t, ReasonAutomatedSyncOff, held.Reason) - assert.Contains(t, held.Message, "kept off by ApplicationSet argocd/checkouts, which owns the switch") + inControl := findCondition(got.Status.Conditions, ApplierClaimConditionInControl) + require.NotNil(t, inControl) + assert.Equal(t, ReasonAutomatedSyncOff, inControl.Reason) + assert.Contains(t, inControl.Message, "kept off by ApplicationSet argocd/checkouts, which owns the switch") assert.False(t, controllerutil.ContainsFinalizer(got, configbutleraiv1alpha3.ArgoCDClaimFinalizer), "nothing of the applier claim's to hand back") assert.Empty(t, f.application(t).GetManagedFields(), "the switch is never written") @@ -308,7 +308,7 @@ func TestArgoCDClaim_AnApplicationSetOwnsTheSwitch(t *testing.T) { } // TestArgoCDClaim_AManagerThatAppliesTheSwitchOwnsIt: whatever applies the Application's manifest -// with the switch in it sets it on every apply, so the applier claim leaves it alone: Held while it is +// with the switch in it sets it on every apply, so the applier claim leaves it alone: InControl while it is // off, refused while it is on. func TestArgoCDClaim_AManagerThatAppliesTheSwitchOwnsIt(t *testing.T) { for name, tc := range map[string]struct { diff --git a/internal/controller/clusterwatchrule_admission_test.go b/internal/controller/clusterwatchrule_admission_test.go index f478a0ebc..1bc5eccfc 100644 --- a/internal/controller/clusterwatchrule_admission_test.go +++ b/internal/controller/clusterwatchrule_admission_test.go @@ -35,6 +35,8 @@ type cwaWatchManager struct { onReconcile func() // collectionOverlap, when set, is the refusal message both CollectionOverlap methods report. collectionOverlap string + // selected is what ClusterResourcesSelectedFor selects for an item. + selected watch.ClusterResourceSelection } func (m *cwaWatchManager) TriggerRuleChange(internaltypes.ResourceReference) { @@ -93,6 +95,10 @@ func (m *cwaWatchManager) StreamSummaryForClusterWatchRule( func (m *cwaWatchManager) StreamStateEvents() <-chan event.GenericEvent { return nil } +func (m *cwaWatchManager) ClusterResourcesSelectedFor(configbutleraiv1alpha3.GitTarget) watch.ClusterResourceSelection { + return m.selected +} + func cwaRunningSummary() watch.StreamSummary { return watch.StreamSummary{ Total: 1, Ready: 1, Reason: "Streaming", Message: "1/1 streams running", @@ -106,7 +112,7 @@ const ( cwaTargetName = "prod-mirror" cwaTargetNS = "team-a" cwaProviderName = "prod-eu-1" - // cwaProviderNS holds the KubeProvider, so cwaTargetNS reaches it only through accessFrom. + // cwaProviderNS holds the KubeProvider, so cwaTargetNS reaches it only through a grant. cwaProviderNS = "platform" ) @@ -127,12 +133,12 @@ func cwaGitProvider() *configbutleraiv1alpha3.GitProvider { } } -func cwaKubeProvider(policy *configbutleraiv1alpha3.NamespaceMatcher) *configbutleraiv1alpha3.KubeProvider { +func cwaKubeProvider(policy []configbutleraiv1alpha3.KubeProviderGitTargetGrant) *configbutleraiv1alpha3.KubeProvider { return &configbutleraiv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: cwaProviderName, Namespace: cwaProviderNS}, Spec: configbutleraiv1alpha3.KubeProviderSpec{ - SecretRef: &meta.SecretKeyReference{Name: "kc"}, - AccessFrom: policy, + SecretRef: &meta.SecretKeyReference{Name: "kc"}, + AllowedGitTargets: policy, }, } } @@ -151,12 +157,12 @@ func cwaClusterWatchRule() *configbutleraiv1alpha3.ClusterWatchRule { } // cwaAdmitting is the policy that admits cwaTargetNS; cwaDenying admits some other namespace. -func cwaAdmitting() *configbutleraiv1alpha3.NamespaceMatcher { - return &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{cwaTargetNS}} +func cwaAdmitting() []configbutleraiv1alpha3.KubeProviderGitTargetGrant { + return mirrorGrants(cwaTargetNS) } -func cwaDenying() *configbutleraiv1alpha3.NamespaceMatcher { - return &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"some-other-namespace"}} +func cwaDenying() []configbutleraiv1alpha3.KubeProviderGitTargetGrant { + return mirrorGrants("some-other-namespace") } type cwaFixture struct { @@ -263,7 +269,7 @@ func TestReconcile_ClusterWatchRuleRefusedWhenTargetNamespaceUnauthorized(t *tes // TestReconcile_ClusterWatchRuleRefusedWhenKubeProviderMissing covers the other half of the // shared gate: an undeclared provider is a hard denial, so an operator cannot sidestep -// accessFrom by simply never creating the provider. +// a grant by simply never creating the provider. func TestReconcile_ClusterWatchRuleRefusedWhenKubeProviderMissing(t *testing.T) { ctx := context.Background() f := newCWAFixture(t, []client.Object{ @@ -315,7 +321,7 @@ func TestReconcile_RefusalStopsDataPlaneBeforePublishingStatus(t *testing.T) { // Pre-seed the store as an earlier, admitted reconcile would have left it, so "no compiled // rule at the end" cannot pass vacuously. f.store.AddOrUpdateClusterWatchRule( - *cwaClusterWatchRule(), cwaTargetName, cwaTargetNS, "git", cwaTargetNS, "clusters/prod") + *cwaClusterWatchRule(), []string{"*"}, cwaTargetName, cwaTargetNS, "git", cwaTargetNS, "clusters/prod") require.Len(t, f.compiledNames(), 1, "precondition: the rule starts out compiled") f.wm.onReconcile = func() { @@ -362,8 +368,14 @@ func TestReconcile_AdmittedBySelectorOnNamespaceLabels(t *testing.T) { ctx := context.Background() f := newCWAFixture(t, []client.Object{ cwaClusterWatchRule(), cwaGitTarget(), cwaGitProvider(), - cwaKubeProvider(&configbutleraiv1alpha3.NamespaceMatcher{ - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirror": "yes"}}, + cwaKubeProvider([]configbutleraiv1alpha3.KubeProviderGitTargetGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirror": "yes"}}, + }, + ClusterResources: []configbutleraiv1alpha3.ClusterResourceIdentity{"*"}, + }, }), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ Name: cwaTargetNS, Labels: map[string]string{"mirror": "yes"}, @@ -376,7 +388,7 @@ func TestReconcile_AdmittedBySelectorOnNamespaceLabels(t *testing.T) { } // TestReconcile_RevocationRemovesCompiledRule is the revocation case: a rule that was admitted and -// running must be torn down when the provider's accessFrom stops admitting its target's +// running must be torn down when the provider's grants stop admitting its target's // namespace. Same terminal status as an initial denial. func TestReconcile_RevocationRemovesCompiledRule(t *testing.T) { ctx := context.Background() @@ -392,11 +404,11 @@ func TestReconcile_RevocationRemovesCompiledRule(t *testing.T) { require.Equal(t, []string{cwaRuleName}, f.compiledNames(), "precondition: the rule is running") replansAfterAdmission := f.wm.replans - // Revoke: the namespace leaves accessFrom. + // Revoke: the namespace leaves the grant. var provider configbutleraiv1alpha3.KubeProvider require.NoError(t, f.client.Get(ctx, k8stypes.NamespacedName{Namespace: cwaProviderNS, Name: cwaProviderName}, &provider)) - provider.Spec.AccessFrom = cwaDenying() + provider.Spec.AllowedGitTargets = cwaDenying() require.NoError(t, f.client.Update(ctx, &provider)) // Round 2: the same reconcile now refuses. @@ -433,7 +445,7 @@ func TestReconcile_AdmissionReadErrorRequeuesAndKeepsCompiledRule(t *testing.T) }) f.store.AddOrUpdateClusterWatchRule( - *cwaClusterWatchRule(), cwaTargetName, cwaTargetNS, "git", cwaTargetNS, "clusters/prod") + *cwaClusterWatchRule(), []string{"*"}, cwaTargetName, cwaTargetNS, "git", cwaTargetNS, "clusters/prod") _, err := f.reconcile(ctx) diff --git a/internal/controller/clusterwatchrule_controller.go b/internal/controller/clusterwatchrule_controller.go index b227d9500..90eed0af6 100644 --- a/internal/controller/clusterwatchrule_controller.go +++ b/internal/controller/clusterwatchrule_controller.go @@ -8,7 +8,6 @@ import ( "fmt" "github.com/go-logr/logr" - corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -220,7 +219,7 @@ func (r *ClusterWatchRuleReconciler) reconcileClusterWatchRuleViaTarget( // admission of the referenced GitTarget: a ClusterWatchRule is cluster-scoped and its gitTargetRef // carries a REQUIRED namespace, so it may name a GitTarget in ANY namespace and widen that target's // mirror scope cluster-wide. Compiling such a rule without re-applying the target's own provider -// admission would let it mirror through a credential whose accessFrom never admitted that target. +// admission would let it mirror through a credential whose grants never admitted that target. // // It lives in internal/watch rather than here because the startup bootstrap must apply exactly the // same refusal BEFORE the first reconcile — otherwise every restart reopens the window it closes. @@ -235,22 +234,16 @@ func (r *ClusterWatchRuleReconciler) gateClusterWatchRule( target configbutleraiv1alpha3.GitTarget, log logr.Logger, ) (bool, ctrl.Result, error) { + var selected watch.ClusterResourceSelection + if r.WatchManager != nil { + selected = r.WatchManager.ClusterResourcesSelectedFor(target) + } decision, err := watch.CompileClusterWatchRule( - ctx, r.Client, r.RuleStore, *clusterRule, target) + ctx, r.Client, r.RuleStore, *clusterRule, target, selected) var selectorErr *watch.ObjectSelectorError if errors.As(err, &selectorErr) { - // A refusal of the rule's own resources, not of its GitTarget, so it lands on - // ResourcesResolved rather than on the condition refuseClusterWatchRule writes. - log.Info("Refusing ClusterWatchRule", "name", clusterRule.Name, - "reason", watch.ReasonInvalidObjectSelector, "message", selectorErr.Message) - if r.WatchManager != nil { - r.WatchManager.TriggerRuleChange(clusterWatchRuleGitTarget(clusterRule)) - } - st.set(ConditionTypeResourcesResolved, metav1.ConditionFalse, - watch.ReasonInvalidObjectSelector, selectorErr.Message) - st.set(ConditionTypeStreamsRunning, metav1.ConditionFalse, - watch.ReasonInvalidObjectSelector, "No streams: the ClusterWatchRule was refused") - result, stallErr := r.stallRule(ctx, st, watch.ReasonInvalidObjectSelector, selectorErr.Message) + result, stallErr := r.refuseRuleResources(ctx, st, clusterRule, watch.ReasonInvalidObjectSelector, + selectorErr.Message, log) return true, result, stallErr } if err != nil { @@ -263,11 +256,34 @@ func (r *ClusterWatchRuleReconciler) gateClusterWatchRule( if decision.Admitted { return false, ctrl.Result{}, nil } + if decision.ResourcesRefused { + result, stallErr := r.refuseRuleResources(ctx, st, clusterRule, decision.Reason, decision.Message, log) + return true, result, stallErr + } result, refuseErr := r.refuseClusterWatchRule(ctx, st, clusterRule, decision, log) return true, result, refuseErr } +// refuseRuleResources is the refusal of the rule's own resources rather than of its GitTarget, so it +// lands on ResourcesResolved rather than on the condition refuseClusterWatchRule writes. The compiled +// rule is already out of the store; the replan tears down what it kept alive. +func (r *ClusterWatchRuleReconciler) refuseRuleResources( + ctx context.Context, + st *reconcileStatus, + clusterRule *configbutleraiv1alpha3.ClusterWatchRule, + reason, message string, + log logr.Logger, +) (ctrl.Result, error) { + log.Info("Refusing ClusterWatchRule", "name", clusterRule.Name, "reason", reason, "message", message) + if r.WatchManager != nil { + r.WatchManager.TriggerRuleChange(clusterWatchRuleGitTarget(clusterRule)) + } + st.set(ConditionTypeResourcesResolved, metav1.ConditionFalse, reason, message) + st.set(ConditionTypeStreamsRunning, metav1.ConditionFalse, reason, "No streams: the ClusterWatchRule was refused") + return r.stallRule(ctx, st, reason, message) +} + // refuseClusterWatchRule is the denial half of the ClusterWatchRule gate. // // Order is the contract, not an implementation detail: CompileClusterWatchRule has ALREADY removed @@ -370,35 +386,9 @@ func (r *ClusterWatchRuleReconciler) SetupWithManager(mgr ctrl.Manager) error { // already suppresses no-op writes, so the loop has no fuel; this makes it structural, and // matches what GitProvider and KubeProvider already do. For(&configbutleraiv1alpha3.ClusterWatchRule{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). - // This rule MIRRORS the GitTarget's readiness into its own GitTargetReady, so the watch has - // to see the status-only update that carries a heal — GenerationChangedPredicate would drop - // exactly that. gitTargetReadyProjectionChanged fires on a spec change or on a move in the - // mirrored verdict, and on nothing else. - Watches( - &configbutleraiv1alpha3.GitTarget{}, - handler.EnqueueRequestsFromMapFunc(r.gitTargetToClusterWatchRules), - builder.WithPredicates(gitTargetReadyProjectionChanged()), - ). - // React to a KubeProvider's accessFrom changing. Without this, REVOKING a - // namespace stops the GitTarget (which does watch KubeProvider) but leaves this rule's - // compiled entry resident until the next periodic reconcile, so the admission gate would - // converge on a ~10m delay instead of on the event. The GitTarget's own status flip cannot - // be relied on to carry it: the edge above re-enqueues only on a move in the MIRRORED - // readiness, and a revocation need not move it. - Watches( - &configbutleraiv1alpha3.KubeProvider{}, - handler.EnqueueRequestsFromMapFunc(r.kubeProviderToClusterWatchRules), - builder.WithPredicates(kubeProviderReadyOrSpecChanged()), - ). - // React to a Namespace's LABELS changing: accessFrom may admit by selector, so a - // label change on a GitTarget's namespace grants or revokes every ClusterWatchRule pointing - // at a target in it. LabelChangedPredicate ignores unrelated namespace churn. - Watches( - &corev1.Namespace{}, - handler.EnqueueRequestsFromMapFunc(r.namespaceToClusterWatchRules), - builder.WithPredicates(predicate.LabelChangedPredicate{}), - ). Named("clusterwatchrule") + b = ruleDependencyWatches(b, r.gitTargetToClusterWatchRules, r.kubeProviderToClusterWatchRules, + r.namespaceToClusterWatchRules) // React to a stream of this rule's GitTarget reaching or leaving Streaming, for the reason the // WatchRule controller wires the same channel: the data plane is the only thing that knows when @@ -442,7 +432,7 @@ func (r *ClusterWatchRuleReconciler) kubeProviderToClusterWatchRules( } // namespaceToClusterWatchRules maps a Namespace label change to every ClusterWatchRule whose -// referenced GitTarget lives in that namespace — the selector half of accessFrom. +// referenced GitTarget lives in that namespace — what a Selector grant reads. func (r *ClusterWatchRuleReconciler) namespaceToClusterWatchRules( ctx context.Context, obj client.Object, diff --git a/internal/controller/constants.go b/internal/controller/constants.go index 3a6b2b46b..92d636cf4 100644 --- a/internal/controller/constants.go +++ b/internal/controller/constants.go @@ -43,6 +43,10 @@ type WatchManagerInterface interface { StreamSummaryForWatchRule(rule configv1alpha3.WatchRule) watch.StreamSummary StreamSummaryForClusterWatchRule(rule configv1alpha3.ClusterWatchRule) watch.StreamSummary + // ClusterResourcesSelectedFor is what the watch planner selects for a ClusterWatchRule item in + // the cluster a GitTarget mirrors from, so the compile checks those types against the grant. + ClusterResourcesSelectedFor(target configv1alpha3.GitTarget) watch.ClusterResourceSelection + // StreamStateEvents is the channel a rule controller wires via source.Channel so a stream // reaching (or leaving) Streaming re-reconciles the rules that project it, instead of leaving // them to discover it on RequeueStreamSettleInterval. A stream coming up is the last thing @@ -72,9 +76,9 @@ const ( // ConditionTypeGitTargetReady indicates whether the referenced GitTarget is ready for writes. ConditionTypeGitTargetReady = "GitTargetReady" // ConditionTypeSourceNamespaceAuthorized reports whether a rule's EFFECTIVE source namespace - // is authorized for the observed generation. It is positive and state-style, and it is set - // even for legacy own-namespace rules (reason LegacySourceNamespace) so the effective - // authorization is always visible and automation has ONE condition to inspect. + // is authorized for the observed generation. It is positive and state-style, and it is set for + // every rule, its own namespace included, so the effective authorization is always visible and + // automation has ONE condition to inspect. // // It is deliberately distinct from GitTargetReady, which stays the health of the referenced // GitTarget and must never be reused for source authorization. Its three values are not diff --git a/internal/controller/dependency_watches.go b/internal/controller/dependency_watches.go index 4c8cf5897..e6e6e2a3a 100644 --- a/internal/controller/dependency_watches.go +++ b/internal/controller/dependency_watches.go @@ -5,8 +5,15 @@ package controller import ( "context" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + ctrlreconcile "sigs.k8s.io/controller-runtime/pkg/reconcile" + + configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" ) // logDependencyListError records a List failure from inside a cross-kind watch @@ -19,3 +26,73 @@ func logDependencyListError(ctx context.Context, err error, listKind string, tri "affected resources will recover on the periodic requeue", "trigger", client.ObjectKeyFromObject(trigger)) } + +// GitBranchProviderIndex indexes GitBranches by the GitProvider they claim through, its namespace +// resolved (namespace/name). A GitBranch may name a GitProvider in another namespace, so the +// GitBranches of a provider cannot be found by listing the provider's namespace. +const GitBranchProviderIndex = "configbutler.ai/gitbranch-provider" + +// IndexGitBranchProvider is the GitBranchProviderIndex value of a GitBranch. +func IndexGitBranchProvider(obj client.Object) []string { + branch, ok := obj.(*configbutleraiv1alpha3.GitBranch) + if !ok || branch.Spec.GitProviderRef.Name == "" { + return nil + } + return []string{branch.GitProviderKey().String()} +} + +// IndexGitBranches registers GitBranchProviderIndex. The GitBranch, GitTarget and applier claim +// controllers all find a provider's GitBranches through it, so it is registered once, before any of +// them is set up. +func IndexGitBranches(ctx context.Context, indexer client.FieldIndexer) error { + return indexer.IndexField(ctx, &configbutleraiv1alpha3.GitBranch{}, GitBranchProviderIndex, + IndexGitBranchProvider) +} + +// gitBranchesOf lists the GitBranches, from any namespace, that claim through the GitProvider provider. +func gitBranchesOf( + ctx context.Context, reader client.Reader, provider client.Object, +) ([]configbutleraiv1alpha3.GitBranch, error) { + var list configbutleraiv1alpha3.GitBranchList + if err := reader.List(ctx, &list, + client.MatchingFields{GitBranchProviderIndex: client.ObjectKeyFromObject(provider).String()}); err != nil { + return nil, err + } + return list.Items, nil +} + +// requestsFor is a reconcile request for each object in a list. +func requestsFor[T any, PT interface { + *T + client.Object +}](objects []T) []ctrlreconcile.Request { + requests := make([]ctrlreconcile.Request, 0, len(objects)) + for i := range objects { + requests = append(requests, ctrlreconcile.Request{NamespacedName: client.ObjectKeyFromObject(PT(&objects[i]))}) + } + return requests +} + +// ruleDependencyWatches adds the watches a WatchRule and a ClusterWatchRule share: their GitTarget, +// its KubeProvider and the Namespace whose labels its grants read. +// +// - The rule MIRRORS the GitTarget's readiness into its own GitTargetReady, so the watch has to see +// the status-only update that carries a heal; GenerationChangedPredicate would drop exactly that. +// gitTargetReadyProjectionChanged fires on a spec change or on a move in the mirrored verdict. +// - A KubeProvider's grants changing. The GitTarget edge cannot carry it: a provider change reaches +// the GitTarget as a status update that need not move its readiness, so without this edge a +// revocation would leave the compiled rule resident until the periodic requeue. +// - A Namespace's labels changing. A Selector grant reads them, and a namespace matched by two grants +// can lose the one granting a wider scope while its GitTarget stays admitted, which moves nothing +// the GitTarget edge sees. LabelChangedPredicate ignores unrelated namespace churn. +func ruleDependencyWatches( + b *builder.Builder, gitTargets, kubeProviders, namespaces handler.MapFunc, +) *builder.Builder { + return b. + Watches(&configbutleraiv1alpha3.GitTarget{}, handler.EnqueueRequestsFromMapFunc(gitTargets), + builder.WithPredicates(gitTargetReadyProjectionChanged())). + Watches(&configbutleraiv1alpha3.KubeProvider{}, handler.EnqueueRequestsFromMapFunc(kubeProviders), + builder.WithPredicates(kubeProviderReadyOrSpecChanged())). + Watches(&corev1.Namespace{}, handler.EnqueueRequestsFromMapFunc(namespaces), + builder.WithPredicates(predicate.LabelChangedPredicate{})) +} diff --git a/internal/controller/dependency_watches_test.go b/internal/controller/dependency_watches_test.go index 257fb5233..b4ba6c95f 100644 --- a/internal/controller/dependency_watches_test.go +++ b/internal/controller/dependency_watches_test.go @@ -38,6 +38,7 @@ func newDependencyWatchesTestClient(t *testing.T, objects ...ctrlclient.Object) return fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objects...). + WithIndex(&configbutleraiv1alpha3.GitBranch{}, GitBranchProviderIndex, IndexGitBranchProvider). Build() } @@ -98,7 +99,8 @@ func gitBranch(providerName, namespace string) *configbutleraiv1alpha3.GitBranch return &configbutleraiv1alpha3.GitBranch{ ObjectMeta: metav1.ObjectMeta{Name: gitBranchNameForTest(providerName, "main"), Namespace: namespace}, Spec: configbutleraiv1alpha3.GitBranchSpec{ - GitProviderRef: meta.LocalObjectReference{Name: providerName}, Branch: "main", + GitProviderRef: meta.NamespacedObjectReference{Name: providerName}, Branch: "main", + AllowedGitTargets: writersFromSame(), }, } } diff --git a/internal/controller/fluxclaim_hold.go b/internal/controller/fluxclaim_hold.go index 2feb4fddd..a17adee38 100644 --- a/internal/controller/fluxclaim_hold.go +++ b/internal/controller/fluxclaim_hold.go @@ -29,7 +29,7 @@ import ( // lifecycle, from docs/design/apply-trigger.md ("Who owns spec.suspend"): // // - Adoption: a Configured applier claim suspends its Kustomization, under GitOps Reverser's field -// manager, with a token; Held (and so Ready) waits until the Kustomization handled it. +// manager, with a token; InControl (and so Ready) waits until the Kustomization handled it. // - External resume: a held Kustomization found resumed with no operation of a branch worker in // flight was resumed by somebody else (`flux resume`). It is suspended again with a token, every // time, with a Normal ClaimedResourceReconciled Event, and recorded as an Unresolved operation @@ -43,7 +43,7 @@ import ( // once the holder is gone. // holdKustomization holds a Configured FluxClaim's Kustomization suspended, and returns the -// applier claim's Held condition. It writes nothing while a branch worker's operation is in flight +// applier claim's InControl condition. It writes nothing while a branch worker's operation is in flight // and followed: the worker resumes the Kustomization and suspends it again itself. One nobody // follows any more is suspended here (suspendUnfollowed), so the Kustomization is never left resumed // with nothing guarding it. @@ -54,7 +54,7 @@ func (r *ApplierClaimReconciler) holdKustomization( defer cancel() held, err := r.readHold(ctx, claim) if err != nil { - return keptCondition(claim.Status.Conditions, ApplierClaimConditionHeld, err.Error()) + return keptCondition(claim.Status.Conditions, ApplierClaimConditionInControl, err.Error()) } if !held.inFlight() || held.operation.Token == "" { r.unfollowed.forget(claim, "") @@ -68,7 +68,7 @@ func (r *ApplierClaimReconciler) holdKustomization( return r.hold(ctx, st, claim, held) } -// applyingCondition is Held=True (Applying) for a branch worker's operation in flight that is +// applyingCondition is InControl=True (Applying) for a branch worker's operation in flight that is // followed: the Kustomization is resumed for it, or about to be, and suspended again when it ends. func applyingCondition(operation *configbutleraiv1alpha3.ApplyOperation) conditionValue { return conditionValue{Status: metav1.ConditionTrue, Reason: ReasonApplying, Message: fmt.Sprintf( @@ -123,7 +123,7 @@ func (r *ApplierClaimReconciler) suspendUnfollowed( case !held.suspension.Suspended || !held.suspension.Held: token, err := held.flux.Suspend(ctx, ref) if err != nil { - return keptCondition(claim.Status.Conditions, ApplierClaimConditionHeld, err.Error()) + return keptCondition(claim.Status.Conditions, ApplierClaimConditionInControl, err.Error()) } r.event(claim, corev1.EventTypeWarning, EventReasonOperationUnfollowed, "the %s apply of %s is followed by nobody; suspended Kustomization %s again", operation.Reason, @@ -255,7 +255,7 @@ func (r *ApplierClaimReconciler) hold( return conditionValue{Status: metav1.ConditionFalse, Reason: ReasonSuspending, Message: message} } unrecorded := func(err error) conditionValue { - return keptCondition(claim.Status.Conditions, ApplierClaimConditionHeld, fmt.Sprintf( + return keptCondition(claim.Status.Conditions, ApplierClaimConditionInControl, fmt.Sprintf( "cannot record how Kustomization %s stood before it is held: %v", ref, err)) } adopt := adopting(claim, held.operation) @@ -272,9 +272,9 @@ func (r *ApplierClaimReconciler) hold( } token, err := held.flux.Suspend(ctx, ref) if err != nil { - return keptCondition(claim.Status.Conditions, ApplierClaimConditionHeld, err.Error()) + return keptCondition(claim.Status.Conditions, ApplierClaimConditionInControl, err.Error()) } - r.suspended(claim, token, heldBefore(claim) || held.inFlight()) + r.suspended(claim, token, wasInControl(claim) || held.inFlight()) return suspending(fmt.Sprintf("Kustomization %s is being suspended", ref)) case !held.suspension.Held || adopt: return r.takeOverSuspend(ctx, st, claim, held) @@ -303,18 +303,18 @@ func (r *ApplierClaimReconciler) takeOverSuspend( } } if err := r.recordPreviousSuspendState(ctx, st, claim, false, true); err != nil { - return keptCondition(claim.Status.Conditions, ApplierClaimConditionHeld, fmt.Sprintf( + return keptCondition(claim.Status.Conditions, ApplierClaimConditionInControl, fmt.Sprintf( "cannot record how Kustomization %s stood before it is held: %v", ref, err)) } if _, err := held.flux.Suspend(ctx, ref); err != nil { - return keptCondition(claim.Status.Conditions, ApplierClaimConditionHeld, err.Error()) + return keptCondition(claim.Status.Conditions, ApplierClaimConditionInControl, err.Error()) } return conditionValue{Status: metav1.ConditionFalse, Reason: ReasonSuspending, Message: fmt.Sprintf("Kustomization %s is being suspended under GitOps Reverser", ref)} } // heldElsewhere reports, from an uncached read, whether the applier claim's applier is free of any -// other applier claim's hold, and when it is not (or cannot be told) the Held condition that says so. +// other applier claim's hold, and when it is not (or cannot be told) the InControl condition that says so. // The cached read Configured was judged on can predate another applier claim coming first in line, and // that one's hold looks like a hand-back's or an apply's from the applier alone. func (r *ApplierClaimReconciler) heldElsewhere( @@ -333,13 +333,13 @@ func (r *ApplierClaimReconciler) heldElsewhere( } // adopting reports whether the applier claim takes its applier anew: it records nothing of how the -// applier stood, did not last say it held it, and has no operation in flight (operation, read +// applier stood, did not last say it was in control, and has no operation in flight (operation, read // uncached where it matters). A hand-back forgets the record and ends the operation, and a successor // is passed its predecessor's record. So a field GitOps Reverser's field manager holds when an // applier claim adopts is no hold in place but one a hand-back kept, set to how the applier stood: // it is recorded as such, and held anew. func adopting(claim configbutleraiv1alpha3.ApplierClaim, operation *configbutleraiv1alpha3.ApplyOperation) bool { - return claim.PreviousSuspendState() == "" && !heldBefore(claim) && + return claim.PreviousSuspendState() == "" && !wasInControl(claim) && (operation == nil || operation.Phase.Completed()) } @@ -352,7 +352,7 @@ func adopting(claim configbutleraiv1alpha3.ApplierClaim, operation *configbutler func (r *ApplierClaimReconciler) recordPreviousSuspendState( ctx context.Context, st *reconcileStatus, claim configbutleraiv1alpha3.ApplierClaim, held, suspended bool, ) error { - if held || claim.PreviousSuspendState() != "" || heldBefore(claim) { + if held || claim.PreviousSuspendState() != "" || wasInControl(claim) { return nil } state := suspendState(suspended) @@ -453,9 +453,9 @@ func completeExternalOperation( claim.Status.Operation = operation } -// heldBefore reports whether the applier claim last said it held its applier. -func heldBefore(claim configbutleraiv1alpha3.ApplierClaim) bool { - return apimeta.IsStatusConditionTrue(*claim.StatusConditions(), ApplierClaimConditionHeld) +// wasInControl reports whether the applier claim last said it was in control of its applier. +func wasInControl(claim configbutleraiv1alpha3.ApplierClaim) bool { + return apimeta.IsStatusConditionTrue(*claim.StatusConditions(), ApplierClaimConditionInControl) } // liveOperation reads the applier claim's status.operation uncached: a branch worker writes it just before @@ -490,7 +490,7 @@ func (r *ApplierClaimReconciler) fluxClient( } // releaseDeadline is how long a retiring applier claim tries to hand its applier back, counted from when -// its Held condition turned Releasing, before it lets go anyway with a Warning Event that says what +// its InControl condition turned Releasing, before it lets go anyway with a Warning Event that says what // the applier is left as. A Kustomization's suspend is handled once the apply running then ends, so // the deadline is longer than an apply usually takes; a kustomize-controller that is down never // handles it, and the deletion must not wait on that forever. @@ -501,8 +501,8 @@ const releaseDeadline = 10 * time.Minute // FluxClaim suspends its Kustomization once more if it is resumed (a branch worker's apply may // still be running), waits until that suspend's token is handled, and releases spec.suspend; an // ArgoCDClaim gives its Application's automated sync switch up (releaseStep). Neither hands back -// what an applier claim in line takes over. Until that is done the applier claim stays the holder, Held=False -// (Releasing), so no successor acts on the applier meanwhile. An applier that is gone has nothing +// what an applier claim in line takes over. Until that is done the applier claim stays the holder, +// InControl=False (Releasing), so no successor acts on the applier meanwhile. An applier that is gone has nothing // to hand back; a cluster no client reaches any more since its KubeProvider went (releaseClient), // and an applier the identity may not write, are let go with a Warning Event: none should hold the // applier claim forever. Nor does anything else past releaseDeadline. An applier claim behind another in line @@ -554,22 +554,37 @@ func (r *ApplierClaimReconciler) retire( } r.letGoOfOperation(claim) clearPreviousSuspendState(claim) - held := conditionValue{Status: metav1.ConditionFalse, Reason: applier.ReasonNotReferenced, - Message: fmt.Sprintf("nothing to hold: no GitTarget names this applier claim, and its %s was handed back", - claim.ApplierKind())} - if check.refs.Named() { - held = conditionValue{Status: metav1.ConditionUnknown, Reason: ReasonNotConfigured, - Message: fmt.Sprintf("the %s was handed back, and is held again once an eligible GitTarget "+ - "references the applier claim", claim.ApplierKind())} - } - st.setValue(ApplierClaimConditionHeld, held) - st.applyReadiness(r.claimReadiness(claim, check.configured, &held)) + inControl := handedBackCondition(claim, check) + st.setValue(ApplierClaimConditionInControl, inControl) + st.applyReadiness(r.claimReadiness(claim, check.configured, &inControl)) if err := st.commit(ctx); err != nil { return ctrl.Result{}, err } + // A hand-back that a denial began and a regrant outlived adopts the applier anew at once. + if !check.denied && check.configured.Status == metav1.ConditionTrue { + return ctrl.Result{RequeueAfter: st.requeueAfter(applierClaimSuspendingInterval)}, nil + } return ctrl.Result{RequeueAfter: st.requeueAfter(heldClaimSteadyInterval)}, nil } +// handedBackCondition is the InControl condition of a live applier claim that has handed its applier +// back, saying what makes it hold the applier again. +func handedBackCondition(claim configbutleraiv1alpha3.ApplierClaim, check *claimCheck) conditionValue { + switch { + case check.denied: + return conditionValue{Status: metav1.ConditionUnknown, Reason: ReasonNotConfigured, + Message: fmt.Sprintf("the %s was handed back, and is held again once the KubeProvider's "+ + "spec.allowedApplierClaims admit the applier claim", claim.ApplierKind())} + case check.refs.Named(): + return conditionValue{Status: metav1.ConditionUnknown, Reason: ReasonNotConfigured, + Message: fmt.Sprintf("the %s was handed back, and is held again once an eligible GitTarget "+ + "references the applier claim", claim.ApplierKind())} + } + return conditionValue{Status: metav1.ConditionFalse, Reason: applier.ReasonNotReferenced, + Message: fmt.Sprintf("nothing to hold: no GitTarget names this applier claim, and its %s was handed back", + claim.ApplierKind())} +} + // clearPreviousSuspendState drops what an applier claim recorded of its applier before it held it, once it // has handed it back (or on to a successor): a later adoption records anew. func clearPreviousSuspendState(claim configbutleraiv1alpha3.ApplierClaim) { @@ -593,42 +608,60 @@ func applierTool(claim configbutleraiv1alpha3.ApplierClaim) string { return "Flux" } -// publishReleasing sets Held=False (Releasing) and the trio on an applier claim whose hand-back is not -// done, its deadline counted from since. The condition is written anew when it was not Releasing -// before, so its lastTransitionTime says when the release began. +// publishReleasing sets InControl=False (Releasing, or Revoking for a hand-back a denial began) and the +// trio on an applier claim whose hand-back is not done, its deadline counted from since. The condition +// is written anew when no hand-back was under way before, so its lastTransitionTime says when the +// release began. func (r *ApplierClaimReconciler) publishReleasing( st *reconcileStatus, claim configbutleraiv1alpha3.ApplierClaim, check *claimCheck, since time.Time, ) { - why := "the applier claim is being deleted" - if check != nil && check.refs.Named() { + why, reason := "the applier claim is being deleted", ReasonReleasing + switch { + case check != nil && (check.denied || revoking(claim)): + why, reason = "the KubeProvider's spec.allowedApplierClaims no longer admit the applier claim", ReasonRevoking + case check != nil && check.refs.Named(): why = "no GitTarget naming the applier claim is eligible any more" - } else if check != nil { + case check != nil: why = "no GitTarget names the applier claim any more" } - held := conditionValue{Status: metav1.ConditionFalse, Reason: ReasonReleasing, Message: fmt.Sprintf( + inControl := conditionValue{Status: metav1.ConditionFalse, Reason: reason, Message: fmt.Sprintf( "%s: handing %s %s back to %s, by %s at the latest", why, claim.ApplierKind(), claim.Applier(), applierTool(claim), since.Add(releaseDeadline).UTC().Format(time.RFC3339))} - if previous := findCondition(*claim.StatusConditions(), ApplierClaimConditionHeld); previous == nil || - previous.Reason != ReasonReleasing { - st.remove(ApplierClaimConditionHeld) + if !handingBack(findCondition(*claim.StatusConditions(), ApplierClaimConditionInControl)) { + st.remove(ApplierClaimConditionInControl) } - st.setValue(ApplierClaimConditionHeld, held) + st.setValue(ApplierClaimConditionInControl, inControl) if check == nil { rd := newReadiness("", claim.ClaimKind()+" is not stalled") - rd.progressing(held.Status, held.Reason, held.Message) + rd.progressing(inControl.Status, inControl.Reason, inControl.Message) st.applyReadiness(rd) return } - st.applyReadiness(r.claimReadiness(claim, check.configured, &held)) + st.applyReadiness(r.claimReadiness(claim, check.configured, &inControl)) +} + +// revoking reports whether the applier claim is handing its applier back because its +// KubeProvider's grants refused it (InControl=False, Revoking): such a hand-back finishes even after a +// regrant, and the applier is adopted anew once it has. +func revoking(claim configbutleraiv1alpha3.ApplierClaim) bool { + inControl := findCondition(*claim.StatusConditions(), ApplierClaimConditionInControl) + return inControl != nil && inControl.Reason == ReasonRevoking +} + +// handingBack reports whether an InControl condition says a hand-back is under way: Releasing, or +// Revoking. A denial arriving during a release continues the same hand-back, with the same deadline. +func handingBack(inControl *metav1.Condition) bool { + return inControl != nil && (inControl.Reason == ReasonReleasing || inControl.Reason == ReasonRevoking) } -// releasingSince is when the applier claim's Held condition turned Releasing, or now when it has not. +// releasingSince is when the applier claim's InControl condition turned Releasing or Revoking, or now +// when it has not. func releasingSince(claim configbutleraiv1alpha3.ApplierClaim) time.Time { - held := findCondition(*claim.StatusConditions(), ApplierClaimConditionHeld) - if held == nil || held.Reason != ReasonReleasing || held.LastTransitionTime.IsZero() { + inControl := findCondition(*claim.StatusConditions(), ApplierClaimConditionInControl) + if !handingBack(inControl) || inControl.LastTransitionTime.IsZero() { return time.Now() } - return held.LastTransitionTime.Time + return inControl.LastTransitionTime.Time } // letGoOfOperation completes an operation a retired applier claim still records in flight, when this @@ -872,48 +905,49 @@ func (r *ApplierClaimReconciler) event(object client.Object, eventType, reason, } } -// publishConsumers sets the applier claim's status.source.otherConsumers: the consumers of its GitRepository -// in the applier's cluster, other than its own Kustomization, that no FluxClaim on the same -// KubeProvider holds. A listing that fails publishes none. Information only (decision 2): it never +// publishAlsoAppliedBy sets the applier claim's status.source.alsoAppliedBy: the objects applying its +// GitRepository in the applier's cluster, other than its own Kustomization, that no FluxClaim on the +// same KubeProvider holds. A listing that fails publishes none. Information only (decision 2): it never // affects Ready. -func (r *ApplierClaimReconciler) publishConsumers(ctx context.Context, claim *configbutleraiv1alpha3.FluxClaim) { +func (r *ApplierClaimReconciler) publishAlsoAppliedBy(ctx context.Context, claim *configbutleraiv1alpha3.FluxClaim) { source := claim.Status.Source if source == nil || r.ApplierClients == nil { return } - source.OtherConsumers = nil + source.AlsoAppliedBy = nil ctx, cancel := context.WithTimeout(ctx, applierCheckTimeout) defer cancel() flux, err := r.fluxClient(ctx, claim) if err != nil { return } - consumers, err := flux.Consumers(ctx, k8stypes.NamespacedName{ + appliedBy, err := flux.AppliedBy(ctx, k8stypes.NamespacedName{ Namespace: source.GitRepository.Namespace, Name: source.GitRepository.Name, }) if err != nil { - logf.FromContext(ctx).V(1).Info("cannot list the GitRepository's consumers", + logf.FromContext(ctx).V(1).Info("cannot list what applies the GitRepository", "gitRepository", source.GitRepository.Namespace+"/"+source.GitRepository.Name, "error", err.Error()) return } - others, err := r.heldKustomizations(ctx, claim.KubeProviderKey()) + others, err := r.kustomizationsInControl(ctx, claim.KubeProviderKey()) if err != nil { return } - for _, consumer := range consumers { - key := k8stypes.NamespacedName{Namespace: consumer.Namespace, Name: consumer.Name} + for _, applying := range appliedBy { + key := k8stypes.NamespacedName{Namespace: applying.Namespace, Name: applying.Name} itself := key == claim.Applier() - if consumer.Kind == configbutleraiv1alpha3.ApplierKindKustomization && (itself || others[key]) { + if applying.Kind == configbutleraiv1alpha3.ApplierKindKustomization && (itself || others[key]) { continue } - source.OtherConsumers = append(source.OtherConsumers, consumer) + source.AlsoAppliedBy = append(source.AlsoAppliedBy, applying) } } -// heldKustomizations is every Kustomization a FluxClaim on the KubeProvider holds: one whose -// Held condition is True. An applier claim that is not Configured (SuspendOwnedElsewhere), or whose -// Kustomization has not handled its suspend yet, does not hold it: Flux may still apply a new artifact on its own. -func (r *ApplierClaimReconciler) heldKustomizations( +// kustomizationsInControl is every Kustomization a FluxClaim on the KubeProvider is in control of: one +// whose InControl condition is True. An applier claim that is not Configured (SuspendOwnedElsewhere), +// or whose Kustomization has not handled its suspend yet, is not in control of it: Flux may still +// apply a new artifact on its own. +func (r *ApplierClaimReconciler) kustomizationsInControl( ctx context.Context, cluster k8stypes.NamespacedName, ) (map[k8stypes.NamespacedName]bool, error) { @@ -921,12 +955,12 @@ func (r *ApplierClaimReconciler) heldKustomizations( if err := r.List(ctx, &claims); err != nil { return nil, err } - held := map[k8stypes.NamespacedName]bool{} + inControl := map[k8stypes.NamespacedName]bool{} for i := range claims.Items { claim := &claims.Items[i] - if claim.DeletionTimestamp.IsZero() && claim.KubeProviderKey() == cluster && heldBefore(claim) { - held[claim.Applier()] = true + if claim.DeletionTimestamp.IsZero() && claim.KubeProviderKey() == cluster && wasInControl(claim) { + inControl[claim.Applier()] = true } } - return held, nil + return inControl, nil } diff --git a/internal/controller/fluxclaim_hold_test.go b/internal/controller/fluxclaim_hold_test.go index 5477edd6a..9641e7184 100644 --- a/internal/controller/fluxclaim_hold_test.go +++ b/internal/controller/fluxclaim_hold_test.go @@ -95,10 +95,10 @@ func TestFluxClaim_AdoptsItsKustomization(t *testing.T) { got := reconcileClaim(t, f.r).(*configbutleraiv1alpha3.FluxClaim) - held := findCondition(got.Status.Conditions, ApplierClaimConditionHeld) - require.NotNil(t, held) - assert.Equal(t, metav1.ConditionTrue, held.Status, held.Message) - assert.Equal(t, ReasonSuspended, held.Reason) + inControl := findCondition(got.Status.Conditions, ApplierClaimConditionInControl) + require.NotNil(t, inControl) + assert.Equal(t, metav1.ConditionTrue, inControl.Status, inControl.Message) + assert.Equal(t, ReasonSuspended, inControl.Reason) assert.True(t, conditionIsTrue(got.Status.Conditions, ConditionTypeReady)) ks := f.sim.Kustomization(checkoutKustomization) assert.True(t, suspendedSpec(ks)) @@ -146,7 +146,7 @@ func TestFluxClaim_AnExternalResumeIsSuspendedAgain(t *testing.T) { "complete once the suspend's token is handled") assert.NotNil(t, got.Status.Operation.FinishedAt) assert.Equal(t, recorded.SuspendToken, got.Status.Operation.SuspendToken) - assert.True(t, conditionIsTrue(got.Status.Conditions, ApplierClaimConditionHeld)) + assert.True(t, conditionIsTrue(got.Status.Conditions, ApplierClaimConditionInControl)) } assert.Len(t, f.sim.Applied(), 2, "each resume applied once, unchecked; that is what the record says") } @@ -158,7 +158,7 @@ func TestFluxClaim_LeavesABranchWorkersOperationAlone(t *testing.T) { claim := testFluxClaim(claimLocalProvider) claim.Finalizers = []string{configbutleraiv1alpha3.FluxClaimFinalizer} claim.Status.Conditions = []metav1.Condition{{ - Type: ApplierClaimConditionHeld, Status: metav1.ConditionTrue, Reason: ReasonSuspended, + Type: ApplierClaimConditionInControl, Status: metav1.ConditionTrue, Reason: ReasonSuspended, LastTransitionTime: metav1.Now(), }} claim.Status.Operation = &configbutleraiv1alpha3.ApplyOperation{ @@ -185,7 +185,7 @@ func workerOperationClaim() *configbutleraiv1alpha3.FluxClaim { claim := testFluxClaim(claimLocalProvider) claim.Finalizers = []string{configbutleraiv1alpha3.FluxClaimFinalizer} claim.Status.Conditions = []metav1.Condition{{ - Type: ApplierClaimConditionHeld, Status: metav1.ConditionTrue, Reason: ReasonSuspended, + Type: ApplierClaimConditionInControl, Status: metav1.ConditionTrue, Reason: ReasonSuspended, LastTransitionTime: metav1.Now(), }} claim.Status.Operation = &configbutleraiv1alpha3.ApplyOperation{ @@ -210,10 +210,10 @@ func TestFluxClaim_AnOperationNobodyFollowsIsSuspended(t *testing.T) { assert.False(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization)), "followed: left to the worker") applying := &configbutleraiv1alpha3.FluxClaim{} require.NoError(t, f.r.Get(context.Background(), claimKey, applying)) - held := findCondition(applying.Status.Conditions, ApplierClaimConditionHeld) - require.NotNil(t, held) - assert.Equal(t, metav1.ConditionTrue, held.Status, "an operation of ours in flight is held") - assert.Equal(t, ReasonApplying, held.Reason) + inControl := findCondition(applying.Status.Conditions, ApplierClaimConditionInControl) + require.NotNil(t, inControl) + assert.Equal(t, metav1.ConditionTrue, inControl.Status, "an operation of ours in flight is held") + assert.Equal(t, ReasonApplying, inControl.Reason) f.r.Follower = followerStub{} got := reconcileClaim(t, f.r).(*configbutleraiv1alpha3.FluxClaim) @@ -224,7 +224,7 @@ func TestFluxClaim_AnOperationNobodyFollowsIsSuspended(t *testing.T) { assert.NotEmpty(t, got.Status.Operation.SuspendToken) assert.NotNil(t, got.Status.Operation.FinishedAt) assert.Contains(t, got.Status.Operation.Message, "followed by nobody") - assert.True(t, conditionIsTrue(got.Status.Conditions, ApplierClaimConditionHeld)) + assert.True(t, conditionIsTrue(got.Status.Conditions, ApplierClaimConditionInControl)) events := f.events() require.Len(t, events, 1) assert.True(t, strings.HasPrefix(events[0], "Warning OperationUnfollowed the Refusal apply"), events[0]) @@ -420,7 +420,7 @@ func TestFluxClaim_TakesOverASuspendBySomebodyElse(t *testing.T) { got := reconcileClaim(t, f.r).(*configbutleraiv1alpha3.FluxClaim) - assert.True(t, conditionIsTrue(got.Status.Conditions, ApplierClaimConditionHeld)) + assert.True(t, conditionIsTrue(got.Status.Conditions, ApplierClaimConditionInControl)) assert.Contains(t, kustomizationManagers(f.sim.Kustomization(checkoutKustomization)), applier.SuspendFieldManager) assert.Empty(t, f.events(), "neither an adoption of a running Kustomization nor an external resume") } @@ -456,9 +456,9 @@ func kustomizationManagers(ks *unstructured.Unstructured) []string { return managers } -// TestFluxClaim_PublishesTheSourcesOtherConsumers is decision 2: the consumers of the +// TestFluxClaim_PublishesWhatElseAppliesTheSource is decision 2: the objects applying the // GitRepository that no FluxClaim holds are listed, information only. -func TestFluxClaim_PublishesTheSourcesOtherConsumers(t *testing.T) { +func TestFluxClaim_PublishesWhatElseAppliesTheSource(t *testing.T) { objs := applierFluxObjects() infra := objs[0].(*unstructured.Unstructured).DeepCopy() infra.SetName("infra") @@ -478,11 +478,11 @@ func TestFluxClaim_PublishesTheSourcesOtherConsumers(t *testing.T) { assert.Equal(t, []meta.NamespacedObjectKindReference{ {Kind: "HelmRelease", Namespace: "apps", Name: "podinfo"}, {Kind: "Kustomization", Namespace: "flux-system", Name: "infra"}, - }, source.OtherConsumers, "itself is not another consumer") + }, source.AlsoAppliedBy, "it does not list itself") assert.True(t, conditionIsTrue(f.claim(t).Status.Conditions, ConditionTypeReady), "information only") // The fourth review's finding C: an applier claim holds its Kustomization only once it says so - // (Held=True). One that is not Configured (SuspendOwnedElsewhere), or still suspending, leaves + // (InControl=True). One that is not Configured (SuspendOwnedElsewhere), or still suspending, leaves // a Kustomization Flux applies on its own. infraClaim := testFluxClaim(claimLocalProvider) infraClaim.Name, infraClaim.Spec.KustomizationRef.Name = "infra", "infra" @@ -492,30 +492,30 @@ func TestFluxClaim_PublishesTheSourcesOtherConsumers(t *testing.T) { {Kind: "Kustomization", Namespace: "flux-system", Name: "infra"}, } for _, suspended := range []metav1.Condition{ - {Type: ApplierClaimConditionHeld, Status: metav1.ConditionUnknown, Reason: ReasonNotConfigured}, - {Type: ApplierClaimConditionHeld, Status: metav1.ConditionFalse, Reason: ReasonSuspending}, + {Type: ApplierClaimConditionInControl, Status: metav1.ConditionUnknown, Reason: ReasonNotConfigured}, + {Type: ApplierClaimConditionInControl, Status: metav1.ConditionFalse, Reason: ReasonSuspending}, } { setClaimCondition(t, f.r, "infra", suspended) _, err := f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) require.NoError(t, err) - assert.Equal(t, infraOther, f.claim(t).Status.Source.OtherConsumers, + assert.Equal(t, infraOther, f.claim(t).Status.Source.AlsoAppliedBy, "an applier claim that is %s does not hold its Kustomization", suspended.Reason) } setClaimCondition(t, f.r, "infra", metav1.Condition{ - Type: ApplierClaimConditionHeld, Status: metav1.ConditionTrue, Reason: ReasonSuspended, + Type: ApplierClaimConditionInControl, Status: metav1.ConditionTrue, Reason: ReasonSuspended, }) _, err := f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) require.NoError(t, err) assert.Equal(t, []meta.NamespacedObjectKindReference{{Kind: "HelmRelease", Namespace: "apps", Name: "podinfo"}}, - f.claim(t).Status.Source.OtherConsumers, "a Kustomization another applier claim holds is not listed") + f.claim(t).Status.Source.AlsoAppliedBy, "a Kustomization another applier claim holds is not listed") require.NoError(t, f.sim.Client.Resource(k8sschema.GroupVersionResource{ Group: "helm.toolkit.fluxcd.io", Version: "v2", Resource: "helmreleases", }).Namespace("apps").Delete(context.Background(), "podinfo", metav1.DeleteOptions{})) _, err = f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) require.NoError(t, err) - assert.Empty(t, f.claim(t).Status.Source.OtherConsumers, "every consumer is held") + assert.Empty(t, f.claim(t).Status.Source.AlsoAppliedBy, "everything applying the source is held") } // setClaimCondition publishes a condition on the FluxClaim team-a/name. @@ -602,7 +602,7 @@ func TestFluxClaim_AHandBackThatNeverEndsLetsGoAtTheDeadline(t *testing.T) { claim.DeletionTimestamp = &metav1.Time{Time: time.Now()} // The deadline counts from when Held turned Releasing, not from the deletion. claim.Status.Conditions = []metav1.Condition{{ - Type: ApplierClaimConditionHeld, Status: metav1.ConditionFalse, Reason: ReasonReleasing, + Type: ApplierClaimConditionInControl, Status: metav1.ConditionFalse, Reason: ReasonReleasing, LastTransitionTime: metav1.NewTime(time.Now().Add(-deleted)), }} f := newHoldFixture(t, claim, ks, objs[1]) @@ -737,10 +737,10 @@ func TestFluxClaim_RecordsNothingOfAKustomizationAnotherHolds(t *testing.T) { got := reconcileClaim(t, f.r).(*configbutleraiv1alpha3.FluxClaim) - held := findCondition(got.Status.Conditions, ApplierClaimConditionHeld) - require.NotNil(t, held) - assert.Equal(t, ReasonNotConfigured, held.Reason) - assert.Contains(t, held.Message, "team-a/checkout-first") + inControl := findCondition(got.Status.Conditions, ApplierClaimConditionInControl) + require.NotNil(t, inControl) + assert.Equal(t, ReasonNotConfigured, inControl.Reason) + assert.Contains(t, inControl.Message, "team-a/checkout-first") assert.Empty(t, got.Status.PreviousSuspendState) assert.False(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization)), "nothing written") } @@ -784,17 +784,17 @@ func TestFluxClaim_ReadoptsAKustomizationItHandedBackSuspended(t *testing.T) { f.events() setReferenced(t, f.r.Client, "checkout-flux", configbutleraiv1alpha3.ApplierClaimKindFlux, true) - var held *metav1.Condition + var inControl *metav1.Condition for range 50 { _, err := f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) require.NoError(t, err) - held = findCondition(f.claim(t).Status.Conditions, ApplierClaimConditionHeld) - if held != nil && held.Reason == ReasonSuspended { + inControl = findCondition(f.claim(t).Status.Conditions, ApplierClaimConditionInControl) + if inControl != nil && inControl.Reason == ReasonSuspended { break } } - require.NotNil(t, held) - assert.Equal(t, ReasonSuspended, held.Reason, held.Message) + require.NotNil(t, inControl) + assert.Equal(t, ReasonSuspended, inControl.Reason, inControl.Message) assert.Equal(t, configbutleraiv1alpha3.SuspendStateSuspended, f.claim(t).Status.PreviousSuspendState, "adopted anew: suspended before the applier claim held it") diff --git a/internal/controller/fluxclaim_suspend_apiserver_test.go b/internal/controller/fluxclaim_suspend_apiserver_test.go index ed9317d6d..9ed1d2cb8 100644 --- a/internal/controller/fluxclaim_suspend_apiserver_test.go +++ b/internal/controller/fluxclaim_suspend_apiserver_test.go @@ -144,15 +144,15 @@ var _ = Describe("A Kustomization resume guarded by uid and resourceVersion, on _, err = applier.NewFlux(dyn).Suspend(ctx, ref) Expect(err).NotTo(HaveOccurred()) } - // request asks Flux.Request for an apply of commit, with held run where the applier claim's hold is - // checked: after the resume's read of the Kustomization, before its write. - request := func(held func(context.Context) error) (applier.Started, error) { + // request asks Flux.Request for an apply of commit, with inControl run where the applier claim's + // InControl is checked: after the resume's read of the Kustomization, before its write. + request := func(inControl func(context.Context) error) (applier.Started, error) { return applier.NewFlux(dyn).Request(ctx, ref, applier.Ask{ - Want: applier.Binding{RepositoryURL: repositoryURL, Branch: branch}, - Commit: commit, - Reason: configbutleraiv1alpha3.ApplyReasonRepair, - Token: "guarded-resume", - Held: held, + Want: applier.Binding{RepositoryURL: repositoryURL, Branch: branch}, + Commit: commit, + Reason: configbutleraiv1alpha3.ApplyReasonRepair, + Token: "guarded-resume", + InControl: inControl, }) } read := func() *unstructured.Unstructured { diff --git a/internal/controller/git_branch_helpers_test.go b/internal/controller/git_branch_helpers_test.go index 4121454cf..6db7fb634 100644 --- a/internal/controller/git_branch_helpers_test.go +++ b/internal/controller/git_branch_helpers_test.go @@ -27,7 +27,12 @@ func claimWorkerForTest(m *git.WorkerManager, repo git.RepoIdentity) git.WorkerR UID: k8stypes.UID(shopRepo1Main.String()), Created: time.Unix(0, 0), }, true) - return git.WorkerRequest{GitBranch: shopRepo1Main, Provider: "repo1", Branch: "main", Repo: repo} + return git.WorkerRequest{ + GitBranch: shopRepo1Main, + Provider: k8stypes.NamespacedName{Namespace: "shop", Name: "repo1"}, + Branch: "main", + Repo: repo, + } } // gitBranchOn is the GitBranch "-" in namespace, writing branch through provider. @@ -35,7 +40,8 @@ func gitBranchOn(provider, namespace, branch string) *configbutleraiv1alpha3.Git return &configbutleraiv1alpha3.GitBranch{ ObjectMeta: metav1.ObjectMeta{Name: gitBranchNameForTest(provider, branch), Namespace: namespace}, Spec: configbutleraiv1alpha3.GitBranchSpec{ - GitProviderRef: meta.LocalObjectReference{Name: provider}, Branch: branch, + GitProviderRef: meta.NamespacedObjectReference{Name: provider}, Branch: branch, + AllowedGitTargets: writersFromSame(), }, } } diff --git a/internal/controller/gitbranch_access_test.go b/internal/controller/gitbranch_access_test.go new file mode 100644 index 000000000..a921a6a8b --- /dev/null +++ b/internal/controller/gitbranch_access_test.go @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "strings" + "testing" + "time" + + meta "github.com/fluxcd/pkg/apis/meta" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8stypes "k8s.io/apimachinery/pkg/types" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" + + configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/git" +) + +// sharedProvider is the GitProvider platform/shared-repo, letting the named namespaces claim every +// branch. +func sharedProvider(namespaces ...string) *configbutleraiv1alpha3.GitProvider { + provider := branchProvider("platform", "shared-repo") + provider.Spec.AllowedGitBranches = []configbutleraiv1alpha3.GitProviderGitBranchGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromNames, + Names: grantNames(namespaces...), + }, + BranchPatterns: []configbutleraiv1alpha3.BranchPattern{"*"}, + }, + } + return provider +} + +// tenantBranch is a GitBranch namespace/main claiming main through platform/shared-repo, created +// age ago. +func tenantBranch(namespace string, age time.Duration) *configbutleraiv1alpha3.GitBranch { + branch := branchObject(namespace, "main", "shared-repo", "main", age) + branch.Spec.GitProviderRef = meta.NamespacedObjectReference{Name: "shared-repo", Namespace: "platform"} + return branch +} + +// grantOnly replaces the shared provider's grant so it admits exactly namespaces. +func (f *gitBranchFixture) grantOnly(t *testing.T, namespaces ...string) { + t.Helper() + stored := &configbutleraiv1alpha3.GitProvider{} + key := k8stypes.NamespacedName{Namespace: "platform", Name: "shared-repo"} + require.NoError(t, f.client.Get(context.Background(), key, stored)) + stored.Spec.AllowedGitBranches = sharedProvider(namespaces...).Spec.AllowedGitBranches + if len(namespaces) == 0 { + stored.Spec.AllowedGitBranches = nil + } + require.NoError(t, f.client.Update(context.Background(), stored)) +} + +func (f *gitBranchFixture) holder() (git.BranchClaimant, bool) { + return f.workers.Claims().Holder(git.BranchKeyFor(gitBranchTestURL, "main")) +} + +// TestGitBranch_AnUnadmittedGitBranchNeverOpensAPushSession: admission comes before the proof, so a +// GitBranch no grant admits never uses the shared credential. +func TestGitBranch_AnUnadmittedGitBranchNeverOpensAPushSession(t *testing.T) { + f := newGitBranchFixture(t, sharedProvider("team-a"), tenantBranch("team-b", time.Hour)) + proofs := 0 + f.r.proveProvider = func(*configbutleraiv1alpha3.GitProvider) error { proofs++; return nil } + + got := f.reconcile(t, "team-b", "main") + + assert.Zero(t, proofs, "no push session is opened with a credential the GitBranch was not granted") + assert.Equal(t, GitBranchReasonNamespaceNotAuthorized, readyOf(got).Reason) + assert.Contains( + t, + readyOf(got).Message, + `GitProvider platform/shared-repo admits team-a in spec.allowedGitBranches; `+ + `namespace "team-b" is not admitted`, + ) + assert.True(t, conditionIsTrue(got.Status.Conditions, ConditionTypeStalled)) + _, held := f.holder() + assert.False(t, held) +} + +// TestGitBranch_AGitBranchAdmittedLaterTakesTheBranchByAge: once a grant admits it, a GitBranch proves +// itself and becomes a candidate like any other, and the oldest candidate holds the branch, also +// when the claims are recomputed after a restart. +func TestGitBranch_AGitBranchAdmittedLaterTakesTheBranchByAge(t *testing.T) { + f := newGitBranchFixture(t, sharedProvider("team-b"), + tenantBranch("team-a", 2*time.Hour), tenantBranch("team-b", time.Hour)) + f.reconcile(t, "team-a", "main") + f.reconcile(t, "team-b", "main") + holder, held := f.holder() + require.True(t, held) + assert.Equal(t, "team-b/main", holder.GitBranch.String(), "only the admitted one holds the branch") + + f.grantOnly(t, "team-a", "team-b") + older := f.reconcile(t, "team-a", "main") + younger := f.reconcile(t, "team-b", "main") + + assert.Equal(t, metav1.ConditionTrue, readyOf(older).Status, readyOf(older).Message) + assert.Equal(t, GitBranchReasonBranchConflict, readyOf(younger).Reason, + "the older GitBranch takes the branch once it is admitted and proves itself") + + // After a restart the claims are recomputed from the objects alone, in whatever order. + restarted := newGitBranchFixture(t) + restarted.client = f.client + restarted.r.Client = f.client + restarted.reconcile(t, "team-b", "main") + restarted.reconcile(t, "team-a", "main") + holder, held = restarted.holder() + require.True(t, held) + assert.Equal(t, "team-a/main", holder.GitBranch.String()) +} + +// TestGitBranch_RevocationDrainsAndKeepsTheFinalizer: a GitBranch whose grant is withdrawn gives up +// its claim and retires its worker, as deletion does, but keeps its finalizer and reports why. The +// old worker takes nothing new, and the successor starts a worker of its own. +func TestGitBranch_RevocationDrainsAndKeepsTheFinalizer(t *testing.T) { + f := newGitBranchFixture(t, sharedProvider("team-a", "team-b"), + tenantBranch("team-a", 2*time.Hour), tenantBranch("team-b", time.Hour)) + f.reconcile(t, "team-a", "main") + f.reconcile(t, "team-b", "main") + revoked := k8stypes.NamespacedName{Namespace: "team-a", Name: "main"} + repo := git.RepoIdentity{ProviderUID: "uid-shared-repo", URL: gitBranchTestURL} + provider := k8stypes.NamespacedName{Namespace: "platform", Name: "shared-repo"} + require.NoError(t, f.workers.EnsureWorker(context.Background(), git.WorkerRequest{ + GitBranch: revoked, Provider: provider, Branch: "main", Repo: repo, + })) + old, live := f.workers.WorkerForGitBranch(revoked) + require.True(t, live) + + f.grantOnly(t, "team-b") + got := f.reconcile(t, "team-a", "main") + + assert.Equal(t, GitBranchReasonNamespaceNotAuthorized, readyOf(got).Reason) + assert.Contains(t, readyOf(got).Message, "gave up the branch") + assert.True(t, conditionIsTrue(got.Status.Conditions, ConditionTypeStalled)) + assert.Contains(t, got.GetFinalizers(), GitBranchFinalizer, "a revoked GitBranch is not being deleted") + _, live = f.workers.WorkerForGitBranch(revoked) + assert.False(t, live, "the worker retired, pushing or refusing what it held") + assert.False(t, old.Enqueue(git.Event{}), "an event arriving after the revocation is refused") + + _, held := f.holder() + assert.False(t, held, "the branch passes to nobody until the successor has reconciled") + require.Len(t, f.workers.GitBranchEvents(), 1, "the successor is woken") + <-f.workers.GitBranchEvents() + f.reconcile(t, "team-b", "main") + holder, held := f.holder() + require.True(t, held) + assert.Equal(t, "team-b/main", holder.GitBranch.String()) + successor := k8stypes.NamespacedName{Namespace: "team-b", Name: "main"} + require.NoError(t, f.workers.EnsureWorker(context.Background(), git.WorkerRequest{ + GitBranch: successor, Provider: provider, Branch: "main", Repo: repo, + })) + fresh, live := f.workers.WorkerForGitBranch(successor) + require.True(t, live) + assert.NotSame(t, old, fresh, "the successor receives none of the old worker's work") + + // Restoring the grant re-admits it; it proves itself and, being older, takes the branch back. + f.grantOnly(t, "team-a", "team-b") + back := f.reconcile(t, "team-a", "main") + assert.Equal(t, metav1.ConditionTrue, readyOf(back).Status, readyOf(back).Message) +} + +// TestGitBranch_OneEditRevokesTwoCandidates: a grant edit that revokes the holder and its successor +// together hands the branch to neither. The holder's release elects nobody, so a GitTarget reconcile +// racing the handoff cannot start the revoked successor's worker; each waiting GitBranch takes the +// branch only through its own reconcile, which checks its grant first, in whichever order they run. +func TestGitBranch_OneEditRevokesTwoCandidates(t *testing.T) { + for _, order := range [][]string{{"team-b", "team-c"}, {"team-c", "team-b"}} { + t.Run(strings.Join(order, "-then-"), func(t *testing.T) { + f := newGitBranchFixture(t, sharedProvider("team-a", "team-b", "team-c"), + tenantBranch("team-a", 3*time.Hour), tenantBranch("team-b", 2*time.Hour), + tenantBranch("team-c", time.Hour)) + for _, ns := range []string{"team-a", "team-b", "team-c"} { + f.reconcile(t, ns, "main") + } + + f.grantOnly(t, "team-c") + f.reconcile(t, "team-a", "main") + + revoked := k8stypes.NamespacedName{Namespace: "team-b", Name: "main"} + err := f.workers.EnsureWorker(context.Background(), git.WorkerRequest{ + GitBranch: revoked, Provider: k8stypes.NamespacedName{Namespace: "platform", Name: "shared-repo"}, + Branch: "main", Repo: git.RepoIdentity{ProviderUID: "uid-shared-repo", URL: gitBranchTestURL}, + }) + require.ErrorIs(t, err, git.ErrBranchOwnedElsewhere, "the revoked successor was handed nothing") + woken := []string{} + for len(f.workers.GitBranchEvents()) > 0 { + event := <-f.workers.GitBranchEvents() + woken = append(woken, event.Object.GetNamespace()) + } + assert.Equal(t, []string{"team-b", "team-c"}, woken, "the waiting GitBranches are woken, oldest first") + + for _, ns := range order { + f.reconcile(t, ns, "main") + } + holder, held := f.holder() + require.True(t, held) + assert.Equal(t, "team-c/main", holder.GitBranch.String(), "team-b, also revoked, never holds the branch") + _, live := f.workers.WorkerForGitBranch(revoked) + assert.False(t, live) + got := &configbutleraiv1alpha3.GitBranch{} + require.NoError(t, f.client.Get(context.Background(), revoked, got)) + assert.Equal(t, GitBranchReasonNamespaceNotAuthorized, readyOf(got).Reason) + }) + } +} + +// TestGitBranch_ANamespaceLabelLossRevokesASelectorGrant: a label removed from a claimant's namespace +// revokes a Selector grant on its next reconcile. +func TestGitBranch_ANamespaceLabelLossRevokesASelectorGrant(t *testing.T) { + tenant := map[string]string{"tenant": "true"} + provider := branchProvider("platform", "shared-repo") + provider.Spec.AllowedGitBranches = []configbutleraiv1alpha3.GitProviderGitBranchGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: tenant}, + }, + BranchPatterns: []configbutleraiv1alpha3.BranchPattern{"teams/{consumer.namespace}/*", "main"}, + }, + } + namespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-a", Labels: tenant}} + f := newGitBranchFixture(t, provider, namespace, tenantBranch("team-a", time.Hour)) + got := f.reconcile(t, "team-a", "main") + require.Equal(t, metav1.ConditionTrue, readyOf(got).Status, readyOf(got).Message) + + stored := &corev1.Namespace{} + require.NoError(t, f.client.Get(context.Background(), ctrlclient.ObjectKeyFromObject(namespace), stored)) + stored.Labels = nil + require.NoError(t, f.client.Update(context.Background(), stored)) + got = f.reconcile(t, "team-a", "main") + + assert.Equal(t, GitBranchReasonNamespaceNotAuthorized, readyOf(got).Reason) + _, held := f.holder() + assert.False(t, held) +} diff --git a/internal/controller/gitbranch_controller.go b/internal/controller/gitbranch_controller.go index 6383ae363..fe86b80b2 100644 --- a/internal/controller/gitbranch_controller.go +++ b/internal/controller/gitbranch_controller.go @@ -5,10 +5,10 @@ package controller import ( "context" "fmt" - "path/filepath" "sync" "time" + corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -25,6 +25,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/source" configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/authz" "github.com/ConfigButler/gitops-reverser/internal/git" "github.com/ConfigButler/gitops-reverser/internal/repourl" "github.com/ConfigButler/gitops-reverser/internal/telemetry" @@ -36,13 +37,16 @@ const GitBranchFinalizer = "configbutler.ai/git-branch" const ( // GitBranchReasonGitProviderNotFound is the stall for a GitBranch whose GitProvider does not - // exist in its namespace. + // exist. GitBranchReasonGitProviderNotFound = "GitProviderNotFound" // GitBranchReasonGitProviderNotReady is the progress reason while the GitProvider is not Ready. GitBranchReasonGitProviderNotReady = "GitProviderNotReady" - // GitBranchReasonBranchNotAllowed is the stall for a branch outside the GitProvider's - // allowedBranches. - GitBranchReasonBranchNotAllowed = "BranchNotAllowed" + // GitBranchReasonBranchNotAllowed is the stall for a branch outside the patterns the + // GitProvider's spec.allowedGitBranches give this GitBranch's namespace. + GitBranchReasonBranchNotAllowed = authz.ReasonBranchNotAllowed + // GitBranchReasonNamespaceNotAuthorized is the stall for a GitBranch whose namespace no grant in + // the GitProvider's spec.allowedGitBranches admits. + GitBranchReasonNamespaceNotAuthorized = authz.ReasonNamespaceNotAuthorized // GitBranchReasonPushSessionFailed is the progress reason while the credential cannot open a // push (receive-pack) session on the remote. A GitBranch that holds its branch keeps it. GitBranchReasonPushSessionFailed = "PushSessionFailed" @@ -189,11 +193,19 @@ func (r *GitBranchReconciler) evaluate( Created: branch.CreationTimestamp.Time, } - if !branchAllowed(provider.Spec.AllowedBranches, branch.Spec.Branch) { - r.observeClaim(key, claimant, false) - rd.stalled(GitBranchReasonBranchNotAllowed, fmt.Sprintf( - "Branch '%s' does not match any pattern in allowedBranches list %v of GitProvider '%s'", - branch.Spec.Branch, provider.Spec.AllowedBranches, branch.GitProviderKey())) + // Admission comes before the proof, so a GitBranch no grant admits never opens a push session + // with the provider's credential. + admitted, err := authz.GitBranchClaimAdmitted(ctx, r.Client, branch, &provider) + if err != nil { + return nil, 0, err + } + if !admitted.Allowed { + message := admitted.Message + if r.relinquish(branch) { + message += "; this GitBranch gave up the branch, and its branch worker pushed or refused the writes " + + "it held" + } + rd.stalled(admitted.Reason, message) return rd, 0, nil } @@ -360,25 +372,13 @@ func (r *GitBranchReconciler) ensureFinalizer( return nil } -// finalize gives up the branch. The claim goes first, so no GitTarget reconcile can start this -// GitBranch's worker again; then the worker is stopped, which closes its intake, pushes or refuses -// every pending write and settles every CommitRequest it holds (BranchWorker.Stop). Only then is -// the finalizer removed. Nothing deletes the branch on the remote. +// finalize gives up the branch (relinquish), and only then removes the finalizer. Nothing deletes +// the branch on the remote. func (r *GitBranchReconciler) finalize(ctx context.Context, branch *configbutleraiv1alpha3.GitBranch) error { if !controllerutil.ContainsFinalizer(branch, GitBranchFinalizer) { return nil } - if r.WorkerManager != nil { - claims := r.WorkerManager.Claims() - key, _, held := claims.HeldBy(client.ObjectKeyFromObject(branch)) - claims.Release(branch.UID) - r.WorkerManager.RetireGitBranch(client.ObjectKeyFromObject(branch)) - // The next candidate holds the branch now. It may be a GitBranch stalled on BranchConflict, - // which nothing else would reconcile before its steady requeue. - if next, taken := claims.Holder(key); held && taken { - r.WorkerManager.WakeGitBranch(next.GitBranch) - } - } + r.relinquish(branch) r.proofsMu.Lock() delete(r.proofs, branch.UID) r.proofsMu.Unlock() @@ -390,14 +390,27 @@ func (r *GitBranchReconciler) finalize(ctx context.Context, branch *configbutler return client.IgnoreNotFound(r.Update(ctx, update)) } -// branchAllowed reports whether branch matches one of the GitProvider's allowedBranches patterns. -func branchAllowed(patterns []string, branch string) bool { - for _, pattern := range patterns { - if match, err := filepath.Match(pattern, branch); err == nil && match { - return true - } +// relinquish gives up whatever branch claim the GitBranch has, and reports whether it held its branch. +// It is the one path for a GitBranch being deleted and for one that no grant admits any more, which +// keeps its finalizer. The claim goes first, so no GitTarget reconcile can start this GitBranch's +// worker again; then the worker is stopped, which closes its intake, pushes or refuses every pending +// write and settles every CommitRequest it holds (BranchWorker.Stop). Release, not a failed proof: +// Observe keeps a holder's candidacy through a failed proof, and a revoked GitBranch must stop being +// a candidate at all. Last, once the old worker has retired so pending work never moves on, the +// GitBranches waiting for the branch are woken. Release handed it to none of them: each takes it only +// through its own reconcile, whose grant check is the one in evaluate. +func (r *GitBranchReconciler) relinquish(branch *configbutleraiv1alpha3.GitBranch) bool { + if r.WorkerManager == nil { + return false + } + claims := r.WorkerManager.Claims() + _, _, held := claims.HeldBy(client.ObjectKeyFromObject(branch)) + waiting := claims.Release(branch.UID) + r.WorkerManager.RetireGitBranch(client.ObjectKeyFromObject(branch)) + for _, next := range waiting { + r.WorkerManager.WakeGitBranch(next.GitBranch) } - return false + return held } // parentBranchNotFoundMessage says which parent is missing and what fixes it. @@ -426,8 +439,10 @@ func (r *GitBranchReconciler) SetupWithManager(mgr ctrl.Manager) error { b := ctrl.NewControllerManagedBy(mgr). For(&configbutleraiv1alpha3.GitBranch{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). // Every GitProvider event, status included: its Ready flipping is what un-blocks a GitBranch, - // and providers are few. + // its grants decide which GitBranches may claim through it, and providers are few. Watches(&configbutleraiv1alpha3.GitProvider{}, handler.EnqueueRequestsFromMapFunc(r.gitProviderToGitBranches)). + Watches(&corev1.Namespace{}, handler.EnqueueRequestsFromMapFunc(r.namespaceToGitBranches), + builder.WithPredicates(predicate.LabelChangedPredicate{})). // gitBranchRef is immutable, so only a GitTarget appearing or going changes the count. Watches(&configbutleraiv1alpha3.GitTarget{}, handler.EnqueueRequestsFromMapFunc(gitTargetToGitBranch), builder.WithPredicates(predicate.Funcs{ @@ -442,21 +457,27 @@ func (r *GitBranchReconciler) SetupWithManager(mgr ctrl.Manager) error { return b.Complete(r) } -// gitProviderToGitBranches enqueues the GitBranches in the provider's namespace that name it. +// gitProviderToGitBranches enqueues the GitBranches, from any namespace, that claim through the +// provider: its grants decide whether each may. func (r *GitBranchReconciler) gitProviderToGitBranches(ctx context.Context, obj client.Object) []ctrlreconcile.Request { - var branches configbutleraiv1alpha3.GitBranchList - if err := r.List(ctx, &branches, client.InNamespace(obj.GetNamespace())); err != nil { + branches, err := gitBranchesOf(ctx, r.Client, obj) + if err != nil { + logDependencyListError(ctx, err, "GitBranches", obj) return nil } - var requests []ctrlreconcile.Request - for i := range branches.Items { - if branches.Items[i].Spec.GitProviderRef.Name == obj.GetName() { - requests = append(requests, ctrlreconcile.Request{ - NamespacedName: client.ObjectKeyFromObject(&branches.Items[i]), - }) - } + return requestsFor(branches) +} + +// namespaceToGitBranches enqueues the GitBranches in a Namespace whose labels changed: a Selector +// grant on their GitProvider reads those labels, so the change can admit or revoke them. Their +// GitTargets follow through the GitBranch watch on the GitTarget controller. +func (r *GitBranchReconciler) namespaceToGitBranches(ctx context.Context, obj client.Object) []ctrlreconcile.Request { + var branches configbutleraiv1alpha3.GitBranchList + if err := r.List(ctx, &branches, client.InNamespace(obj.GetName())); err != nil { + logDependencyListError(ctx, err, "GitBranches", obj) + return nil } - return requests + return requestsFor(branches.Items) } // gitTargetToGitBranch enqueues the GitBranch a GitTarget references. diff --git a/internal/controller/gitbranch_controller_test.go b/internal/controller/gitbranch_controller_test.go index 5f0aa3d89..488a99e2d 100644 --- a/internal/controller/gitbranch_controller_test.go +++ b/internal/controller/gitbranch_controller_test.go @@ -29,16 +29,28 @@ const gitBranchTestURL = "https://github.com/acme/platform.git" var gitBranchEpoch = time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC) -// branchProvider is a GitProvider in namespace for the test repository, allowing branches, Ready -// unless told otherwise. It has no secretRef, so its credential resolves to anonymous and the -// fixture's prover decides what that credential may do. -func branchProvider(namespace, name string, allowed ...string) *configbutleraiv1alpha3.GitProvider { +// branchProvider is a GitProvider in namespace for the test repository, letting GitBranches in its +// own namespace claim the allowed branch patterns (every branch when none are given), Ready unless +// told otherwise. It has no secretRef, so its credential resolves to anonymous and the fixture's +// prover decides what that credential may do. +func branchProvider( + namespace, name string, + allowed ...configbutleraiv1alpha3.BranchPattern, +) *configbutleraiv1alpha3.GitProvider { if len(allowed) == 0 { - allowed = []string{"*"} + allowed = []configbutleraiv1alpha3.BranchPattern{"*"} } return &configbutleraiv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, UID: k8stypes.UID("uid-" + name)}, - Spec: configbutleraiv1alpha3.GitProviderSpec{URL: gitBranchTestURL, AllowedBranches: allowed}, + Spec: configbutleraiv1alpha3.GitProviderSpec{URL: gitBranchTestURL, + AllowedGitBranches: []configbutleraiv1alpha3.GitProviderGitBranchGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromSame, + }, + BranchPatterns: allowed, + }, + }}, Status: configbutleraiv1alpha3.GitProviderStatus{Conditions: []metav1.Condition{{ Type: ConditionTypeReady, Status: metav1.ConditionTrue, Reason: ReasonSucceeded, LastTransitionTime: metav1.Now(), @@ -54,7 +66,8 @@ func branchObject(namespace, name, provider, branch string, age time.Duration) * CreationTimestamp: metav1.NewTime(gitBranchEpoch.Add(-age)), }, Spec: configbutleraiv1alpha3.GitBranchSpec{ - GitProviderRef: meta.LocalObjectReference{Name: provider}, Branch: branch, + GitProviderRef: meta.NamespacedObjectReference{Name: provider}, Branch: branch, + AllowedGitTargets: writersFromSame(), }, } } @@ -210,7 +223,8 @@ func TestGitBranch_TheHolderKeepsItsBranchWhenItsCredentialFails(t *testing.T) { assert.Equal(t, GitBranchReasonBranchConflict, readyOf(younger).Reason) } -// TestGitBranch_TheBranchMustBeAllowed: allowedBranches globs moved here from the GitTarget. +// TestGitBranch_TheBranchMustBeAllowed: a branch matching no pattern the grants give the +// GitBranch's namespace is refused, and never claimed. func TestGitBranch_TheBranchMustBeAllowed(t *testing.T) { for branch, allowed := range map[string]bool{ "main": true, "develop": true, "feature/login": true, "release/v1.0": true, @@ -230,7 +244,7 @@ func TestGitBranch_TheBranchMustBeAllowed(t *testing.T) { assert.Equal(t, GitBranchReasonBranchNotAllowed, readyOf(got).Reason) assert.True(t, conditionIsTrue(got.Status.Conditions, ConditionTypeStalled)) _, _, held := f.workers.Claims().HeldBy(ctrlclient.ObjectKeyFromObject(got)) - assert.False(t, held, "a branch outside allowedBranches is never claimed") + assert.False(t, held, "a branch outside the granted patterns is never claimed") }) } } @@ -262,7 +276,7 @@ func TestGitBranch_DeletionStopsTheWorkerAndReleasesTheBranch(t *testing.T) { f.reconcile(t, "team-b", "main") holder := k8stypes.NamespacedName{Namespace: "platform", Name: "main"} require.NoError(t, f.workers.EnsureWorker(context.Background(), git.WorkerRequest{ - GitBranch: holder, Provider: "acme", Branch: "main", + GitBranch: holder, Provider: k8stypes.NamespacedName{Namespace: "platform", Name: "acme"}, Branch: "main", Repo: git.RepoIdentity{ProviderUID: "uid-acme", URL: gitBranchTestURL}, })) @@ -295,8 +309,11 @@ func TestGitBranch_AMissingParentStallsTheBranch(t *testing.T) { key := ctrlclient.ObjectKeyFromObject(branch) require.NoError(t, f.workers.EnsureWorker(context.Background(), git.WorkerRequest{ - GitBranch: key, Provider: "acme", Branch: "edits", ParentBranch: "release", - Repo: git.RepoIdentity{ProviderUID: "uid-acme", URL: gitBranchTestURL}, + GitBranch: key, + Provider: k8stypes.NamespacedName{Namespace: "platform", Name: "acme"}, + Branch: "edits", + ParentBranch: "release", + Repo: git.RepoIdentity{ProviderUID: "uid-acme", URL: gitBranchTestURL}, })) worker, _ := f.workers.WorkerForGitBranch(key) worker.RecordMissingParentForTest("release") @@ -323,7 +340,7 @@ func TestGitBranch_AnUnresolvedDefaultBranchStallsToo(t *testing.T) { f.reconcile(t, "platform", "edits") key := k8stypes.NamespacedName{Namespace: "platform", Name: "edits"} require.NoError(t, f.workers.EnsureWorker(context.Background(), git.WorkerRequest{ - GitBranch: key, Provider: "acme", Branch: "edits", + GitBranch: key, Provider: k8stypes.NamespacedName{Namespace: "platform", Name: "acme"}, Branch: "edits", Repo: git.RepoIdentity{ProviderUID: "uid-acme", URL: gitBranchTestURL}, })) worker, _ := f.workers.WorkerForGitBranch(key) @@ -369,7 +386,7 @@ func TestGitBranch_ANoticeIsPublishedWithoutAnyLook(t *testing.T) { f.reconcile(t, "platform", "edits") key := k8stypes.NamespacedName{Namespace: "platform", Name: "edits"} require.NoError(t, f.workers.EnsureWorker(context.Background(), git.WorkerRequest{ - GitBranch: key, Provider: "acme", Branch: "edits", + GitBranch: key, Provider: k8stypes.NamespacedName{Namespace: "platform", Name: "acme"}, Branch: "edits", Repo: git.RepoIdentity{ProviderUID: "uid-acme", URL: gitBranchTestURL}, })) assert.Nil(t, f.reconcile(t, "platform", "edits").Status.LastPushNoticeAt, "no delivery yet") @@ -394,7 +411,7 @@ func TestGitBranch_ARepointClearsTheNotice(t *testing.T) { "the same repository keeps it") key := k8stypes.NamespacedName{Namespace: "platform", Name: "main"} require.NoError(t, f.workers.EnsureWorker(context.Background(), git.WorkerRequest{ - GitBranch: key, Provider: "acme", Branch: "main", + GitBranch: key, Provider: k8stypes.NamespacedName{Namespace: "platform", Name: "acme"}, Branch: "main", Repo: git.RepoIdentity{ProviderUID: "uid-acme", URL: gitBranchTestURL}, })) require.True(t, f.workers.NotifyPush("platform", "acme", "main", "abc"), "the old repository's worker") diff --git a/internal/controller/gitprovider_controller.go b/internal/controller/gitprovider_controller.go index 5c61c7010..d1c156286 100644 --- a/internal/controller/gitprovider_controller.go +++ b/internal/controller/gitprovider_controller.go @@ -102,7 +102,6 @@ func (r *GitProviderReconciler) reconcileGitProvider( "name", gitProvider.Name, "namespace", gitProvider.Namespace, "url", gitProvider.Spec.URL, - "allowedBranches", gitProvider.Spec.AllowedBranches, "generation", gitProvider.Generation, "resourceVersion", gitProvider.ResourceVersion) diff --git a/internal/controller/gitprovider_controller_test.go b/internal/controller/gitprovider_controller_test.go index 6a89fd58f..939ac9f8d 100644 --- a/internal/controller/gitprovider_controller_test.go +++ b/internal/controller/gitprovider_controller_test.go @@ -315,8 +315,8 @@ var _ = Describe("GitProvider Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "git@github.com:test/repo.git", - AllowedBranches: []string{"main"}, + URL: "git@github.com:test/repo.git", + AllowedGitBranches: branchesFromSame("main"), SecretRef: &meta.LocalObjectReference{ Name: "nonexistent-secret", }, @@ -376,8 +376,8 @@ var _ = Describe("GitProvider Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "git@github.com:test/repo.git", - AllowedBranches: []string{"main"}, + URL: "git@github.com:test/repo.git", + AllowedGitBranches: branchesFromSame("main"), SecretRef: &meta.LocalObjectReference{ Name: "malformed-secret", }, @@ -436,8 +436,8 @@ var _ = Describe("GitProvider Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "git@github.com:test/repo.git", - AllowedBranches: []string{"main"}, + URL: "git@github.com:test/repo.git", + AllowedGitBranches: branchesFromSame("main"), Commit: &configbutleraiv1alpha3.CommitSpec{ Signing: &configbutleraiv1alpha3.CommitSigningSpec{ SecretRef: meta.LocalObjectReference{ diff --git a/internal/controller/gitprovider_immutability_test.go b/internal/controller/gitprovider_immutability_test.go index 7f5701658..a09d2d921 100644 --- a/internal/controller/gitprovider_immutability_test.go +++ b/internal/controller/gitprovider_immutability_test.go @@ -24,25 +24,28 @@ var _ = Describe("GitProvider URL Immutability", func() { interval = time.Millisecond * 250 ) - It("rejects a change to the URL but allows changing allowedBranches", func() { + It("rejects a change to the URL but allows changing allowedGitBranches", func() { ctx := context.Background() key := types.NamespacedName{Name: "immutable-provider", Namespace: "default"} provider := &configbutleraiv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Name: key.Name, Namespace: key.Namespace}, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://example.com/repo.git", - AllowedBranches: []string{"main"}, + URL: "https://example.com/repo.git", + AllowedGitBranches: branchesFromSame("main"), }, } Expect(k8sClient.Create(ctx, provider)).Should(Succeed()) DeferCleanup(func() { _ = k8sClient.Delete(ctx, provider) }) - // allowedBranches is operational and mutable: widening the set succeeds. + // allowedGitBranches is operational and mutable: widening the set succeeds. Eventually(func(g Gomega) { current := &configbutleraiv1alpha3.GitProvider{} g.Expect(k8sClient.Get(ctx, key, current)).To(Succeed()) - current.Spec.AllowedBranches = []string{"main", "develop"} + current.Spec.AllowedGitBranches[0].BranchPatterns = []configbutleraiv1alpha3.BranchPattern{ + "main", + "develop", + } g.Expect(k8sClient.Update(ctx, current)).To(Succeed()) }, timeout, interval).Should(Succeed()) diff --git a/internal/controller/gittarget_controller.go b/internal/controller/gittarget_controller.go index 754831e54..0eff8ae9f 100644 --- a/internal/controller/gittarget_controller.go +++ b/internal/controller/gittarget_controller.go @@ -308,8 +308,14 @@ func (r *GitTargetReconciler) publishAheadOfGates( ) { layout, scanned := r.observeLayout(target) publishLayout(st, target, layout, scanned) + // The provider labels join git_pushes_total, which the worker labels with the provider it + // pushes through: the GitBranch's, which may live in another namespace than the GitBranch. + provider := dest.providerKey() + if provider.Name == "" { + provider.Namespace = dest.key.Namespace + } telemetry.RecordBranchTarget( - target.Namespace, target.Name, dest.key.Namespace, dest.providerName(), dest.branchName(), + target.Namespace, target.Name, provider.Namespace, provider.Name, dest.branchName(), target.SourceCluster()) } @@ -473,10 +479,10 @@ func (r *GitTargetReconciler) evaluateValidatedGate( // KubeProvider now, not here — the GitTarget only NAMES its source cluster. The // KubeProvider's readiness is projected onto the GitTarget as a separate condition. - // Namespace authorization: a GitTarget may reference a KubeProvider only from the provider's - // own namespace or one its spec.accessFrom admits, and only while the provider holds its audit - // route. Enforced HERE and only here, on every reconcile — which - // also covers a policy tightened after the GitTarget was created. Failing this gate returns + // Namespace authorization: a GitTarget may reference a KubeProvider only while a grant in its + // spec.allowedGitTargets admits the GitTarget's namespace, and only while the provider holds its + // audit route. Enforced HERE and only here, on every reconcile — which also covers a policy + // tightened after the GitTarget was created. Failing this gate returns // before DeclareForGitTarget below, so an unauthorized target starts no watch and writes no Git. authorized, authReason, authMsg, authErr := r.checkSourceAuthorization(ctx, target) if authErr != nil { @@ -1013,11 +1019,13 @@ func (r *GitTargetReconciler) resolveDestination( return gitDestination{key: target.GitBranchKey(), branch: branch, provider: provider}, nil } -func (d gitDestination) providerName() string { +// providerKey is the GitBranch's GitProvider, its namespace resolved: a GitBranch may name a +// GitProvider in another namespace, and the worker pushes with that one's credential. +func (d gitDestination) providerKey() k8stypes.NamespacedName { if d.branch == nil { - return "" + return k8stypes.NamespacedName{} } - return d.branch.Spec.GitProviderRef.Name + return d.branch.GitProviderKey() } func (d gitDestination) branchName() string { @@ -1031,7 +1039,7 @@ func (d gitDestination) branchName() string { func (d gitDestination) workerRequest() git.WorkerRequest { return git.WorkerRequest{ GitBranch: d.key, - Provider: d.providerName(), + Provider: d.providerKey(), Branch: d.branchName(), Repo: repoIdentityOf(d.provider), ParentBranch: d.branch.Spec.ParentBranch, @@ -1439,8 +1447,8 @@ func (r *GitTargetReconciler) SetupWithManager(mgr ctrl.Manager) error { handler.EnqueueRequestsFromMapFunc(r.gitDestinationToGitTargets), builder.WithPredicates(gitBranchReadyOrSpecChanged()), ). - // A GitProvider's accessFrom decides which namespaces may reference its GitBranches, so a - // changed or freshly applied provider re-runs the GitTargets on those GitBranches. + // A GitProvider's grants decide which GitBranches may claim through it, so a changed or + // freshly applied provider re-runs the GitTargets on those GitBranches. // GenerationChangedPredicate ignores the status-only updates its controller writes itself. Watches( &configbutleraiv1alpha3.GitProvider{}, @@ -1457,8 +1465,8 @@ func (r *GitTargetReconciler) SetupWithManager(mgr ctrl.Manager) error { handler.EnqueueRequestsFromMapFunc(r.kubeProviderToGitTargets), builder.WithPredicates(kubeProviderReadyOrSpecChanged()), ). - // React to a Namespace's LABELS changing: a KubeProvider's accessFrom selector is - // evaluated against namespace labels, so a label change can grant or revoke a GitTarget's + // React to a Namespace's LABELS changing: a Selector grant is evaluated against namespace + // labels, so a label change can grant or revoke a GitTarget's // authorization. Re-enqueue the GitTargets in that namespace so the reconcile-time refusal // converges instead of waiting for the periodic reconcile. LabelChangedPredicate ignores the // unrelated namespace churn (annotations, status). @@ -1569,14 +1577,12 @@ func gitTargetsOn( case *configbutleraiv1alpha3.GitBranch: branches[client.ObjectKeyFromObject(obj)] = true case *configbutleraiv1alpha3.GitProvider: - var list configbutleraiv1alpha3.GitBranchList - if err := reader.List(ctx, &list, client.InNamespace(obj.GetNamespace())); err != nil { + list, err := gitBranchesOf(ctx, reader, obj) + if err != nil { return nil, err } - for i := range list.Items { - if list.Items[i].Spec.GitProviderRef.Name == obj.GetName() { - branches[client.ObjectKeyFromObject(&list.Items[i])] = true - } + for i := range list { + branches[client.ObjectKeyFromObject(&list[i])] = true } } if len(branches) == 0 { @@ -1597,7 +1603,7 @@ func gitTargetsOn( // gitDestinationToGitTargets maps a GitBranch or GitProvider event to every GitTarget writing // through it (gitTargetsOn): a GitBranch taking or losing its branch changes whether they can be -// wired, and a GitProvider's accessFrom decides which of their namespaces may reference it. +// wired, and a GitBranch's allowedGitTargets decide which of their namespaces may reference it. func (r *GitTargetReconciler) gitDestinationToGitTargets( ctx context.Context, obj client.Object, @@ -1607,16 +1613,12 @@ func (r *GitTargetReconciler) gitDestinationToGitTargets( logDependencyListError(ctx, err, "GitTargets", obj) return nil } - requests := make([]ctrlreconcile.Request, 0, len(targets)) - for i := range targets { - requests = append(requests, ctrlreconcile.Request{NamespacedName: client.ObjectKeyFromObject(&targets[i])}) - } - return requests + return requestsFor(targets) } // kubeProviderToGitTargets maps a KubeProvider event to every GitTarget that references it as -// its source, across ALL namespaces (accessFrom may admit any of them). It re-enqueues dependents -// when the provider's Ready flips or its accessFrom policy changes, so the projected +// its source, across ALL namespaces (a grant may admit any of them). It re-enqueues dependents +// when the provider's Ready flips or its grants change, so the projected // KubeProviderReady and the namespace-authorization refusal converge without waiting for the // periodic reconcile. func (r *GitTargetReconciler) kubeProviderToGitTargets( diff --git a/internal/controller/gittarget_controller_test.go b/internal/controller/gittarget_controller_test.go index 2d7356bd6..0d4ebfcda 100644 --- a/internal/controller/gittarget_controller_test.go +++ b/internal/controller/gittarget_controller_test.go @@ -70,8 +70,8 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedBranches: []string{"main"}, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: branchesFromSame("main"), SecretRef: &meta.LocalObjectReference{ Name: "test-secret", }, @@ -130,8 +130,8 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedBranches: []string{"main", "develop"}, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: branchesFromSame("main", "develop"), SecretRef: &meta.LocalObjectReference{ Name: "test-secret", }, @@ -250,8 +250,8 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedBranches: []string{"main"}, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: branchesFromSame("main"), SecretRef: &meta.LocalObjectReference{ Name: "test-secret", }, @@ -339,8 +339,8 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedBranches: []string{"main"}, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: branchesFromSame("main"), SecretRef: &meta.LocalObjectReference{ Name: "test-secret", }, @@ -458,8 +458,8 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedBranches: []string{"main"}, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: branchesFromSame("main"), }, } Expect(k8sClient.Create(ctx, gitProvider)).Should(Succeed()) @@ -524,8 +524,8 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedBranches: []string{"main"}, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: branchesFromSame("main"), }, } Expect(k8sClient.Create(ctx, gitProvider)).Should(Succeed()) @@ -602,8 +602,8 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedBranches: []string{"main"}, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: branchesFromSame("main"), }, } Expect(k8sClient.Create(ctx, gitProvider)).Should(Succeed()) @@ -684,8 +684,8 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedBranches: []string{"main"}, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: branchesFromSame("main"), }, } Expect(k8sClient.Create(ctx, gitProvider)).Should(Succeed()) @@ -757,8 +757,8 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedBranches: []string{"main"}, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: branchesFromSame("main"), }, } Expect(k8sClient.Create(ctx, gitProvider)).Should(Succeed()) @@ -837,8 +837,8 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedBranches: []string{"main"}, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: branchesFromSame("main"), }, } Expect(k8sClient.Create(ctx, gitProvider)).Should(Succeed()) @@ -914,7 +914,8 @@ func createGitBranchFor(ctx context.Context, provider *configbutleraiv1alpha3.Gi Name: gitBranchNameForTest(provider.Name, branch), Namespace: provider.Namespace, }, Spec: configbutleraiv1alpha3.GitBranchSpec{ - GitProviderRef: meta.LocalObjectReference{Name: provider.Name}, Branch: branch, + GitProviderRef: meta.NamespacedObjectReference{Name: provider.Name}, Branch: branch, + AllowedGitTargets: writersFromSame(), }, } Expect(k8sClient.Create(ctx, gitBranch)).Should(Succeed()) diff --git a/internal/controller/gittarget_gitbranch_test.go b/internal/controller/gittarget_gitbranch_test.go index 33d9d474d..f3dc8cb2b 100644 --- a/internal/controller/gittarget_gitbranch_test.go +++ b/internal/controller/gittarget_gitbranch_test.go @@ -11,6 +11,7 @@ import ( "github.com/go-logr/logr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -27,9 +28,8 @@ import ( "github.com/ConfigButler/gitops-reverser/internal/watch" ) -// destinationFixture is a GitTarget team-a/apps on the GitBranch platform/main, which team-a may -// reference through the GitProvider platform/acme's accessFrom, with a started worker manager and a -// router with no watch plane. +// destinationFixture is a GitTarget team-a/apps on the GitBranch platform/main, whose +// allowedGitTargets admit team-a, with a started worker manager and a router with no watch plane. type destinationFixture struct { r *GitTargetReconciler client ctrlclient.Client @@ -43,8 +43,15 @@ func newDestinationFixture(t *testing.T) *destinationFixture { require.NoError(t, clientgoscheme.AddToScheme(scheme)) require.NoError(t, configbutleraiv1alpha3.AddToScheme(scheme)) provider := branchProvider("platform", "acme") - provider.Spec.AccessFrom = &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-a"}} branch := branchObject("platform", "main", "acme", "main", time.Hour) + branch.Spec.AllowedGitTargets = []configbutleraiv1alpha3.GitBranchGitTargetGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromNames, + Names: grantNames("team-a"), + }, + }, + } target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "apps"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ @@ -97,8 +104,8 @@ func TestReconcile_AGitBranchThatHoldsNothingKeepsTheTargetWaiting(t *testing.T) } // TestReconcile_ATargetOnAHeldGitBranchInAnotherNamespaceIsWired: the GitBranch holds the branch and -// its GitProvider admits the target's namespace, so the target is wired to the holder's worker with -// that provider's identity. +// admits the target's namespace, so the target is wired to the holder's worker with its provider's +// identity. func TestReconcile_ATargetOnAHeldGitBranchInAnotherNamespaceIsWired(t *testing.T) { f := newDestinationFixture(t) f.workers.Claims().Observe(git.BranchKeyFor(gitBranchTestURL, "main"), git.BranchClaimant{ @@ -121,39 +128,50 @@ func TestReconcile_ATargetOnAHeldGitBranchInAnotherNamespaceIsWired(t *testing.T assert.Equal(t, "main", worker.Branch) } -// TestReconcile_AGitBranchWhoseProviderDoesNotAdmitTheNamespace: the cross-namespace reference is -// decided by the branch's GitProvider, against the TARGET's namespace. -func TestReconcile_AGitBranchWhoseProviderDoesNotAdmitTheNamespace(t *testing.T) { +// TestReconcile_AGitBranchThatDoesNotAdmitTheNamespace: who may write through a GitBranch is decided +// by the GitBranch's spec.allowedGitTargets, against the TARGET's namespace. +func TestReconcile_AGitBranchThatDoesNotAdmitTheNamespace(t *testing.T) { f := newDestinationFixture(t) - provider := &configbutleraiv1alpha3.GitProvider{} - require.NoError(t, f.client.Get(context.Background(), k8stypes.NamespacedName{Namespace: "platform", Name: "acme"}, - provider)) - provider.Spec.AccessFrom = nil - require.NoError(t, f.client.Update(context.Background(), provider)) + branch := &configbutleraiv1alpha3.GitBranch{} + require.NoError(t, f.client.Get(context.Background(), k8stypes.NamespacedName{Namespace: "platform", Name: "main"}, + branch)) + branch.Spec.AllowedGitTargets = nil + require.NoError(t, f.client.Update(context.Background(), branch)) _, got := f.reconcile(t) validated := apimeta.FindStatusCondition(got.Status.Conditions, GitTargetConditionValidated) require.NotNil(t, validated) assert.Equal(t, GitTargetReasonNamespaceNotAuthorized, validated.Reason) - assert.Contains(t, validated.Message, "GitBranch platform/main") + assert.Contains(t, validated.Message, "GitBranch platform/main admits no namespace in spec.allowedGitTargets") } -// TestGitBranchWatches_MapToWhatTheyChange: a GitProvider event reaches the GitBranches in its -// namespace that name it; a GitTarget event reaches the GitBranch it references, from any namespace. +// TestGitBranchWatches_MapToWhatTheyChange: a GitProvider event reaches the GitBranches that claim +// through it, from any namespace; a Namespace event reaches the GitBranches in it; a GitTarget event +// reaches the GitBranch it references, from any namespace. func TestGitBranchWatches_MapToWhatTheyChange(t *testing.T) { scheme := runtime.NewScheme() require.NoError(t, configbutleraiv1alpha3.AddToScheme(scheme)) - c := fake.NewClientBuilder().WithScheme(scheme).WithObjects( + require.NoError(t, clientgoscheme.AddToScheme(scheme)) + tenant := branchObject("team-c", "live", "acme", "teams/team-c/live", time.Hour) + tenant.Spec.GitProviderRef.Namespace = "platform" + c := fake.NewClientBuilder().WithScheme(scheme). + WithIndex(&configbutleraiv1alpha3.GitBranch{}, GitBranchProviderIndex, IndexGitBranchProvider).WithObjects( branchObject("platform", "main", "acme", "main", time.Hour), branchObject("platform", "release", "acme", "release", time.Hour), branchObject("platform", "other", "mirror", "main", time.Hour), branchObject("team-b", "main", "acme", "main", time.Hour), + tenant, ).Build() r := &GitBranchReconciler{Client: c} - assert.Equal(t, []string{"platform/main", "platform/release"}, - requestsToKeys(r.gitProviderToGitBranches(context.Background(), branchProvider("platform", "acme")))) + assert.Equal(t, []string{"platform/main", "platform/release", "team-c/live"}, + requestsToKeys(r.gitProviderToGitBranches(context.Background(), branchProvider("platform", "acme"))), + "a GitBranch in another namespace claiming through the provider is reached too") + assert.Equal(t, []string{"team-b/main"}, + requestsToKeys(r.gitProviderToGitBranches(context.Background(), branchProvider("team-b", "acme")))) + assert.Equal(t, []string{"team-c/live"}, requestsToKeys(r.namespaceToGitBranches(context.Background(), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-c"}}))) target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "apps"}, @@ -207,10 +225,13 @@ func TestGitTargetsOn_FindsTheTargetsOfABranchOrItsProvider(t *testing.T) { }, } } - c := fake.NewClientBuilder().WithScheme(scheme).WithObjects( - branchObject("platform", "main", "acme", "main", time.Hour), + tenant := branchObject("team-c", "live", "acme", "teams/team-c/live", time.Hour) + tenant.Spec.GitProviderRef.Namespace = "platform" + c := fake.NewClientBuilder().WithScheme(scheme). + WithIndex(&configbutleraiv1alpha3.GitBranch{}, GitBranchProviderIndex, IndexGitBranchProvider).WithObjects( + branchObject("platform", "main", "acme", "main", time.Hour), tenant, target("platform", "infra", "", "main"), target("team-a", "apps", "platform", "main"), - target("team-a", "own", "", "main"), + target("team-a", "own", "", "main"), target("team-d", "shared", "team-c", "live"), ).Build() byBranch, err := gitTargetsOn(context.Background(), c, branchObject("platform", "main", "acme", "main", 0)) @@ -218,8 +239,63 @@ func TestGitTargetsOn_FindsTheTargetsOfABranchOrItsProvider(t *testing.T) { assert.Len(t, byBranch, 2) byProvider, err := gitTargetsOn(context.Background(), c, branchProvider("platform", "acme")) require.NoError(t, err) - assert.Len(t, byProvider, 2) + assert.Len(t, byProvider, 3, "including the targets of a GitBranch in another namespace that claims through it") none, err := gitTargetsOn(context.Background(), c, branchProvider("platform", "unused")) require.NoError(t, err) assert.Empty(t, none) } + +// TestReconcile_ACrossNamespaceGitBranchWritesWithItsProvidersCredential: a GitBranch in team-a that +// claims through platform/acme is written with platform/acme's credential and repository, also when +// team-a has a GitProvider of the same name for another repository. +func TestReconcile_ACrossNamespaceGitBranchWritesWithItsProvidersCredential(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, clientgoscheme.AddToScheme(scheme)) + require.NoError(t, configbutleraiv1alpha3.AddToScheme(scheme)) + platform := branchProvider("platform", "acme") + platform.Spec.AllowedGitBranches[0] = configbutleraiv1alpha3.GitProviderGitBranchGrant{ + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromNames, + Names: grantNames("team-a"), + }, + BranchPatterns: []configbutleraiv1alpha3.BranchPattern{"teams/{consumer.namespace}/*"}, + } + decoy := branchProvider("team-a", "acme") + decoy.UID = "uid-decoy" + decoy.Spec.URL = "https://github.com/acme/decoy.git" + branch := branchObject("team-a", "live", "acme", "teams/team-a/live", time.Hour) + branch.Spec.GitProviderRef.Namespace = "platform" + target := &configbutleraiv1alpha3.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "apps"}, + Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: claimProviderRef(claimLocalProvider), + GitBranchRef: meta.NamespacedObjectReference{Name: "live"}, + Path: "apps", + }, + } + c := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(platform, decoy, branch, target, admitting(claimLocalProvider, "team-a")). + WithStatusSubresource(&configbutleraiv1alpha3.GitTarget{}).Build() + workers := git.NewWorkerManager(c, logr.Discard(), git.BranchWorkerLimits{}, types.SensitiveResourcePolicy{}) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + go func() { _ = workers.Start(ctx) }() + time.Sleep(50 * time.Millisecond) + workers.Claims().Observe(git.BranchKeyFor(gitBranchTestURL, "teams/team-a/live"), git.BranchClaimant{ + GitBranch: ctrlclient.ObjectKeyFromObject(branch), UID: branch.UID, Created: branch.CreationTimestamp.Time, + }, true) + r := &GitTargetReconciler{ + Client: c, WorkerManager: workers, EventRouter: watch.NewEventRouter(workers, nil, c, logr.Discard()), + } + + _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: ctrlclient.ObjectKeyFromObject(target)}) + require.NoError(t, err) + + worker, wired := workers.WorkerForTarget(target) + require.True(t, wired) + assert.Equal(t, "platform", worker.GitProviderNamespace, "the credential is read from the provider's namespace") + assert.Equal(t, "acme", worker.GitProviderRef) + _, onPlatformRepo := workers.WorkerForGitBranchAt(ctrlclient.ObjectKeyFromObject(branch), + git.BranchKeyFor(gitBranchTestURL, "teams/team-a/live")) + assert.True(t, onPlatformRepo, "the repository is the platform provider's, not the decoy's") +} diff --git a/internal/controller/gittarget_immutability_test.go b/internal/controller/gittarget_immutability_test.go index fd0abaf05..2b4709cb1 100644 --- a/internal/controller/gittarget_immutability_test.go +++ b/internal/controller/gittarget_immutability_test.go @@ -85,7 +85,7 @@ var _ = Describe("GitTarget Destination Immutability", func() { gitBranch := &configbutleraiv1alpha3.GitBranch{ ObjectMeta: metav1.ObjectMeta{Name: tc.key.Name, Namespace: tc.key.Namespace}, Spec: configbutleraiv1alpha3.GitBranchSpec{ - GitProviderRef: meta.LocalObjectReference{Name: "prov-a"}, + GitProviderRef: meta.NamespacedObjectReference{Name: "prov-a"}, Branch: "edits", ParentBranch: tc.parent, }, diff --git a/internal/controller/gittarget_placement_validation_test.go b/internal/controller/gittarget_placement_validation_test.go index 35aae1052..93e31e5a8 100644 --- a/internal/controller/gittarget_placement_validation_test.go +++ b/internal/controller/gittarget_placement_validation_test.go @@ -262,7 +262,7 @@ func TestEvaluateValidatedGate_InvalidPlacementPolicy(t *testing.T) { provider := &configbutleraiv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Name: "provider-a", Namespace: ns}, Spec: configbutleraiv1alpha3.GitProviderSpec{ - AllowedBranches: []string{"main"}, + AllowedGitBranches: branchesFromSame("main"), }, } target := &configbutleraiv1alpha3.GitTarget{ diff --git a/internal/controller/gittarget_shared_branch_test.go b/internal/controller/gittarget_shared_branch_test.go index df50af592..152db0ea2 100644 --- a/internal/controller/gittarget_shared_branch_test.go +++ b/internal/controller/gittarget_shared_branch_test.go @@ -48,7 +48,10 @@ func sharedProviderReconciler(t *testing.T, objects ...ctrlclient.Object) *GitTa require.NoError(t, configbutleraiv1alpha3.AddToScheme(scheme)) // The KubeProvider every GitTarget here mirrors through admits every namespace. local := admitting(claimLocalProvider) - local.Spec.AccessFrom = &configbutleraiv1alpha3.NamespaceMatcher{Selector: &metav1.LabelSelector{}} + local.Spec.AllowedGitTargets = []configbutleraiv1alpha3.KubeProviderGitTargetGrant{{ + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{From: configbutleraiv1alpha3.GrantFromAll}, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.ConsumerNamespaceVariable}, + }} objects = append(objects, local) for _, ns := range []string{"platform", "team-b"} { objects = append(objects, &configbutleraiv1alpha3.GitProvider{ @@ -80,15 +83,16 @@ func TestCheckForConflicts_ASharedGitBranchIsOneBranchAcrossNamespaces(t *testin } // TestCheckForConflicts_AnUnadmittedTargetReservesNoFolder: a GitTarget whose namespace the -// GitBranch's GitProvider does not admit writes nothing, so it cannot hold a folder against the -// targets that may. Without this, any namespace that knows a GitBranch's name could create a target -// at "." first and block every folder on that branch. Once the provider admits it, the older target -// owns its path as any other does. +// GitBranch does not admit writes nothing, so it cannot hold a folder against the targets that may. +// Without this, any namespace that knows a GitBranch's name could create a target at "." first and +// block every folder on that branch. Once the GitBranch admits it, the older target owns its path as +// any other does. func TestCheckForConflicts_AnUnadmittedTargetReservesNoFolder(t *testing.T) { branch := &configbutleraiv1alpha3.GitBranch{ ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: gitBranchNameForTest("monorepo", "main")}, Spec: configbutleraiv1alpha3.GitBranchSpec{ - GitProviderRef: meta.LocalObjectReference{Name: "monorepo"}, Branch: "main", + GitProviderRef: meta.NamespacedObjectReference{Name: "monorepo"}, Branch: "main", + AllowedGitTargets: writersFromSame(), }, } squatter := sharedProviderTarget("intruder", "everything", "platform", ".", 2*time.Hour) @@ -97,15 +101,19 @@ func TestCheckForConflicts_AnUnadmittedTargetReservesNoFolder(t *testing.T) { r := sharedProviderReconciler(t, branch, squatter, owner) conflict, msg, _, err := r.checkForConflicts(context.Background(), owner) require.NoError(t, err) - assert.False(t, conflict, "a target its GitProvider does not admit must not hold a folder: %s", msg) + assert.False(t, conflict, "a target its GitBranch does not admit must not hold a folder: %s", msg) - var provider configbutleraiv1alpha3.GitProvider - require.NoError( - t, - r.Get(context.Background(), ctrlclient.ObjectKey{Namespace: "platform", Name: "monorepo"}, &provider), + var stored configbutleraiv1alpha3.GitBranch + require.NoError(t, r.Get(context.Background(), ctrlclient.ObjectKeyFromObject(branch), &stored)) + stored.Spec.AllowedGitTargets = append( + stored.Spec.AllowedGitTargets, + configbutleraiv1alpha3.GitBranchGitTargetGrant{ + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromNames, Names: grantNames("intruder"), + }, + }, ) - provider.Spec.AccessFrom = &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"intruder"}} - require.NoError(t, r.Update(context.Background(), &provider)) + require.NoError(t, r.Update(context.Background(), &stored)) conflict, msg, reason, err := r.checkForConflicts(context.Background(), owner) require.NoError(t, err) diff --git a/internal/controller/gittarget_source_cluster.go b/internal/controller/gittarget_source_cluster.go index 7ea714bd3..9175cd01d 100644 --- a/internal/controller/gittarget_source_cluster.go +++ b/internal/controller/gittarget_source_cluster.go @@ -13,8 +13,9 @@ import ( ) // GitTargetReasonNamespaceNotAuthorized is the Validated=False reason when a GitTarget's namespace -// is not admitted by its referenced KubeProvider's spec.accessFrom. It runs on every -// reconcile, so a policy tightened AFTER a GitTarget was created stops that target's watches too. +// is not admitted by its referenced KubeProvider's spec.allowedGitTargets, or by its GitBranch's. It +// runs on every reconcile, so a policy tightened AFTER a GitTarget was created stops that target's +// watches too. const GitTargetReasonNamespaceNotAuthorized = authz.ReasonNamespaceNotAuthorized // GitTargetReasonKubeProviderNotFound is the Validated=False reason when a GitTarget's diff --git a/internal/controller/gittarget_source_cluster_test.go b/internal/controller/gittarget_source_cluster_test.go index ccd7028c8..37fa95b43 100644 --- a/internal/controller/gittarget_source_cluster_test.go +++ b/internal/controller/gittarget_source_cluster_test.go @@ -118,11 +118,14 @@ func scScheme(t *testing.T) *runtime.Scheme { } func TestCheckSourceAuthorization(t *testing.T) { - // The provider lives in "platform", so team-a reaches it only through spec.accessFrom. - provider := func(policy *configbutleraiv1alpha3.NamespaceMatcher) *configbutleraiv1alpha3.KubeProvider { + // The provider lives in "platform", so team-a reaches it only through spec.allowedGitTargets. + provider := func(grant configbutleraiv1alpha3.KubeProviderGitTargetGrant) *configbutleraiv1alpha3.KubeProvider { p := remoteKubeProvider("kc", "") p.Namespace = "platform" - p.Spec.AccessFrom = policy + grant.SourceNamespaces = []configbutleraiv1alpha3.ScopeNamespace{ + configbutleraiv1alpha3.ConsumerNamespaceVariable, + } + p.Spec.AllowedGitTargets = []configbutleraiv1alpha3.KubeProviderGitTargetGrant{grant} return p } ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-a", Labels: map[string]string{"tier": "trusted"}}} @@ -159,22 +162,41 @@ func TestCheckSourceAuthorization(t *testing.T) { { name: "provider allows the namespace by name", objects: []client.Object{ - provider(&configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-a"}}), ns, + provider( + configbutleraiv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromNames, + Names: grantNames("team-a"), + }, + }, + ), + ns, }, providerRef: "prod-eu-1", wantAuthorized: true, }, { name: "provider does not allow the namespace -> refused", objects: []client.Object{ - provider(&configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-b"}}), ns, + provider( + configbutleraiv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromNames, + Names: grantNames("team-b"), + }, + }, + ), + ns, }, providerRef: "prod-eu-1", wantAuthorized: false, wantReason: GitTargetReasonNamespaceNotAuthorized, }, { name: "provider allows the namespace by label selector", objects: []client.Object{ - provider(&configbutleraiv1alpha3.NamespaceMatcher{ - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "trusted"}}, + provider(configbutleraiv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "trusted"}}, + }, }), ns, }, providerRef: "prod-eu-1", wantAuthorized: true, @@ -182,12 +204,15 @@ func TestCheckSourceAuthorization(t *testing.T) { { // A selector the API accepted but that cannot compile must FAIL CLOSED. Treating an // unevaluatable policy as "allow" would hand a namespace access it was never granted. - name: "invalid accessFrom selector -> refused, not allowed", + name: "invalid selector -> refused, not allowed", objects: []client.Object{ - provider(&configbutleraiv1alpha3.NamespaceMatcher{ - Selector: &metav1.LabelSelector{ - MatchExpressions: []metav1.LabelSelectorRequirement{ - {Key: "tier", Operator: "BogusOperator", Values: []string{"trusted"}}, + provider(configbutleraiv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + {Key: "tier", Operator: "BogusOperator", Values: []string{"trusted"}}, + }, }, }, }), ns, @@ -199,15 +224,25 @@ func TestCheckSourceAuthorization(t *testing.T) { // no labels. A name-based allow still works; a selector-only policy then denies. name: "namespace object absent -> evaluated with no labels, name allow still holds", objects: []client.Object{ - provider(&configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-a"}}), + provider( + configbutleraiv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromNames, + Names: grantNames("team-a"), + }, + }, + ), }, providerRef: "prod-eu-1", wantAuthorized: true, }, { name: "namespace object absent -> selector-only policy denies (no labels to match)", objects: []client.Object{ - provider(&configbutleraiv1alpha3.NamespaceMatcher{ - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "trusted"}}, + provider(configbutleraiv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "trusted"}}, + }, }), }, providerRef: "prod-eu-1", wantAuthorized: false, wantReason: GitTargetReasonNamespaceNotAuthorized, @@ -229,11 +264,15 @@ func TestCheckSourceAuthorization(t *testing.T) { } // TestCheckSourceAuthorization_OwnNamespaceAndAuditRoute covers the two admission rules a -// namespaced KubeProvider adds: its OWN namespace is always admitted, with no accessFrom and with -// the reference's namespace omitted; and a provider whose audit route another namespace holds is -// denied to every consumer, its own namespace included. +// namespaced KubeProvider adds: its OWN namespace is admitted only by a grant, such as from: Same, +// with the reference's namespace omitted; and a provider whose audit route another namespace holds +// is denied to every consumer, its own namespace included. func TestCheckSourceAuthorization_OwnNamespaceAndAuditRoute(t *testing.T) { own := inClusterKubeProvider("local") + own.Spec.AllowedGitTargets = []configbutleraiv1alpha3.KubeProviderGitTargetGrant{{ + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{From: configbutleraiv1alpha3.GrantFromSame}, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.ConsumerNamespaceVariable}, + }} target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "gt", Namespace: kpNamespace}, Spec: configbutleraiv1alpha3.GitTargetSpec{ @@ -241,13 +280,23 @@ func TestCheckSourceAuthorization_OwnNamespaceAndAuditRoute(t *testing.T) { }, } - t.Run("own namespace is admitted without accessFrom", func(t *testing.T) { + t.Run("own namespace is admitted by from: Same", func(t *testing.T) { cl := fake.NewClientBuilder().WithScheme(scScheme(t)).WithObjects(own).Build() ok, _, _, err := (&GitTargetReconciler{Client: cl}).checkSourceAuthorization(context.Background(), target) require.NoError(t, err) assert.True(t, ok) }) + t.Run("own namespace is refused with no grant", func(t *testing.T) { + cl := fake.NewClientBuilder().WithScheme(scScheme(t)).WithObjects(inClusterKubeProvider("local")).Build() + ok, reason, msg, err := (&GitTargetReconciler{Client: cl}).checkSourceAuthorization( + context.Background(), target) + require.NoError(t, err) + assert.False(t, ok) + assert.Equal(t, GitTargetReasonNamespaceNotAuthorized, reason) + assert.Contains(t, msg, "admits no namespace in spec.allowedGitTargets") + }) + t.Run("a route held by another namespace is refused", func(t *testing.T) { // "team-b.edge" names team-b, which holds it while a provider there carries it, however // much older the team-a provider spelling it out is. @@ -282,7 +331,7 @@ func TestCheckSourceAuthorization_ReadErrorsRequeue(t *testing.T) { } provider := remoteKubeProvider("kc", "") provider.Namespace = "platform" - provider.Spec.AccessFrom = &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-a"}} + provider.Spec.AllowedGitTargets = mirrorGrants("team-a") tests := []struct { name string @@ -373,10 +422,12 @@ func TestReconcile_UnauthorizedNamespaceStartsNoWatch(t *testing.T) { // The provider lives in platform and admits team-b only; the GitTarget lives in team-a. provider := remoteKubeProvider("kc", "") provider.Namespace = providerKey.Namespace - provider.Spec.AccessFrom = &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-b"}} + provider.Spec.AllowedGitTargets = mirrorGrants("team-b") gitProvider := &configbutleraiv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Name: "gp", Namespace: ns}, - Spec: configbutleraiv1alpha3.GitProviderSpec{AllowedBranches: []string{"main"}}, + Spec: configbutleraiv1alpha3.GitProviderSpec{ + AllowedGitBranches: branchesFromSame("main"), + }, } target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "gt", Namespace: ns, UID: "gt-uid"}, @@ -411,7 +462,7 @@ func TestReconcile_UnauthorizedNamespaceStartsNoWatch(t *testing.T) { require.NotNil(t, validated, "Validated condition must be set") assert.Equal(t, metav1.ConditionFalse, validated.Status) assert.Equal(t, GitTargetReasonNamespaceNotAuthorized, validated.Reason) - assert.Contains(t, validated.Message, "not permitted to reference") + assert.Contains(t, validated.Message, "admits team-b in spec.allowedGitTargets") // The data plane is reported blocked, not merely un-ready... streams := apimeta.FindStatusCondition(got.Status.Conditions, GitTargetConditionStreamsRunning) @@ -629,7 +680,7 @@ func TestObserveSourceReachable_Defaults(t *testing.T) { } // TestKubeProviderToGitTargets: a provider event requeues the GitTargets that mirror through it, -// in any namespace its accessFrom may admit, matched on the provider's namespace AND name. +// in any namespace its grants may admit, matched on the provider's namespace AND name. func TestKubeProviderToGitTargets(t *testing.T) { target := func(namespace, name string, ref meta.NamespacedObjectReference) *configbutleraiv1alpha3.GitTarget { return &configbutleraiv1alpha3.GitTarget{ diff --git a/internal/controller/grant_helpers_test.go b/internal/controller/grant_helpers_test.go new file mode 100644 index 000000000..ca3ad186b --- /dev/null +++ b/internal/controller/grant_helpers_test.go @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" +) + +// grantNames converts namespace names into a grant's names. +func grantNames(namespaces ...string) []configbutleraiv1alpha3.GrantNamespace { + names := make([]configbutleraiv1alpha3.GrantNamespace, 0, len(namespaces)) + for _, ns := range namespaces { + names = append(names, configbutleraiv1alpha3.GrantNamespace(ns)) + } + return names +} + +// mirrorGrants admits the named namespaces to mirror their own namespace and every cluster-scoped +// resource, the reach a provider admitting them had before grants were scoped. +func mirrorGrants(namespaces ...string) []configbutleraiv1alpha3.KubeProviderGitTargetGrant { + return []configbutleraiv1alpha3.KubeProviderGitTargetGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromNames, + Names: grantNames(namespaces...), + }, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.ConsumerNamespaceVariable}, + ClusterResources: []configbutleraiv1alpha3.ClusterResourceIdentity{"*"}, + }, + } +} + +// applierGrants admits the named namespaces to hold appliers in scope. +func applierGrants( + scope []configbutleraiv1alpha3.ScopeNamespace, namespaces ...string, +) []configbutleraiv1alpha3.KubeProviderApplierClaimGrant { + return []configbutleraiv1alpha3.KubeProviderApplierClaimGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromNames, + Names: grantNames(namespaces...), + }, + ApplierNamespaces: scope, + }, + } +} + +// branchesFromSame is the allowedGitBranches of a GitProvider that lets GitBranches in its own namespace +// claim the branches patterns match. +func branchesFromSame( + patterns ...configbutleraiv1alpha3.BranchPattern, +) []configbutleraiv1alpha3.GitProviderGitBranchGrant { + return []configbutleraiv1alpha3.GitProviderGitBranchGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{From: configbutleraiv1alpha3.GrantFromSame}, + BranchPatterns: patterns, + }, + } +} + +// writersFromSame is the allowedGitTargets of a GitBranch shared with its own namespace only. +func writersFromSame() []configbutleraiv1alpha3.GitBranchGitTargetGrant { + return []configbutleraiv1alpha3.GitBranchGitTargetGrant{ + {ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{From: configbutleraiv1alpha3.GrantFromSame}}, + } +} diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index f125506dc..5e740d588 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -115,6 +115,8 @@ var _ = BeforeSuite(func() { }) Expect(err).NotTo(HaveOccurred()) + Expect(IndexGitBranches(ctx, mgr.GetFieldIndexer())).To(Succeed()) + testRuleStore = rulestore.NewStore() // Initialize WorkerManager for new architecture @@ -197,12 +199,31 @@ var _ = BeforeSuite(func() { // Ship the suite's shared in-cluster KubeProvider, so a GitTarget or applier claim naming it // (testKubeProviderRef) passes the reconcile-time hard gate that REQUIRES the referenced - // KubeProvider to exist. Its empty selector admits every namespace the specs create. + // KubeProvider to exist. Its grants admit every namespace the specs create, to mirror its own + // namespace and every cluster-scoped resource, and to hold appliers anywhere. Expect(k8sClient.Create(ctx, &configbutleraiv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: testKubeProviderNamespace, Name: testKubeProviderName}, Spec: configbutleraiv1alpha3.KubeProviderSpec{ - InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, - AccessFrom: &configbutleraiv1alpha3.NamespaceMatcher{Selector: &metav1.LabelSelector{}}, + InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, + AllowedGitTargets: []configbutleraiv1alpha3.KubeProviderGitTargetGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromAll, + }, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{ + configbutleraiv1alpha3.ConsumerNamespaceVariable, + }, + ClusterResources: []configbutleraiv1alpha3.ClusterResourceIdentity{"*"}, + }, + }, + AllowedApplierClaims: []configbutleraiv1alpha3.KubeProviderApplierClaimGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromAll, + }, + ApplierNamespaces: []configbutleraiv1alpha3.ScopeNamespace{"*"}, + }, + }, }, })).To(Succeed()) diff --git a/internal/controller/superseded_fields_admission_test.go b/internal/controller/superseded_fields_admission_test.go index 94ff19e33..d620cfd23 100644 --- a/internal/controller/superseded_fields_admission_test.go +++ b/internal/controller/superseded_fields_admission_test.go @@ -69,11 +69,9 @@ var _ = Describe("Superseded source-scope fields", func() { provider := &configbutleraiv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: "renamed-source-scope", Namespace: "default"}, Spec: configbutleraiv1alpha3.KubeProviderSpec{ - SecretRef: &meta.SecretKeyReference{Name: "kc"}, - AccessFrom: &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-a"}}, - AllowCrossNamespace: &configbutleraiv1alpha3.KubeProviderCrossNamespace{ - Sources: true, Appliers: true, - }, + SecretRef: &meta.SecretKeyReference{Name: "kc"}, + AllowedGitTargets: mirrorGrants("team-a"), + AllowedApplierClaims: applierGrants([]configbutleraiv1alpha3.ScopeNamespace{"*"}, "team-a"), }, } Expect(k8sClient.Create(ctx, provider)).To(Succeed()) @@ -81,9 +79,9 @@ var _ = Describe("Superseded source-scope fields", func() { var storedProvider configbutleraiv1alpha3.KubeProvider Expect(k8sClient.Get(ctx, provider.Key(), &storedProvider)).To(Succeed()) - Expect(storedProvider.Spec.AccessFrom).NotTo(BeNil(), "accessFrom must round-trip, not be pruned") - Expect(storedProvider.AllowsCrossNamespaceSources()).To(BeTrue(), "allowCrossNamespace must round-trip") - Expect(storedProvider.AllowsCrossNamespaceAppliers()).To(BeTrue()) + Expect(storedProvider.Spec.AllowedGitTargets).To(Equal(provider.Spec.AllowedGitTargets), + "allowedGitTargets must round-trip, not be pruned") + Expect(storedProvider.Spec.AllowedApplierClaims).To(Equal(provider.Spec.AllowedApplierClaims)) target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "renamed-commit", Namespace: "default"}, @@ -185,9 +183,9 @@ var _ = Describe("Reference names must not be empty", func() { provider := &configbutleraiv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Name: "empty-secret-name", Namespace: "default"}, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://example.com/repo.git", - AllowedBranches: []string{"main"}, - SecretRef: &meta.LocalObjectReference{Name: ""}, + URL: "https://example.com/repo.git", + AllowedGitBranches: branchesFromSame("main"), + SecretRef: &meta.LocalObjectReference{Name: ""}, }, } @@ -215,7 +213,7 @@ var _ = Describe("Reference names must not be empty", func() { branch := &configbutleraiv1alpha3.GitBranch{ ObjectMeta: metav1.ObjectMeta{Name: "empty-provider-name", Namespace: "default"}, Spec: configbutleraiv1alpha3.GitBranchSpec{ - GitProviderRef: meta.LocalObjectReference{Name: ""}, Branch: "main", + GitProviderRef: meta.NamespacedObjectReference{Name: ""}, Branch: "main", }, } Expect(k8sClient.Create(ctx, branch)).NotTo(Succeed(), diff --git a/internal/controller/watchrule_controller.go b/internal/controller/watchrule_controller.go index 7f0e50bef..61252c73d 100644 --- a/internal/controller/watchrule_controller.go +++ b/internal/controller/watchrule_controller.go @@ -271,27 +271,8 @@ func (r *WatchRuleReconciler) SetupWithManager(mgr ctrl.Manager) error { // already suppresses no-op writes, so the loop has no fuel; this makes it structural, and // matches what GitProvider and KubeProvider already do. For(&configbutleraiv1alpha3.WatchRule{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). - // This rule MIRRORS the GitTarget's readiness into its own GitTargetReady, so the watch has - // to see the status-only update that carries a heal — GenerationChangedPredicate would drop - // exactly that. gitTargetReadyProjectionChanged fires on a spec change or on a move in the - // mirrored verdict, and on nothing else. - Watches( - &configbutleraiv1alpha3.GitTarget{}, - handler.EnqueueRequestsFromMapFunc(r.gitTargetToWatchRules), - builder.WithPredicates(gitTargetReadyProjectionChanged()), - ). - // React to a KubeProvider's allowCrossNamespace.sources (or accessFrom) - // changing. The GitTarget->WatchRules edge above still cannot be relied on to carry this: a - // KubeProvider change reaches the GitTarget as a status update that need not move the - // GitTarget's readiness at all, and only a readiness move re-enqueues the rules. Without - // this mapper, flipping the delegation flag would leave every affected WatchRule - // un-reconciled until its periodic requeue — so a REVOCATION would take minutes. - Watches( - &configbutleraiv1alpha3.KubeProvider{}, - handler.EnqueueRequestsFromMapFunc(r.kubeProviderToWatchRules), - builder.WithPredicates(kubeProviderReadyOrSpecChanged()), - ). Named("watchrule") + b = ruleDependencyWatches(b, r.gitTargetToWatchRules, r.kubeProviderToWatchRules, r.namespaceToWatchRules) if r.WatchManager != nil { // React to a stream of this rule's GitTarget reaching or leaving Streaming. Without it a @@ -308,8 +289,19 @@ func (r *WatchRuleReconciler) SetupWithManager(mgr ctrl.Manager) error { return b.Complete(r) } +// namespaceToWatchRules maps a Namespace label change to the WatchRules in it. A WatchRule feeds a +// GitTarget in its own namespace, which is the namespace the grants are evaluated for. +func (r *WatchRuleReconciler) namespaceToWatchRules(ctx context.Context, obj client.Object) []ctrlreconcile.Request { + var rules configbutleraiv1alpha3.WatchRuleList + if err := r.List(ctx, &rules, client.InNamespace(obj.GetName())); err != nil { + logDependencyListError(ctx, err, "WatchRules", obj) + return nil + } + return requestsFor(rules.Items) +} + // kubeProviderToWatchRules maps a KubeProvider change to every WatchRule whose GitTarget -// mirrors through that provider, so a delegation grant or revocation converges on the event rather +// mirrors through that provider, so a grant or revocation converges on the event rather // than on the periodic reconcile. func (r *WatchRuleReconciler) kubeProviderToWatchRules( ctx context.Context, diff --git a/internal/controller/watchrule_controller_test.go b/internal/controller/watchrule_controller_test.go index 0f0a21690..f6d4eda77 100644 --- a/internal/controller/watchrule_controller_test.go +++ b/internal/controller/watchrule_controller_test.go @@ -63,8 +63,8 @@ var _ = Describe("WatchRule Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test/repo.git", - AllowedBranches: []string{"*"}, + URL: "https://github.com/test/repo.git", + AllowedGitBranches: branchesFromSame("*"), SecretRef: &meta.LocalObjectReference{ Name: "git-credentials", }, @@ -179,8 +179,8 @@ var _ = Describe("WatchRule Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/octocat/Hello-World", - AllowedBranches: []string{"main"}, + URL: "https://github.com/octocat/Hello-World", + AllowedGitBranches: branchesFromSame("main"), }, } Expect(k8sClient.Create(ctx, gitProvider)).Should(Succeed()) @@ -280,7 +280,7 @@ var _ = Describe("WatchRule Controller", func() { By("Verifying the legacy own-namespace rule is authorized without any policy or flag") Expect(sourceAuthorized.Status).To(Equal(metav1.ConditionTrue)) - Expect(sourceAuthorized.Reason).To(Equal(WatchRuleReasonLegacySourceNamespace)) + Expect(sourceAuthorized.Reason).To(Equal(WatchRuleReasonSourceNamespaceAllowed)) for _, c := range updatedRule.Status.Conditions { if c.Type == ConditionTypeReady { diff --git a/internal/controller/watchrule_kstatus_test.go b/internal/controller/watchrule_kstatus_test.go index e7d1009da..ac07b4958 100644 --- a/internal/controller/watchrule_kstatus_test.go +++ b/internal/controller/watchrule_kstatus_test.go @@ -62,7 +62,7 @@ func TestWatchRuleSourceNamespaceKstatusContract(t *testing.T) { }, wantStatus: kstatus.CurrentStatus, }, { - name: "delegation disabled: the refusal is terminal", + name: "namespace outside the grant: the refusal is terminal", conds: []map[string]interface{}{ conditionMap(ConditionTypeSourceNamespaceAuthorized, "False", WatchRuleReasonSourceNamespaceNotAllowed, diff --git a/internal/controller/watchrule_source_namespace.go b/internal/controller/watchrule_source_namespace.go index eac3b2d7b..ab015c298 100644 --- a/internal/controller/watchrule_source_namespace.go +++ b/internal/controller/watchrule_source_namespace.go @@ -16,19 +16,11 @@ import ( ) // SourceNamespaceAuthorized condition reasons, re-exported from internal/authz so the decision and -// the status surface can never drift apart. -// -// Three of the six went with the source-side selector: the condition can no longer be Unknown, -// because the gate reads only control-plane objects this reconcile already has, and it can no -// longer report an empty resolved scope, because "*" is one cluster-wide stream rather than a set -// that could come back empty. +// the status surface can never drift apart. The condition can never be Unknown: the gate reads only +// control-plane objects this reconcile already has. const ( - // WatchRuleReasonLegacySourceNamespace is the True reason when every item watches the rule's - // own namespace. - WatchRuleReasonLegacySourceNamespace = authz.ReasonLegacySourceNamespace - // WatchRuleReasonSourceNamespaceAllowed is the True reason when every item is admitted and at - // least one names a namespace other than the rule's own — an authorized override or the - // cluster-wide wildcard. + // WatchRuleReasonSourceNamespaceAllowed is the True reason when every item watches a namespace the + // KubeProvider's grants give the GitTarget. WatchRuleReasonSourceNamespaceAllowed = authz.ReasonSourceNamespaceAllowed // WatchRuleReasonSourceNamespaceNotAllowed is the TERMINAL False reason for a refusal. WatchRuleReasonSourceNamespaceNotAllowed = authz.ReasonSourceNamespaceNotAllowed @@ -41,7 +33,7 @@ const ( // The gate is cross-object (WatchRule → GitTarget → KubeProvider), so it is not expressible in // CEL and is a reconciler check rather than a webhook, per docs/spec/where-validation-lives.md — // the same shape and ordering as checkSourceAuthorization. Running it on every reconcile is what -// makes a delegation WITHDRAWN after a rule was accepted revoke that rule. +// makes a grant WITHDRAWN after a rule was accepted revoke that rule. // // Every item is resolved, and the aggregate is published as one condition. A DENIED item refuses // the whole rule rather than being trimmed away: mirroring two of the three namespaces a rule asked @@ -94,8 +86,8 @@ func (r *WatchRuleReconciler) gateSourceNamespace( // asserts the terminal condition must also be able to assert the rule is already gone. // // The refusal is terminal (Stalled=True, Reconciling=False) rather than a retry: nothing this -// controller does will change the verdict. Recovery arrives as an EVENT — a KubeProvider flag or -// accessFrom change — through the mappers registered in SetupWithManager. +// controller does will change the verdict. Recovery arrives as an EVENT — a KubeProvider grant change +// or a Namespace label change — through the mappers registered in SetupWithManager. func (r *WatchRuleReconciler) refuseSourceNamespace( ctx context.Context, st *reconcileStatus, diff --git a/internal/controller/watchrule_source_namespace_test.go b/internal/controller/watchrule_source_namespace_test.go index 9d924a137..a1c9da194 100644 --- a/internal/controller/watchrule_source_namespace_test.go +++ b/internal/controller/watchrule_source_namespace_test.go @@ -29,7 +29,7 @@ const ( wrsnTarget = "acme" wrsnRule = "repo-config-rule" wrsnProvider = "workspaces" - // wrsnProviderNS holds the KubeProvider, so wrsnTenantNS reaches it only through accessFrom. + // wrsnProviderNS holds the KubeProvider, so wrsnTenantNS reaches it only through a grant. wrsnProviderNS = "platform" ) @@ -50,15 +50,17 @@ func wrsnGitProvider() *configbutleraiv1alpha3.GitProvider { } } -func wrsnKubeProvider(delegate bool) *configbutleraiv1alpha3.KubeProvider { +// wrsnKubeProvider admits wrsnTenantNS to watch its own namespace, and every namespace when wide. +func wrsnKubeProvider(wide bool) *configbutleraiv1alpha3.KubeProvider { + grants := mirrorGrants(wrsnTenantNS) + if wide { + grants[0].SourceNamespaces = append(grants[0].SourceNamespaces, "*") + } return &configbutleraiv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: wrsnProvider, Namespace: wrsnProviderNS}, Spec: configbutleraiv1alpha3.KubeProviderSpec{ - SecretRef: &meta.SecretKeyReference{Name: "kc"}, - AccessFrom: &configbutleraiv1alpha3.NamespaceMatcher{ - Names: []string{wrsnTenantNS}, - }, - AllowCrossNamespace: &configbutleraiv1alpha3.KubeProviderCrossNamespace{Sources: delegate}, + SecretRef: &meta.SecretKeyReference{Name: "kc"}, + AllowedGitTargets: grants, }, } } @@ -142,23 +144,22 @@ func wrsnCondition(t *testing.T, rule *configbutleraiv1alpha3.WatchRule, conditi } func wrsnBaseObjects( - delegate bool, + wide bool, sourceNamespaces ...string, ) []client.Object { return []client.Object{ wrsnGitTarget(), wrsnGitProvider(), - wrsnKubeProvider(delegate), + wrsnKubeProvider(wide), wrsnWatchRule(sourceNamespaces...), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: wrsnTenantNS}}, } } -// TestReconcile_LegacyWatchRuleNeedsNoPolicyOrFlag is THE test, and it is first on purpose. -// -// A WatchRule that omits sourceNamespace must compile with no GitTarget policy and no delegation -// flag. If this fails, deny-by-default has broken every existing WatchRule on upgrade. -func TestReconcile_LegacyWatchRuleNeedsNoPolicyOrFlag(t *testing.T) { +// TestReconcile_OwnNamespaceRuleNeedsOnlyANamespaceGrant: a WatchRule that omits sourceNamespace +// watches its own namespace, which a grant giving sourceNamespaces '{consumer.namespace}' covers. It needs no +// wider grant. +func TestReconcile_OwnNamespaceRuleNeedsOnlyANamespaceGrant(t *testing.T) { ctx := context.Background() f := newWRSNFixture(t, wrsnBaseObjects(false, "")) @@ -166,11 +167,11 @@ func TestReconcile_LegacyWatchRuleNeedsNoPolicyOrFlag(t *testing.T) { require.NoError(t, err) assert.Equal(t, []string{wrsnRule}, f.compiledNames(), - "an existing own-namespace WatchRule must keep working with no new configuration") + "an own-namespace WatchRule needs only a '{consumer.namespace}' grant") cond := wrsnCondition(t, f.reloadRule(ctx, t), ConditionTypeSourceNamespaceAuthorized) assert.Equal(t, metav1.ConditionTrue, cond.Status) - assert.Equal(t, WatchRuleReasonLegacySourceNamespace, cond.Reason) + assert.Equal(t, WatchRuleReasonSourceNamespaceAllowed, cond.Reason) } // TestReconcile_DeniedSourceNamespaceStartsNoWatch mirrors @@ -178,7 +179,7 @@ func TestReconcile_LegacyWatchRuleNeedsNoPolicyOrFlag(t *testing.T) { // The gate has to stop the data plane, not just describe it. func TestReconcile_DeniedSourceNamespaceStartsNoWatch(t *testing.T) { ctx := context.Background() - // The target names the namespace, but the provider does not delegate. + // The rule asks for another namespace, but the grant gives only '{consumer.namespace}'. f := newWRSNFixture(t, wrsnBaseObjects( false, wrsnSourceNS)) @@ -191,7 +192,7 @@ func TestReconcile_DeniedSourceNamespaceStartsNoWatch(t *testing.T) { cond := wrsnCondition(t, rule, ConditionTypeSourceNamespaceAuthorized) assert.Equal(t, metav1.ConditionFalse, cond.Status) assert.Equal(t, WatchRuleReasonSourceNamespaceNotAllowed, cond.Reason) - assert.Contains(t, cond.Message, "spec.allowCrossNamespace.sources", + assert.Contains(t, cond.Message, "spec.allowedGitTargets[].sourceNamespaces", "the message must name the fix") } @@ -246,7 +247,7 @@ func TestReconcile_AuthorizedOverrideCompilesWithItsSourceNamespace(t *testing.T } // TestReconcile_RevokedSourceNamespaceRemovesTheCompiledRuleAndReplans is the REVOCATION contract. -// A rule accepted and then denied by a withdrawn delegation must have its compiled rule REMOVED and +// A rule accepted and then denied by a withdrawn grant must have its compiled rule REMOVED and // the watch manager replanned — and the removal must already have happened by the time the replan // runs, because status is published only after that. A gate that reports without stopping is not a // gate. @@ -259,11 +260,12 @@ func TestReconcile_RevokedSourceNamespaceRemovesTheCompiledRuleAndReplans(t *tes require.NoError(t, err) require.Equal(t, []string{wrsnRule}, f.compiledNames(), "precondition: the rule is compiled") - // The platform admin withdraws the delegation. + // The platform admin narrows the grant to the tenant's own namespace. var provider configbutleraiv1alpha3.KubeProvider require.NoError(t, f.client.Get(ctx, k8stypes.NamespacedName{Namespace: wrsnProviderNS, Name: wrsnProvider}, &provider)) - provider.Spec.AllowCrossNamespace = nil + provider.Spec.AllowedGitTargets[0].SourceNamespaces = []configbutleraiv1alpha3.ScopeNamespace{ + configbutleraiv1alpha3.ConsumerNamespaceVariable} require.NoError(t, f.client.Update(ctx, &provider)) // Observe the world at the exact moment the data plane is replanned. @@ -281,11 +283,9 @@ func TestReconcile_RevokedSourceNamespaceRemovesTheCompiledRuleAndReplans(t *tes assert.Equal(t, metav1.ConditionFalse, cond.Status) } -// A KubeProvider that delegates puts NO further namespace policy in the way: the source -// credential's own RBAC is the bound, and this gate does not try to predict it. That is the whole -// simplification, so it is asserted rather than left implied — the previous release refused this -// exact rule unless a GitTarget policy also listed the namespace. -func TestReconcile_DelegationIsTheOnlyPolicyLeft(t *testing.T) { +// A grant giving sourceNamespaces "*" puts NO further namespace policy in the way: the source +// credential's own RBAC is the bound, and this gate does not try to predict it. +func TestReconcile_WildcardGrantIsTheOnlyPolicy(t *testing.T) { ctx := context.Background() f := newWRSNFixture(t, wrsnBaseObjects(true, "some-namespace-nobody-declared")) @@ -293,7 +293,7 @@ func TestReconcile_DelegationIsTheOnlyPolicyLeft(t *testing.T) { require.NoError(t, err) assert.Equal(t, []string{wrsnRule}, f.compiledNames(), - "with the delegation granted, no second allow-list stands between the rule and the watch") + "with \"*\" granted, no second allow-list stands between the rule and the watch") cond := wrsnCondition(t, f.reloadRule(ctx, t), ConditionTypeSourceNamespaceAuthorized) assert.Equal(t, metav1.ConditionTrue, cond.Status) assert.Equal(t, WatchRuleReasonSourceNamespaceAllowed, cond.Reason) @@ -370,7 +370,7 @@ func TestReconcile_MixedItemsCompileTheirOwnScopes(t *testing.T) { // rule asked for is worse than a loud failure — and the message must name the offending item. func TestReconcile_DeniedItemRefusesTheWholeRule(t *testing.T) { ctx := context.Background() - // Item 0 is the free legacy case; item 1 asks for a namespace the provider does not delegate. + // Item 0 watches the rule's own namespace, which the grant gives; item 1 asks for one it does not. f := newWRSNFixture(t, wrsnBaseObjects(false, "", "tenant-zen")) _, err := f.reconcile(ctx) @@ -386,10 +386,10 @@ func TestReconcile_DeniedItemRefusesTheWholeRule(t *testing.T) { assert.Contains(t, cond.Message, "tenant-zen", "...and by what it asked for") } -// "*" is refused outright while the provider does not delegate, and the refusal names the flag. It +// "*" is refused outright while no grant gives sourceNamespaces "*", and the refusal names the field. It // is the widest request in the API — every namespace the credential can read — so it must not be // the one that slips past a provider granting nothing. -func TestReconcile_WildcardWithoutDelegationIsRefused(t *testing.T) { +func TestReconcile_WildcardWithoutAWildcardGrantIsRefused(t *testing.T) { ctx := context.Background() f := newWRSNFixture(t, wrsnBaseObjects(false, configbutleraiv1alpha3.SourceNamespaceWildcard)) @@ -401,7 +401,7 @@ func TestReconcile_WildcardWithoutDelegationIsRefused(t *testing.T) { cond := wrsnCondition(t, rule, ConditionTypeSourceNamespaceAuthorized) assert.Equal(t, metav1.ConditionFalse, cond.Status) assert.Equal(t, WatchRuleReasonSourceNamespaceNotAllowed, cond.Reason) - assert.Contains(t, cond.Message, "spec.allowCrossNamespace.sources") + assert.Contains(t, cond.Message, "spec.allowedGitTargets[].sourceNamespaces") assert.Equal(t, metav1.ConditionTrue, wrsnCondition(t, rule, ConditionTypeStalled).Status, "a refusal is terminal: nothing this controller does will change the verdict") } diff --git a/internal/controller/wiring/access_grants_wiring_test.go b/internal/controller/wiring/access_grants_wiring_test.go new file mode 100644 index 000000000..d37bcd073 --- /dev/null +++ b/internal/controller/wiring/access_grants_wiring_test.go @@ -0,0 +1,389 @@ +// SPDX-License-Identifier: Apache-2.0 + +package wiring + +import ( + "context" + "strings" + "testing" + "time" + + meta "github.com/fluxcd/pkg/apis/meta" + "github.com/go-logr/logr" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + + configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/authz" + "github.com/ConfigButler/gitops-reverser/internal/controller" + "github.com/ConfigButler/gitops-reverser/internal/git" + "github.com/ConfigButler/gitops-reverser/internal/rulestore" + itypes "github.com/ConfigButler/gitops-reverser/internal/types" + "github.com/ConfigButler/gitops-reverser/internal/watch" +) + +// Revocation and recovery are driven by watch events: a grant edit or a Namespace label change, and +// nothing on the consumer. Each wait is propagationBudget, shorter than any requeue that could +// rescue a missing watch. + +// conditionsOf reads the conditions of a WatchRule, GitBranch or GitTarget. +func conditionsOf(obj client.Object) []metav1.Condition { + switch typed := obj.(type) { + case *configbutleraiv1alpha3.WatchRule: + return typed.Status.Conditions + case *configbutleraiv1alpha3.ClusterWatchRule: + return typed.Status.Conditions + case *configbutleraiv1alpha3.GitBranch: + return typed.Status.Conditions + case *configbutleraiv1alpha3.GitTarget: + return typed.Status.Conditions + } + return nil +} + +// awaitCondition polls key until its condition conditionType has reason want, or fails. +func awaitCondition( + ctx context.Context, t *testing.T, key types.NamespacedName, fresh func() client.Object, + conditionType, want string, budget time.Duration, +) *metav1.Condition { + t.Helper() + return pollCondition(ctx, t, key, fresh, conditionType, "reason "+want, + func(c *metav1.Condition) bool { return c.Reason == want }, budget) +} + +// pollCondition polls key until its condition conditionType satisfies want, or fails naming what, the +// state waited for. +func pollCondition( + ctx context.Context, t *testing.T, key types.NamespacedName, fresh func() client.Object, + conditionType, what string, want func(*metav1.Condition) bool, budget time.Duration, +) *metav1.Condition { + t.Helper() + deadline := time.Now().Add(budget) + last := "absent" + for time.Now().Before(deadline) { + obj := fresh() + if err := k8sClient.Get(ctx, key, obj); err == nil { + if c := apimeta.FindStatusCondition(conditionsOf(obj), conditionType); c != nil { + if want(c) { + return c + } + last = string(c.Status) + " " + c.Reason + ": " + c.Message + } + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("%s %s never reached %s within %s (last seen %q)", key, conditionType, what, budget, last) + return nil +} + +// createNamespace creates a Namespace with labels. +func createNamespace(ctx context.Context, t *testing.T, name string, labels map[string]string) { + t.Helper() + require.NoError( + t, + k8sClient.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: name, Labels: labels}}), + ) +} + +// setLabels replaces a Namespace's labels. +func setLabels(ctx context.Context, t *testing.T, name string, labels map[string]string) { + t.Helper() + require.NoError(t, retry.RetryOnConflict(retry.DefaultRetry, func() error { + ns := &corev1.Namespace{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: name}, ns); err != nil { + return err + } + ns.Labels = labels + return k8sClient.Update(ctx, ns) + })) +} + +// TestWatchRule_ANarrowedScopeRefusesOnALabelChange: a namespace matched by two grants loses the label +// of the one granting "*". Its GitTarget stays admitted, so its readiness does not move; only the +// WatchRule Namespace watch re-evaluates the rule, and a restored label admits it again. +func TestWatchRule_ANarrowedScopeRefusesOnALabelChange(t *testing.T) { + ctx := context.Background() + startManager(t, func(m manager.Manager) error { + return (&controller.WatchRuleReconciler{ + Client: m.GetClient(), Scheme: m.GetScheme(), RuleStore: rulestore.NewStore(), + }).SetupWithManager(m) + }) + + const namespace = "wiring-scope" + both := map[string]string{"mirror": "yes", "audit": "yes"} + createNamespace(ctx, t, namespace, both) + require.NoError(t, k8sClient.Create(ctx, &configbutleraiv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: "source", Namespace: namespace}, + Spec: configbutleraiv1alpha3.KubeProviderSpec{ + InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, + AllowedGitTargets: []configbutleraiv1alpha3.KubeProviderGitTargetGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirror": "yes"}}, + }, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{"{consumer.namespace}"}, + }, + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"audit": "yes"}}, + }, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{"*"}, + }, + }, + }, + })) + target := &configbutleraiv1alpha3.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: "target", Namespace: namespace}, + Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: meta.NamespacedObjectReference{Name: "source"}, + GitBranchRef: meta.NamespacedObjectReference{Name: "provider-main"}, + Path: "mirror", + }, + } + require.NoError(t, k8sClient.Create(ctx, target)) + rule := &configbutleraiv1alpha3.WatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: "rule", Namespace: namespace}, + Spec: configbutleraiv1alpha3.WatchRuleSpec{ + GitTargetRef: meta.LocalObjectReference{Name: "target"}, + Rules: []configbutleraiv1alpha3.ResourceRule{ + {Resources: []string{"configmaps"}, SourceNamespace: configbutleraiv1alpha3.SourceNamespaceWildcard}, + }, + }, + } + require.NoError(t, k8sClient.Create(ctx, rule)) + key := client.ObjectKeyFromObject(rule) + fresh := func() client.Object { return &configbutleraiv1alpha3.WatchRule{} } + + awaitCondition(ctx, t, key, fresh, controller.ConditionTypeSourceNamespaceAuthorized, + controller.WatchRuleReasonSourceNamespaceAllowed, settleBudget) + + setLabels(ctx, t, namespace, map[string]string{"mirror": "yes"}) + refused := awaitCondition(ctx, t, key, fresh, controller.ConditionTypeSourceNamespaceAuthorized, + controller.WatchRuleReasonSourceNamespaceNotAllowed, propagationBudget) + require.Contains(t, refused.Message, `watch "wiring-scope"`) + decision, err := authz.GitTargetAdmitted(ctx, k8sClient, target) + require.NoError(t, err) + require.True(t, decision.Allowed, "the GitTarget stays admitted for its narrower scope") + + setLabels(ctx, t, namespace, both) + awaitCondition(ctx, t, key, fresh, controller.ConditionTypeSourceNamespaceAuthorized, + controller.WatchRuleReasonSourceNamespaceAllowed, propagationBudget) +} + +// registerBranchAndTargetControllers installs the GitBranch and GitTarget controllers, the GitBranch +// index they find a provider's GitBranches through, and the worker manager that holds the claims. +func registerBranchAndTargetControllers(m manager.Manager) error { + if err := controller.IndexGitBranches(context.Background(), m.GetFieldIndexer()); err != nil { + return err + } + workers := git.NewWorkerManager(m.GetClient(), logr.Discard(), git.BranchWorkerLimits{}, + itypes.SensitiveResourcePolicy{}) + if err := m.Add(workers); err != nil { + return err + } + if err := (&controller.GitBranchReconciler{ + Client: m.GetClient(), Scheme: m.GetScheme(), WorkerManager: workers, + }).SetupWithManager(m); err != nil { + return err + } + return (&controller.GitTargetReconciler{ + Client: m.GetClient(), Scheme: m.GetScheme(), WorkerManager: workers, + EventRouter: watch.NewEventRouter(workers, nil, m.GetClient(), logr.Discard()), + }).SetupWithManager(m) +} + +// TestGitBranch_ALabelRemovedRevokesTheClaimAndItsGitTargetsFollow: a label removed from a claimant's +// namespace revokes its GitBranch through the GitBranch Namespace watch, and a GitTarget in another +// namespace writing through it follows. Restoring the label re-admits the GitBranch, which then +// tries to prove itself again. Then the branch owner's writer grant is withdrawn and restored, and +// the GitTarget is refused and admitted again, each on the GitBranch event alone. +func TestGitBranch_ALabelRemovedRevokesTheClaimAndItsGitTargetsFollow(t *testing.T) { + ctx := context.Background() + startManager(t, registerBranchAndTargetControllers) + + tenant := map[string]string{"tenant": "true"} + createNamespace(ctx, t, "wiring-platform", nil) + createNamespace(ctx, t, "wiring-tenant", tenant) + createNamespace(ctx, t, "wiring-writer", nil) + require.NoError(t, k8sClient.Create(ctx, &configbutleraiv1alpha3.GitProvider{ + ObjectMeta: metav1.ObjectMeta{Name: "shared-repo", Namespace: "wiring-platform"}, + Spec: configbutleraiv1alpha3.GitProviderSpec{ + // Nothing listens here, so the push-session proof fails at once: the reasons tell an + // admitted GitBranch (PushSessionFailed) from a revoked one (NamespaceNotAuthorized). + URL: "https://127.0.0.1:1/platform/config.git", + AllowedGitBranches: []configbutleraiv1alpha3.GitProviderGitBranchGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: tenant}, + }, + BranchPatterns: []configbutleraiv1alpha3.BranchPattern{"teams/{consumer.namespace}/*"}, + }, + }, + }, + })) + branch := &configbutleraiv1alpha3.GitBranch{ + ObjectMeta: metav1.ObjectMeta{Name: "live", Namespace: "wiring-tenant"}, + Spec: configbutleraiv1alpha3.GitBranchSpec{ + GitProviderRef: meta.NamespacedObjectReference{Name: "shared-repo", Namespace: "wiring-platform"}, + Branch: "teams/wiring-tenant/live", + AllowedGitTargets: []configbutleraiv1alpha3.GitBranchGitTargetGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromNames, + Names: []configbutleraiv1alpha3.GrantNamespace{"wiring-writer"}, + }, + }, + }, + }, + } + require.NoError(t, k8sClient.Create(ctx, branch)) + require.NoError(t, k8sClient.Create(ctx, &configbutleraiv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: "source", Namespace: "wiring-writer"}, + Spec: configbutleraiv1alpha3.KubeProviderSpec{ + InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, + AllowedGitTargets: []configbutleraiv1alpha3.KubeProviderGitTargetGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromSame, + }, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{"{consumer.namespace}"}, + }, + }, + }, + })) + target := &configbutleraiv1alpha3.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: "apps", Namespace: "wiring-writer"}, + Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: meta.NamespacedObjectReference{Name: "source"}, + GitBranchRef: meta.NamespacedObjectReference{Name: "live", Namespace: "wiring-tenant"}, + Path: "apps", + }, + } + require.NoError(t, k8sClient.Create(ctx, target)) + branchKey := client.ObjectKeyFromObject(branch) + freshBranch := func() client.Object { return &configbutleraiv1alpha3.GitBranch{} } + awaitCondition(ctx, t, branchKey, freshBranch, controller.ConditionTypeReady, + controller.GitBranchReasonPushSessionFailed, settleBudget) + + setLabels(ctx, t, "wiring-tenant", nil) + awaitCondition(ctx, t, branchKey, freshBranch, controller.ConditionTypeReady, + controller.GitBranchReasonNamespaceNotAuthorized, propagationBudget) + targetKey := client.ObjectKeyFromObject(target) + awaitTarget(ctx, t, targetKey, controller.GitTargetConditionGitBranchReady, + func(c *metav1.Condition) bool { return strings.Contains(c.Message, "spec.allowedGitBranches") }) + + setLabels(ctx, t, "wiring-tenant", tenant) + awaitCondition(ctx, t, branchKey, freshBranch, controller.ConditionTypeReady, + controller.GitBranchReasonPushSessionFailed, propagationBudget) + + // The branch owner removes the writer's namespace from allowedGitTargets, and adds it back: the + // GitBranch edit reaches the GitTarget in the other namespace each time. + refused := func(c *metav1.Condition) bool { return c.Reason == controller.GitTargetReasonNamespaceNotAuthorized } + admitted := func(c *metav1.Condition) bool { return !refused(c) } + writers := branch.Spec.AllowedGitTargets + setWriters( + ctx, + t, + branchKey, + []configbutleraiv1alpha3.GitBranchGitTargetGrant{ + {ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{From: configbutleraiv1alpha3.GrantFromSame}}, + }, + ) + awaitTarget(ctx, t, targetKey, controller.GitTargetConditionValidated, refused) + setWriters(ctx, t, branchKey, writers) + awaitTarget(ctx, t, targetKey, controller.GitTargetConditionValidated, admitted) +} + +// awaitTarget polls a GitTarget until its condition conditionType satisfies want, within +// propagationBudget. +func awaitTarget( + ctx context.Context, t *testing.T, key types.NamespacedName, conditionType string, + want func(*metav1.Condition) bool, +) { + t.Helper() + pollCondition(ctx, t, key, func() client.Object { return &configbutleraiv1alpha3.GitTarget{} }, + conditionType, "what was waited for", want, propagationBudget) +} + +// setWriters replaces a GitBranch's allowedGitTargets. +func setWriters( + ctx context.Context, + t *testing.T, + key types.NamespacedName, + grants []configbutleraiv1alpha3.GitBranchGitTargetGrant, +) { + t.Helper() + require.NoError(t, retry.RetryOnConflict(retry.DefaultRetry, func() error { + stored := &configbutleraiv1alpha3.GitBranch{} + if err := k8sClient.Get(ctx, key, stored); err != nil { + return err + } + stored.Spec.AllowedGitTargets = grants + return k8sClient.Update(ctx, stored) + })) +} + +// TestClusterWatchRule_ClusterResourcesFollowTheGrant: removing a type from the grant's +// clusterResources takes the ClusterWatchRule naming it out of the rule store, and restoring it puts +// the rule back, each on the KubeProvider event alone. +func TestClusterWatchRule_ClusterResourcesFollowTheGrant(t *testing.T) { + ctx := context.Background() + store := rulestore.NewStore() + startManager(t, func(m manager.Manager) error { + return (&controller.ClusterWatchRuleReconciler{ + Client: m.GetClient(), Scheme: m.GetScheme(), RuleStore: store, + }).SetupWithManager(m) + }) + + namespace := seedTarget(ctx, t, "wiring-cluster-resources") + setClusterResources := func(identities ...configbutleraiv1alpha3.ClusterResourceIdentity) { + t.Helper() + require.NoError(t, retry.RetryOnConflict(retry.DefaultRetry, func() error { + provider := &configbutleraiv1alpha3.KubeProvider{} + if err := k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: "source"}, + provider); err != nil { + return err + } + provider.Spec.AllowedGitTargets[0].ClusterResources = identities + return k8sClient.Update(ctx, provider) + })) + } + compiled := func(want bool, budget time.Duration) { + t.Helper() + require.Eventually(t, func() bool { + return (len(store.SnapshotClusterWatchRules()) == 1) == want + }, budget, 50*time.Millisecond, "compiled=%t", want) + } + setClusterResources("clusterroles.rbac.authorization.k8s.io") + rule := &configbutleraiv1alpha3.ClusterWatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: "wiring-cluster-roles"}, + Spec: configbutleraiv1alpha3.ClusterWatchRuleSpec{ + GitTargetRef: meta.NamespacedObjectReference{Name: "target", Namespace: namespace}, + Rules: []configbutleraiv1alpha3.ClusterResourceRule{{ + APIGroups: []string{"rbac.authorization.k8s.io"}, Resources: []string{"clusterroles"}, + }}, + }, + } + require.NoError(t, k8sClient.Create(ctx, rule)) + compiled(true, settleBudget) + + setClusterResources("nodes") + refused := awaitCondition(ctx, t, client.ObjectKeyFromObject(rule), + func() client.Object { return &configbutleraiv1alpha3.ClusterWatchRule{} }, + controller.ConditionTypeResourcesResolved, watch.ClusterWatchRuleReasonClusterResourceNotAllowed, + propagationBudget) + require.Contains(t, refused.Message, `"clusterroles.rbac.authorization.k8s.io"`) + compiled(false, propagationBudget) + + setClusterResources("clusterroles.rbac.authorization.k8s.io") + compiled(true, propagationBudget) +} diff --git a/internal/controller/wiring/readiness_wiring_test.go b/internal/controller/wiring/readiness_wiring_test.go index 0ad17582b..1769fa8c7 100644 --- a/internal/controller/wiring/readiness_wiring_test.go +++ b/internal/controller/wiring/readiness_wiring_test.go @@ -26,7 +26,9 @@ import ( "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "k8s.io/client-go/util/retry" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/config" "sigs.k8s.io/controller-runtime/pkg/envtest" "sigs.k8s.io/controller-runtime/pkg/manager" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" @@ -90,6 +92,8 @@ func startManager(t *testing.T, register func(manager.Manager) error) { Scheme: scheme.Scheme, Metrics: metricsserver.Options{BindAddress: "0"}, LeaderElection: false, + // Each test starts its own manager in this one process, some with the same controllers. + Controller: config.Controller{SkipNameValidation: ptr.To(true)}, }) require.NoError(t, err) require.NoError(t, register(mgr)) @@ -109,9 +113,9 @@ func startManager(t *testing.T, register func(manager.Manager) error) { } // seedTarget creates a namespace, a GitProvider, a KubeProvider and a GitTarget mirroring through -// it, and returns the namespace. The KubeProvider is in the target's own namespace, which it always -// admits: a ClusterWatchRule is refused (and its GitTargetReady overwritten with the refusal) unless -// the source cluster admits the target's namespace. +// it, and returns the namespace. The KubeProvider is in the target's own namespace and grants it +// every cluster-scoped resource: a ClusterWatchRule is refused (and its GitTargetReady overwritten +// with the refusal) unless the source cluster admits the target's namespace. func seedTarget(ctx context.Context, t *testing.T, name string) string { t.Helper() @@ -122,15 +126,33 @@ func seedTarget(ctx context.Context, t *testing.T, name string) string { require.NoError(t, k8sClient.Create(ctx, &configbutleraiv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Name: "provider", Namespace: namespace}, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test/repo.git", - AllowedBranches: []string{"*"}, - SecretRef: &meta.LocalObjectReference{Name: "git-credentials"}, + URL: "https://github.com/test/repo.git", + AllowedGitBranches: []configbutleraiv1alpha3.GitProviderGitBranchGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromSame, + }, + BranchPatterns: []configbutleraiv1alpha3.BranchPattern{"*"}, + }, + }, + SecretRef: &meta.LocalObjectReference{Name: "git-credentials"}, }, })) require.NoError(t, k8sClient.Create(ctx, &configbutleraiv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: "source", Namespace: namespace}, Spec: configbutleraiv1alpha3.KubeProviderSpec{ InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, + AllowedGitTargets: []configbutleraiv1alpha3.KubeProviderGitTargetGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromSame, + }, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{ + configbutleraiv1alpha3.ConsumerNamespaceVariable, + }, + ClusterResources: []configbutleraiv1alpha3.ClusterResourceIdentity{"*"}, + }, + }, }, })) require.NoError(t, k8sClient.Create(ctx, &configbutleraiv1alpha3.GitTarget{ @@ -187,29 +209,8 @@ func awaitMirroredReady( budget time.Duration, ) { t.Helper() - - deadline := time.Now().Add(budget) - last := "absent" - for time.Now().Before(deadline) { - obj := fresh() - if err := k8sClient.Get(ctx, key, obj); err == nil { - var conditions []metav1.Condition - switch typed := obj.(type) { - case *configbutleraiv1alpha3.WatchRule: - conditions = typed.Status.Conditions - case *configbutleraiv1alpha3.ClusterWatchRule: - conditions = typed.Status.Conditions - } - if c := apimeta.FindStatusCondition(conditions, controller.ConditionTypeGitTargetReady); c != nil { - if c.Status == want { - return - } - last = string(c.Status) - } - } - time.Sleep(50 * time.Millisecond) - } - t.Fatalf("GitTargetReady never reached %q within %s (last seen %q)", want, budget, last) + pollCondition(ctx, t, key, fresh, controller.ConditionTypeGitTargetReady, "status "+string(want), + func(c *metav1.Condition) bool { return c.Status == want }, budget) } // TestWatchRule_FollowsGitTargetStatusOnlyRecovery is the regression test for a rule whose mirrored diff --git a/internal/git/branch_owner.go b/internal/git/branch_owner.go index 3462d2029..65af9dd9a 100644 --- a/internal/git/branch_owner.go +++ b/internal/git/branch_owner.go @@ -3,6 +3,7 @@ package git import ( + "sort" "sync" "time" @@ -23,9 +24,15 @@ import ( // claim and does not hand the branch to another GitBranch. Any other candidate whose proof // fails stops being one. // 4. The claim lives here, in the manager's memory. After a restart it is recomputed by 1 and 2. +// 5. A holder giving the branch up hands it to nobody. Every other candidate becomes one again only +// through its own GitBranch reconcile, which checks its grants before its proof, so a candidate +// whose grant was withdrawn since it last reconciled never holds the branch, and never gets a +// worker. The candidates waiting are stalled on BranchConflict, so they reconcile again soon on +// their own; the GitBranch reconciler also wakes them at once (Release returns them). // // The GitBranch reconciler feeds it (Observe, Release); the GitTarget reconciler, the worker sweep -// and EnsureWorker read it, so they cannot disagree about who writes a branch. +// and EnsureWorker read it, so they cannot disagree about who writes a branch. The grants are checked +// in one place only, the GitBranch reconcile, before it calls Observe. // BranchClaimant is a GitBranch as the claim sees it: which object, and how old. type BranchClaimant struct { @@ -73,6 +80,8 @@ func (c *BranchClaims) Observe(key BranchKey, claimant BranchClaimant, proven bo defer c.mu.Unlock() entry, known := c.entries[claimant.UID] if known && entry.key != key { + // The GitBranches waiting for the old key are stalled on BranchConflict, and reconcile again + // on their own. c.releaseLocked(claimant.UID) known = false } @@ -88,24 +97,34 @@ func (c *BranchClaims) Observe(key BranchKey, claimant BranchClaimant, proven bo return c.holderLocked(key) } -// Release forgets a GitBranch: it is being deleted, and its worker has stopped. If it held its key, -// the oldest remaining candidate takes it over. -func (c *BranchClaims) Release(uid k8stypes.UID) { +// Release forgets a GitBranch: it is being deleted, or no grant admits it any more. If it held its +// key, nobody holds the key afterwards (rule 5), and Release returns the GitBranches that wait for it, +// oldest first: they are candidates again once their own reconcile has checked their grants. +func (c *BranchClaims) Release(uid k8stypes.UID) []BranchClaimant { c.mu.Lock() defer c.mu.Unlock() - c.releaseLocked(uid) + return c.releaseLocked(uid) } -func (c *BranchClaims) releaseLocked(uid k8stypes.UID) { +func (c *BranchClaims) releaseLocked(uid k8stypes.UID) []BranchClaimant { entry, known := c.entries[uid] if !known { - return + return nil } delete(c.entries, uid) - if c.holders[entry.key] == uid { - delete(c.holders, entry.key) + if c.holders[entry.key] != uid { + return nil + } + delete(c.holders, entry.key) + var waiting []BranchClaimant + for _, other := range c.entries { + if other.key == entry.key { + other.candidate = false + waiting = append(waiting, other.claimant) + } } - c.electLocked(entry.key) + sort.Slice(waiting, func(i, j int) bool { return waiting[i].olderThan(waiting[j]) }) + return waiting } // electLocked applies rule 2: the oldest candidate for key holds it. A holder is always a candidate, diff --git a/internal/git/branch_owner_test.go b/internal/git/branch_owner_test.go index 0c86bc226..f3c19bfbc 100644 --- a/internal/git/branch_owner_test.go +++ b/internal/git/branch_owner_test.go @@ -85,9 +85,10 @@ func TestBranchClaims_TheHolderKeepsTheBranchThroughAFailedProof(t *testing.T) { assert.Equal(t, younger, got) } -// TestBranchClaims_ReleaseHandsTheBranchToTheNextCandidate: a deleted holder gives the branch to the -// oldest remaining candidate, and HeldBy follows it. -func TestBranchClaims_ReleaseHandsTheBranchToTheNextCandidate(t *testing.T) { +// TestBranchClaims_ReleaseHandsTheBranchToNobody is rule 5: a holder giving the branch up elects no +// successor. It returns the GitBranches waiting, oldest first, and the first of them to confirm again +// holds the branch; an older one confirming later takes it over by rule 2. +func TestBranchClaims_ReleaseHandsTheBranchToNobody(t *testing.T) { claims := NewBranchClaims() key := BranchKeyFor("https://github.com/acme/platform.git", "main") first := claimant("platform", "main", 3*time.Hour) @@ -97,13 +98,21 @@ func TestBranchClaims_ReleaseHandsTheBranchToTheNextCandidate(t *testing.T) { claims.Observe(key, c, true) } - claims.Release(first.UID) - gotKey, got, held := claims.HeldBy(second.GitBranch) - require.True(t, held) - assert.Equal(t, key, gotKey) - assert.Equal(t, second, got) + assert.Empty(t, claims.Release(third.UID), "a candidate that does not hold the branch hands nothing on") + claims.Observe(key, third, true) + + waiting := claims.Release(first.UID) + assert.Equal(t, []BranchClaimant{second, third}, waiting) + _, held := claims.Holder(key) + assert.False(t, held, "nobody holds the branch until a waiting GitBranch confirms itself") _, _, held = claims.HeldBy(first.GitBranch) assert.False(t, held, "a released GitBranch holds nothing") + + got, held := claims.Observe(key, third, true) + require.True(t, held) + assert.Equal(t, third, got) + got, _ = claims.Observe(key, second, true) + assert.Equal(t, second, got, "the older candidate takes the branch over once it confirms") } // TestBranchClaims_AGitBranchThatNamesAnotherRepositoryGivesTheOldOneUp: its GitProvider was diff --git a/internal/git/claim_helpers_test.go b/internal/git/claim_helpers_test.go index 08f73dbc4..bac3f0deb 100644 --- a/internal/git/claim_helpers_test.go +++ b/internal/git/claim_helpers_test.go @@ -9,7 +9,7 @@ import ( ) // testGitBranch is the GitBranch a test worker writes for: one per provider and branch, in the -// provider's namespace, as a GitBranch's gitProviderRef is always local. +// provider's namespace. func testGitBranch(provider, namespace, branch string) k8stypes.NamespacedName { return k8stypes.NamespacedName{Namespace: namespace, Name: testGitBranchName(provider, branch)} } @@ -26,7 +26,7 @@ func claimForTest(m *WorkerManager, provider, namespace, branch string, repo Rep }, true) return WorkerRequest{ GitBranch: gitBranch, - Provider: provider, + Provider: k8stypes.NamespacedName{Namespace: namespace, Name: provider}, Branch: branch, Repo: repo, } diff --git a/internal/git/git_branch_ref_test.go b/internal/git/git_branch_ref_test.go index 8888d4cb1..d299634fb 100644 --- a/internal/git/git_branch_ref_test.go +++ b/internal/git/git_branch_ref_test.go @@ -33,12 +33,15 @@ func TestResolveGitBranch_FollowsTheTargetToItsBranchAndProvider(t *testing.T) { branch := &configv1alpha3.GitBranch{ ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "main"}, Spec: configv1alpha3.GitBranchSpec{ - GitProviderRef: meta.LocalObjectReference{Name: "acme"}, Branch: "main", + GitProviderRef: meta.NamespacedObjectReference{Name: "acme"}, Branch: "main", }, } orphan := &configv1alpha3.GitBranch{ ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "orphan"}, - Spec: configv1alpha3.GitBranchSpec{GitProviderRef: meta.LocalObjectReference{Name: "gone"}, Branch: "x"}, + Spec: configv1alpha3.GitBranchSpec{ + GitProviderRef: meta.NamespacedObjectReference{Name: "gone"}, + Branch: "x", + }, } provider := &configv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "acme"}, diff --git a/internal/git/worker_branch_owner_test.go b/internal/git/worker_branch_owner_test.go index 5a2393db7..f8c7f413d 100644 --- a/internal/git/worker_branch_owner_test.go +++ b/internal/git/worker_branch_owner_test.go @@ -28,7 +28,8 @@ func TestEnsureWorker_OnlyTheHolderGetsAWorker(t *testing.T) { require.NoError(t, m.EnsureWorker(ctx, claimForTest(m, "monorepo", "team-a", "main", teamA))) refused := WorkerRequest{ - GitBranch: testGitBranch("monorepo", "team-b", "main"), Provider: "monorepo", Branch: "main", Repo: teamB, + GitBranch: testGitBranch("monorepo", "team-b", "main"), + Provider: k8stypes.NamespacedName{Namespace: "team-b", Name: "monorepo"}, Branch: "main", Repo: teamB, } require.ErrorIs(t, m.EnsureWorker(ctx, refused), ErrBranchOwnedElsewhere) _, found := m.WorkerForGitBranch(refused.GitBranch) @@ -65,7 +66,15 @@ func TestReconcileWorkers_StopsTheWorkerOfAGitBranchThatLostItsBranch(t *testing Observe(key, BranchClaimant{GitBranch: older, UID: "older", Created: time.Unix(0, 0).Add(-time.Hour)}, true) require.ErrorIs( t, - m.EnsureWorker(ctx, WorkerRequest{GitBranch: older, Provider: "monorepo", Branch: "main", Repo: repo}), + m.EnsureWorker( + ctx, + WorkerRequest{ + GitBranch: older, + Provider: k8stypes.NamespacedName{Namespace: older.Namespace, Name: "monorepo"}, + Branch: "main", + Repo: repo, + }, + ), ErrBranchOwnedElsewhere, "the previous holder's worker is still live", ) @@ -76,7 +85,15 @@ func TestReconcileWorkers_StopsTheWorkerOfAGitBranchThatLostItsBranch(t *testing require.NoError( t, - m.EnsureWorker(ctx, WorkerRequest{GitBranch: older, Provider: "monorepo", Branch: "main", Repo: repo}), + m.EnsureWorker( + ctx, + WorkerRequest{ + GitBranch: older, + Provider: k8stypes.NamespacedName{Namespace: older.Namespace, Name: "monorepo"}, + Branch: "main", + Repo: repo, + }, + ), ) require.NoError(t, m.ReconcileWorkers(context.Background())) _, found = m.WorkerForGitBranch(older) @@ -102,3 +119,24 @@ func TestRetireGitBranch_StopsTheWorkerAndForgetsTheBranch(t *testing.T) { assert.False(t, known, "the next holder starts from the remote") m.RetireGitBranch(request.GitBranch) // a second call finds nothing to do } + +// TestEnsureWorker_ACrossNamespaceGitBranchWritesWithItsProvider: a GitBranch in team-a claiming +// through platform/shared-repo gets a worker that pushes with that provider's credential, never a +// same-name provider in team-a, and a push delivery for the provider wakes it. +func TestEnsureWorker_ACrossNamespaceGitBranchWritesWithItsProvider(t *testing.T) { + m, ctx := startedManager(t) + repo := RepoIdentity{URL: ownerTestURL} + request := claimForTest(m, "shared-repo", "team-a", "teams/team-a/live", repo) + request.Provider = k8stypes.NamespacedName{Namespace: "platform", Name: "shared-repo"} + require.NoError(t, m.EnsureWorker(ctx, request)) + + worker, found := m.WorkerForGitBranch(request.GitBranch) + require.True(t, found) + assert.Equal(t, "platform", worker.GitProviderNamespace, "the credential is read where the provider lives") + assert.Equal(t, "shared-repo", worker.GitProviderRef) + + assert.False(t, m.NotifyPush("team-a", "shared-repo", "teams/team-a/live", ""), + "a same-name provider in the GitBranch's namespace is not the one it writes with") + assert.True(t, m.NotifyPush("platform", "shared-repo", "teams/team-a/live", ""), + "receiver routing reaches the worker of a cross-namespace GitBranch") +} diff --git a/internal/git/worker_manager.go b/internal/git/worker_manager.go index 14b2ff5be..e6af99090 100644 --- a/internal/git/worker_manager.go +++ b/internal/git/worker_manager.go @@ -364,12 +364,13 @@ func (m *WorkerManager) RemoteForBranch(key BranchKey) (RemoteObservation, bool) } // WorkerRequest names the branch worker a GitTarget needs: the GitBranch holding the branch, the -// GitProvider (in the GitBranch's namespace) it writes with, and the repository that provider names. +// GitProvider it writes with, and the repository that provider names. type WorkerRequest struct { // GitBranch is the claim holder the worker writes for. GitBranch k8stypes.NamespacedName - // Provider is the GitBranch's GitProvider, by name, in the GitBranch's namespace. - Provider string + // Provider is the GitBranch's GitProvider, its namespace resolved: a GitBranch may name one in + // another namespace, whose credential the worker then pushes with. + Provider k8stypes.NamespacedName // Branch is the GitBranch's spec.branch. Branch string // Repo is the repository the GitProvider names now. @@ -378,10 +379,6 @@ type WorkerRequest struct { ParentBranch string } -func (r WorkerRequest) provider() k8stypes.NamespacedName { - return k8stypes.NamespacedName{Namespace: r.GitBranch.Namespace, Name: r.Provider} -} - // EnsureWorker ensures a worker exists for the repository branch req names, keyed by that branch // (BranchKey), and that it is the worker for the repository the GitProvider names NOW. Only the // GitBranch holding the branch's claim (branch_owner.go) gets one: any other is refused @@ -415,7 +412,7 @@ func (m *WorkerManager) EnsureWorker(_ context.Context, req WorkerRequest) error repo := req.Repo key := BranchKeyFor(repo.URL, req.Branch) if key == (BranchKey{}) { - return fmt.Errorf("GitProvider %s names no repository", req.provider()) + return fmt.Errorf("GitProvider %s names no repository", req.Provider) } if err := m.refuseUnclaimed(key, req.GitBranch); err != nil { return err @@ -483,7 +480,7 @@ func (m *WorkerManager) refuseUnclaimed(key BranchKey, gitBranch k8stypes.Namesp // startWorkerLocked creates and starts the worker for key, writing through provider, and records // it. Called with lifecycleMu and mu held. func (m *WorkerManager) startWorkerLocked(key BranchKey, req WorkerRequest) error { - provider, repo, parentBranch := req.provider(), req.Repo, req.ParentBranch + provider, repo, parentBranch := req.Provider, req.Repo, req.ParentBranch providerName, providerNamespace, branch := provider.Name, provider.Namespace, key.Branch m.Log.Info("Creating new branch worker", "key", key.String(), "repository", repo.String()) worker := NewBranchWorker( diff --git a/internal/rulestore/object_selector_test.go b/internal/rulestore/object_selector_test.go index a5d0804a4..5a9935c17 100644 --- a/internal/rulestore/object_selector_test.go +++ b/internal/rulestore/object_selector_test.go @@ -68,7 +68,7 @@ func TestAddOrUpdateClusterWatchRule_CompilesTheCanonicalObjectSelector(t *testi }}, }, } - store.AddOrUpdateClusterWatchRule(rule, "target", "ns", "p", "ns", "path") + store.AddOrUpdateClusterWatchRule(rule, []string{"*"}, "target", "ns", "p", "ns", "path") rules := store.SnapshotClusterWatchRules() if len(rules) != 1 || len(rules[0].Rules) != 1 { diff --git a/internal/rulestore/store.go b/internal/rulestore/store.go index a72ef8d2d..9941b918b 100644 --- a/internal/rulestore/store.go +++ b/internal/rulestore/store.go @@ -7,6 +7,7 @@ It provides efficient lookup and matching of Kubernetes resources against active package rulestore import ( + "slices" "strings" "sync" @@ -141,10 +142,21 @@ type CompiledClusterRule struct { // CreatedAt is the ClusterWatchRule's creation timestamp; see CompiledRule.CreatedAt. CreatedAt metav1.Time + // ClusterResources are the cluster-scoped resource identities the KubeProvider's grants gave the + // GitTarget when the rule compiled ("*" for every one). The planner watches no other type, so + // discovery serving a new type later never widens what the rule mirrors. + ClusterResources []string + // Rules contains the compiled cluster resource rules with per-rule scope. Rules []CompiledClusterResourceRule } +// AllowsType reports whether the grant the rule compiled under covers resource in group. +func (c CompiledClusterRule) AllowsType(group, resource string) bool { + scope := configv1alpha3.KubeProviderGitTargetScope{ClusterResources: c.ClusterResources} + return scope.AllowsClusterResource(group, resource) +} + // CompiledClusterResourceRule represents a single cluster resource rule. // // It carries no scope: a ClusterWatchRule is cluster-scope-only, so resolution always matches @@ -265,6 +277,7 @@ func (s *RuleStore) GetWatchRule(key types.NamespacedName) (CompiledRule, bool) // - path: POSIX-like relative path prefix for writes (from GitTarget.Spec.Path, sanitized upstream) func (s *RuleStore) AddOrUpdateClusterWatchRule( rule configv1alpha3.ClusterWatchRule, + clusterResources []string, gitTargetName string, gitTargetNamespace string, gitBranchName string, @@ -287,6 +300,7 @@ func (s *RuleStore) AddOrUpdateClusterWatchRule( GitBranchNamespace: gitBranchNamespace, Path: path, CreatedAt: rule.CreationTimestamp, + ClusterResources: slices.Clone(clusterResources), Rules: make([]CompiledClusterResourceRule, 0, len(rule.Spec.Rules)), } diff --git a/internal/rulestore/store_test.go b/internal/rulestore/store_test.go index 3aea2ee4d..b63595458 100644 --- a/internal/rulestore/store_test.go +++ b/internal/rulestore/store_test.go @@ -137,7 +137,7 @@ func TestAddOrUpdateClusterWatchRule(t *testing.T) { // Add rule store.AddOrUpdateClusterWatchRule( - rule, + rule, []string{"*"}, "test-cluster-target", "gitops-system", "cluster-provider", @@ -222,7 +222,15 @@ func TestDeleteClusterWatchRule(t *testing.T) { key := types.NamespacedName{Name: "delete-cluster-test", Namespace: ""} // Add rule - store.AddOrUpdateClusterWatchRule(rule, "test-dest", "gitops-system", "test-repo", "gitops-system", "test") + store.AddOrUpdateClusterWatchRule( + rule, + []string{"*"}, + "test-dest", + "gitops-system", + "test-repo", + "gitops-system", + "test", + ) // Verify it exists if _, exists := store.clusterRules[key]; !exists { @@ -431,7 +439,7 @@ func TestGetMatchingClusterRules(t *testing.T) { podRule.Name = "pod-cluster-rule" store.AddOrUpdateClusterWatchRule( - clusterRule, + clusterRule, []string{"*"}, "dest1", "gitops-system", "repo1", @@ -439,7 +447,7 @@ func TestGetMatchingClusterRules(t *testing.T) { "cluster", ) store.AddOrUpdateClusterWatchRule( - podRule, + podRule, []string{"*"}, "dest2", "gitops-system", "repo2", @@ -664,7 +672,15 @@ func TestSnapshotClusterWatchRules(t *testing.T) { } rule1.Name = "cluster-rule1" - store.AddOrUpdateClusterWatchRule(rule1, "dest1", "gitops-system", "repo1", "gitops-system", "cluster") + store.AddOrUpdateClusterWatchRule( + rule1, + []string{"*"}, + "dest1", + "gitops-system", + "repo1", + "gitops-system", + "cluster", + ) snapshot := store.SnapshotClusterWatchRules() @@ -838,6 +854,7 @@ func TestGetMatchingRules_NamespacedWatchRule_NamespaceContract(t *testing.T) { }}, }, }, + []string{"*"}, "cluster-target", "ops", "cluster-provider", "ops", "cluster", ) @@ -1009,7 +1026,15 @@ func TestSharedSelector_RoutesAlikeThroughBothEntryPoints(t *testing.T) { }, } clusterRule.Name = "cluster-rule" - store.AddOrUpdateClusterWatchRule(clusterRule, "target", "tenant-acme", "provider", "tenant-acme", "clusters") + store.AddOrUpdateClusterWatchRule( + clusterRule, + []string{"*"}, + "target", + "tenant-acme", + "provider", + "tenant-acme", + "clusters", + ) inScope := &unstructured.Unstructured{} inScope.SetNamespace("tenant-acme") @@ -1168,7 +1193,7 @@ func TestStore_OwnsItsSelectorSlices(t *testing.T) { }, } rule.Name = "cluster-rule" - store.AddOrUpdateClusterWatchRule(rule, "target", "tenant-acme", "provider", + store.AddOrUpdateClusterWatchRule(rule, []string{"*"}, "target", "tenant-acme", "provider", "tenant-acme", "clusters") return func() { diff --git a/internal/watch/bootstrap.go b/internal/watch/bootstrap.go index 2c5be40d1..7e57338fc 100644 --- a/internal/watch/bootstrap.go +++ b/internal/watch/bootstrap.go @@ -93,7 +93,8 @@ func (m *Manager) bootstrapClusterWatchRule(ctx context.Context, rule configv1al // target the provider never admitted. A refusal is not fatal to startup: the // rule is simply left out of the store, and the reconciler's own gate re-decides (and can grant // it) as soon as the controller's initial sync reaches this rule. - decision, err := CompileClusterWatchRule(ctx, m.Client, m.RuleStore, rule, target) + decision, err := CompileClusterWatchRule(ctx, m.Client, m.RuleStore, rule, target, + m.ClusterResourcesSelectedFor(target)) if err != nil { return fmt.Errorf("evaluating admission for ClusterWatchRule %q: %w", rule.Name, err) } diff --git a/internal/watch/bootstrap_admission_test.go b/internal/watch/bootstrap_admission_test.go index cb95a5674..55805952b 100644 --- a/internal/watch/bootstrap_admission_test.go +++ b/internal/watch/bootstrap_admission_test.go @@ -30,7 +30,7 @@ const ( bootTargetNS = "team-a" bootProviderName = "prod-eu-1" // bootProviderNS is the KubeProvider's namespace, not the GitTarget's, so whether it admits the - // GitTarget is its spec.accessFrom's decision rather than the own-namespace rule's. + // GitTarget is decided by a Names grant in its spec.allowedGitTargets. bootProviderNS = "platform" ) @@ -51,12 +51,26 @@ func bootGitProvider() *configv1alpha3.GitProvider { } } -func bootKubeProvider(policy *configv1alpha3.NamespaceMatcher) *configv1alpha3.KubeProvider { +// mirrorGrants admits the named namespaces to mirror their own namespace and every cluster-scoped +// resource, the reach a provider admitting them had before grants were scoped. +func mirrorGrants(namespaces ...string) []configv1alpha3.KubeProviderGitTargetGrant { + names := make([]configv1alpha3.GrantNamespace, 0, len(namespaces)) + for _, ns := range namespaces { + names = append(names, configv1alpha3.GrantNamespace(ns)) + } + return []configv1alpha3.KubeProviderGitTargetGrant{{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromNames, Names: names}, + SourceNamespaces: []configv1alpha3.ScopeNamespace{configv1alpha3.ConsumerNamespaceVariable}, + ClusterResources: []configv1alpha3.ClusterResourceIdentity{"*"}, + }} +} + +func bootKubeProvider(policy []configv1alpha3.KubeProviderGitTargetGrant) *configv1alpha3.KubeProvider { return &configv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: bootProviderNS, Name: bootProviderName}, Spec: configv1alpha3.KubeProviderSpec{ - SecretRef: &meta.SecretKeyReference{Name: "prod-eu-1-kubeconfig"}, - AccessFrom: policy, + SecretRef: &meta.SecretKeyReference{Name: "prod-eu-1-kubeconfig"}, + AllowedGitTargets: policy, }, } } @@ -96,7 +110,7 @@ func bootCompiledNames(m *Manager) []string { func TestBootstrapClusterWatchRule_RefusesUnauthorizedGitTargetNamespace(t *testing.T) { m := bootManager(t, bootGitTarget(), bootGitProvider(), - bootKubeProvider(&configv1alpha3.NamespaceMatcher{Names: []string{"some-other-namespace"}}), + bootKubeProvider(mirrorGrants("some-other-namespace")), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: bootTargetNS}}, ) @@ -126,7 +140,7 @@ func TestBootstrapClusterWatchRule_RefusesMissingKubeProvider(t *testing.T) { func TestBootstrapClusterWatchRule_SeedsAdmittedRule(t *testing.T) { m := bootManager(t, bootGitTarget(), bootGitProvider(), - bootKubeProvider(&configv1alpha3.NamespaceMatcher{Names: []string{bootTargetNS}}), + bootKubeProvider(mirrorGrants(bootTargetNS)), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: bootTargetNS}}, ) @@ -163,7 +177,7 @@ func TestBootstrapRuleStore_SkipsUnauthorizedRuleButStillReady(t *testing.T) { &configv1alpha3.GitProvider{ObjectMeta: metav1.ObjectMeta{Name: "git", Namespace: "team-ok"}}, admittedRule, // Admits team-ok only, so the team-a rule is refused. - bootKubeProvider(&configv1alpha3.NamespaceMatcher{Names: []string{"team-ok"}}), + bootKubeProvider(mirrorGrants("team-ok")), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: bootTargetNS}}, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-ok"}}, ) @@ -189,7 +203,7 @@ func TestBootstrapClusterWatchRule_WildcardStillResolvesItsClusterScopedTypes(t m := bootManager(t, bootGitTarget(), bootGitProvider(), - bootKubeProvider(&configv1alpha3.NamespaceMatcher{Names: []string{bootTargetNS}}), + bootKubeProvider(mirrorGrants(bootTargetNS)), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: bootTargetNS}}, ) @@ -207,12 +221,12 @@ func TestCompileClusterWatchRule_RefusalRemovesAnAlreadyCompiledRule(t *testing. ctx := context.Background() m := bootManager(t, bootGitTarget(), bootGitProvider(), - bootKubeProvider(&configv1alpha3.NamespaceMatcher{Names: []string{bootTargetNS}}), + bootKubeProvider(mirrorGrants(bootTargetNS)), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: bootTargetNS}}, ) decision, err := CompileClusterWatchRule( - ctx, m.Client, m.RuleStore, *bootClusterWatchRule(), *bootGitTarget()) + ctx, m.Client, m.RuleStore, *bootClusterWatchRule(), *bootGitTarget(), nil) require.NoError(t, err) require.True(t, decision.Admitted) require.Len(t, bootCompiledNames(m), 1, "precondition: the rule is compiled") @@ -221,11 +235,11 @@ func TestCompileClusterWatchRule_RefusalRemovesAnAlreadyCompiledRule(t *testing. var provider configv1alpha3.KubeProvider providerKey := client.ObjectKey{Namespace: bootProviderNS, Name: bootProviderName} require.NoError(t, m.Client.Get(ctx, providerKey, &provider)) - provider.Spec.AccessFrom = &configv1alpha3.NamespaceMatcher{Names: []string{"someone-else"}} + provider.Spec.AllowedGitTargets = mirrorGrants("someone-else") require.NoError(t, m.Client.Update(ctx, &provider)) decision, err = CompileClusterWatchRule( - ctx, m.Client, m.RuleStore, *bootClusterWatchRule(), *bootGitTarget()) + ctx, m.Client, m.RuleStore, *bootClusterWatchRule(), *bootGitTarget(), nil) require.NoError(t, err) assert.False(t, decision.Admitted) diff --git a/internal/watch/cluster_context.go b/internal/watch/cluster_context.go index 716f6b35f..2274a801d 100644 --- a/internal/watch/cluster_context.go +++ b/internal/watch/cluster_context.go @@ -864,3 +864,27 @@ func (m *Manager) publishClusterOrderLocked() { } m.clusterOrder.Store(&out) } + +// ClusterResourcesSelectedFor is what the watch planner selects, in the cluster target mirrors from, +// for a ClusterWatchRule item: the same matching collectClusterWatchRuleSelections does, versions and +// the omitted-apiGroups ambiguity rule included, so the compile checks against the grant exactly the +// types the rule would watch. It selects nothing until the target's cluster has been captured. +func (m *Manager) ClusterResourcesSelectedFor(target configv1alpha3.GitTarget) ClusterResourceSelection { + ref := types.NewResourceReference(target.Name, target.Namespace) + return func(item configv1alpha3.ClusterResourceRule) []schema.GroupResource { + clusterID, captured := m.watchPlane().clusters[ref.Key()] + if !captured { + return nil + } + cc := m.clusterContextByID(clusterID) + if cc == nil { + return nil + } + matched := matchClusterRuleItem(cc.registry.Followable(), item.APIGroups, item.APIVersions, item.Resources) + selected := make([]schema.GroupResource, 0, len(matched)) + for _, rec := range matched { + selected = append(selected, rec.Identity.GVR.GroupResource()) + } + return selected + } +} diff --git a/internal/watch/cluster_resources_test.go b/internal/watch/cluster_resources_test.go new file mode 100644 index 000000000..cf2189cdb --- /dev/null +++ b/internal/watch/cluster_resources_test.go @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: Apache-2.0 + +package watch + +import ( + "context" + "testing" + + meta "github.com/fluxcd/pkg/apis/meta" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + + configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/authz" + "github.com/ConfigButler/gitops-reverser/internal/types" +) + +// granting is the boot KubeProvider admitting the boot GitTarget's namespace with clusterResources +// identities; none at all gives it its own namespace and no cluster-scoped resource. +func granting(identities ...configv1alpha3.ClusterResourceIdentity) *configv1alpha3.KubeProvider { + grant := configv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{ + From: configv1alpha3.GrantFromNames, + Names: []configv1alpha3.GrantNamespace{bootTargetNS}, + }, + ClusterResources: identities, + } + if len(identities) == 0 { + grant.SourceNamespaces = []configv1alpha3.ScopeNamespace{configv1alpha3.ConsumerNamespaceVariable} + } + return bootKubeProvider([]configv1alpha3.KubeProviderGitTargetGrant{grant}) +} + +// clusterRuleFor is a ClusterWatchRule on the boot GitTarget with items. +func clusterRuleFor(name string, items ...configv1alpha3.ClusterResourceRule) configv1alpha3.ClusterWatchRule { + return configv1alpha3.ClusterWatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: configv1alpha3.ClusterWatchRuleSpec{ + GitTargetRef: meta.NamespacedObjectReference{Name: bootTargetName, Namespace: bootTargetNS}, + Rules: items, + }, + } +} + +func item(groups []string, resources ...string) configv1alpha3.ClusterResourceRule { + return configv1alpha3.ClusterResourceRule{APIGroups: groups, Resources: resources} +} + +var ( + rbacGroup = []string{"rbac.authorization.k8s.io"} + coreGroup = []string{""} +) + +// TestCompileClusterWatchRule_ClusterResources is decided on the rule's text: named pairs are checked +// pair by pair, a wildcard needs "*", and one forbidden type refuses the whole rule. +func TestCompileClusterWatchRule_ClusterResources(t *testing.T) { + clusterRoles := item(rbacGroup, "clusterroles") + tests := []struct { + name string + provider *configv1alpha3.KubeProvider + items []configv1alpha3.ClusterResourceRule + refused string + }{ + {"no clusterResources refuses clusterroles", granting(), []configv1alpha3.ClusterResourceRule{clusterRoles}, + `asks for "clusterroles.rbac.authorization.k8s.io"`}, + {"another identity refuses clusterroles", + granting("customresourcedefinitions.apiextensions.k8s.io"), + []configv1alpha3.ClusterResourceRule{clusterRoles}, `"clusterroles.rbac.authorization.k8s.io"`}, + {"a bare name is the core group only", granting("clusterroles"), + []configv1alpha3.ClusterResourceRule{clusterRoles}, `"clusterroles.rbac.authorization.k8s.io"`}, + {"its identity compiles clusterroles", granting("clusterroles.rbac.authorization.k8s.io"), + []configv1alpha3.ClusterResourceRule{clusterRoles}, ""}, + {"* compiles clusterroles", granting("*"), []configv1alpha3.ClusterResourceRule{clusterRoles}, ""}, + {"a bare name compiles a core resource", granting("nodes"), + []configv1alpha3.ClusterResourceRule{item(coreGroup, "nodes")}, ""}, + {"resources: * needs *", granting("clusterroles.rbac.authorization.k8s.io"), + []configv1alpha3.ClusterResourceRule{item(rbacGroup, "*")}, `needs clusterResources: ["*"]`}, + {"apiGroups: * needs *", granting("clusterroles.rbac.authorization.k8s.io"), + []configv1alpha3.ClusterResourceRule{item([]string{"*"}, "clusterroles")}, `needs clusterResources: ["*"]`}, + {"a wildcard compiles under *", granting("*"), + []configv1alpha3.ClusterResourceRule{item([]string{"*"}, "*")}, ""}, + {"one forbidden type refuses the whole rule, and is named", + granting("clusterroles.rbac.authorization.k8s.io"), + []configv1alpha3.ClusterResourceRule{clusterRoles, item(coreGroup, "nodes", "namespaces")}, + `spec.rules[1] asks for "nodes", "namespaces"`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := bootManager(t, bootGitTarget(), tt.provider) + rule := clusterRuleFor("rule", tt.items...) + decision, err := CompileClusterWatchRule(context.Background(), m.Client, m.RuleStore, rule, + *bootGitTarget(), nil) + require.NoError(t, err) + if tt.refused == "" { + assert.True(t, decision.Admitted, decision.Message) + assert.Equal(t, []string{"rule"}, bootCompiledNames(m)) + return + } + assert.False(t, decision.Admitted) + assert.True(t, decision.ResourcesRefused) + assert.Equal(t, ClusterWatchRuleReasonClusterResourceNotAllowed, decision.Reason) + assert.Contains(t, decision.Message, tt.refused) + assert.Contains(t, decision.Message, "KubeProvider platform/prod-eu-1 lets namespace \"team-a\" mirror "+ + "cluster-scoped resources") + assert.Empty(t, bootCompiledNames(m), "a refused rule is never compiled") + }) + } +} + +// TestCompileClusterWatchRule_AnOmittedGroupIsCheckedByItsResolvedPair: with apiGroups omitted, the +// item is checked by the types the planner selects for it. +func TestCompileClusterWatchRule_AnOmittedGroupIsCheckedByItsResolvedPair(t *testing.T) { + m := bootManager(t, bootGitTarget(), granting("nodes")) + rule := clusterRuleFor("rule", item(nil, "clusterroles")) + rbac := func(configv1alpha3.ClusterResourceRule) []schema.GroupResource { + return []schema.GroupResource{{Group: rbacGroup[0], Resource: "clusterroles"}} + } + + decision, err := CompileClusterWatchRule(context.Background(), m.Client, m.RuleStore, rule, *bootGitTarget(), rbac) + require.NoError(t, err) + assert.Equal(t, ClusterWatchRuleReasonClusterResourceNotAllowed, decision.Reason) + assert.Contains(t, decision.Message, `"clusterroles.rbac.authorization.k8s.io"`) + + // Selecting nothing today, it compiles; the planner keeps it to the grant. + decision, err = CompileClusterWatchRule(context.Background(), m.Client, m.RuleStore, rule, *bootGitTarget(), nil) + require.NoError(t, err) + assert.True(t, decision.Admitted) + compiled := m.RuleStore.SnapshotClusterWatchRules() + require.Len(t, compiled, 1) + assert.Equal(t, []string{"nodes"}, compiled[0].ClusterResources) + assert.False(t, compiled[0].AllowsType("rbac.authorization.k8s.io", "clusterroles")) +} + +// TestCompileClusterWatchRule_RemovingAnIdentityRefusesOnlyTheRulesNamingIt: the GitTarget stays +// admitted, and its other rules keep compiling. +func TestCompileClusterWatchRule_RemovingAnIdentityRefusesOnlyTheRulesNamingIt(t *testing.T) { + m := bootManager(t, bootGitTarget(), granting("nodes", "clusterroles.rbac.authorization.k8s.io")) + nodes := clusterRuleFor("nodes", item(coreGroup, "nodes")) + roles := clusterRuleFor("roles", item(rbacGroup, "clusterroles")) + for _, rule := range []configv1alpha3.ClusterWatchRule{nodes, roles} { + decision, err := CompileClusterWatchRule( + context.Background(), + m.Client, + m.RuleStore, + rule, + *bootGitTarget(), + nil, + ) + require.NoError(t, err) + require.True(t, decision.Admitted, decision.Message) + } + + stored := &configv1alpha3.KubeProvider{} + require.NoError(t, m.Client.Get(context.Background(), granting().Key(), stored)) + stored.Spec.AllowedGitTargets[0].ClusterResources = []configv1alpha3.ClusterResourceIdentity{"nodes"} + require.NoError(t, m.Client.Update(context.Background(), stored)) + + decision, err := CompileClusterWatchRule(context.Background(), m.Client, m.RuleStore, roles, *bootGitTarget(), nil) + require.NoError(t, err) + assert.False(t, decision.Admitted) + decision, err = CompileClusterWatchRule(context.Background(), m.Client, m.RuleStore, nodes, *bootGitTarget(), nil) + require.NoError(t, err) + assert.True(t, decision.Admitted) + assert.Equal(t, []string{"nodes"}, bootCompiledNames(m)) + admitted, err := authz.GitTargetAdmitted(context.Background(), m.Client, bootGitTarget()) + require.NoError(t, err) + assert.True(t, admitted.Allowed, "the GitTarget stays admitted for its other rules") +} + +// TestRefreshWatchedTypeTables_DiscoveryNeverWidensAGrant: a compiled rule's matches outside the +// clusterResources it compiled under are never planned, so a type discovery serves later stays out. +func TestRefreshWatchedTypeTables_DiscoveryNeverWidensAGrant(t *testing.T) { + for _, tc := range []struct { + granted []string + types int + }{{[]string{"*"}, 1}, {[]string{"namespaces"}, 1}, {[]string{"nodes"}, 0}, {nil, 0}} { + manager, store := makeWatchedTypeManager(t) + store.AddOrUpdateClusterWatchRule(clusterRuleForResource("rule-1", "namespaces"), tc.granted, + "test-target", "test-ns", "test-provider", "test-ns", "test-path") + manager.refreshWatchedTypeTables() + table, _ := manager.watchedTypeTableForGitDest(gitDestRef("test-target")) + assert.Len(t, table.Types, tc.types, "granted %v", tc.granted) + } +} + +// TestRefreshWatchedTypeTables_ANarrowerGrantRebuildsThePlan: the granted clusterResources are part of +// the plan's fingerprint, so recompiling a rule under a narrower grant drops what it no longer covers. +func TestRefreshWatchedTypeTables_ANarrowerGrantRebuildsThePlan(t *testing.T) { + manager, store := makeWatchedTypeManager(t) + rule := clusterRuleForResource("rule-1", "namespaces") + store.AddOrUpdateClusterWatchRule(rule, []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", + "test-path") + manager.refreshWatchedTypeTables() + table, _ := manager.watchedTypeTableForGitDest(gitDestRef("test-target")) + require.Len(t, table.Types, 1) + + store.AddOrUpdateClusterWatchRule(rule, []string{"nodes"}, "test-target", "test-ns", "test-provider", "test-ns", + "test-path") + manager.refreshWatchedTypeTables() + table, _ = manager.watchedTypeTableForGitDest(gitDestRef("test-target")) + assert.Empty(t, table.Types, "a type the grant no longer covers leaves the plan") +} + +// TestCompileClusterWatchRule_TheCompileChecksWhatThePlannerSelects: an item omitting apiGroups whose +// resource two groups serve, but only one at the version it names, is no ambiguity to the planner. The +// compile checks the type the planner selects, so withdrawing its grant refuses the rule and empties +// the plan. +func TestCompileClusterWatchRule_TheCompileChecksWhatThePlannerSelects(t *testing.T) { + m, _ := makeWatchedTypeManager(t) + target := bootGitTarget() + m.Client = bootManager(t, target, granting("*")).Client + discovery := oneScopedResourceDiscovery("", "namespaces", "Namespace", false) + discovery.groups = append(discovery.groups, testAPIGroup("other.example", "v2")) + discovery.resources = append(discovery.resources, &metav1.APIResourceList{ + GroupVersion: "other.example/v2", APIResources: []metav1.APIResource{ + {Name: "namespaces", Kind: "Namespace", Verbs: metav1.Verbs{"get", "list", "watch"}}, + }, + }) + seedClusterCatalog(t, m, configPlaneClusterID, discovery) + dest := types.NewResourceReference(target.Name, target.Namespace) + m.rememberGitTargetCluster(dest, configPlaneClusterID) + rule := clusterRuleFor("rule", configv1alpha3.ClusterResourceRule{ + Resources: []string{"namespaces"}, APIVersions: []string{"v1"}, + }) + + decision, err := CompileClusterWatchRule(context.Background(), m.Client, m.RuleStore, rule, *target, + m.ClusterResourcesSelectedFor(*target)) + require.NoError(t, err) + require.True(t, decision.Admitted, decision.Message) + m.refreshWatchedTypeTables() + before, _ := m.watchedTypeTableForGitDest(dest) + require.Len(t, before.Types, 1) + + stored := &configv1alpha3.KubeProvider{} + require.NoError(t, m.Client.Get(context.Background(), granting().Key(), stored)) + stored.Spec.AllowedGitTargets = granting().Spec.AllowedGitTargets + require.NoError(t, m.Client.Update(context.Background(), stored)) + decision, err = CompileClusterWatchRule(context.Background(), m.Client, m.RuleStore, rule, *target, + m.ClusterResourcesSelectedFor(*target)) + require.NoError(t, err) + assert.Equal(t, ClusterWatchRuleReasonClusterResourceNotAllowed, decision.Reason) + assert.Contains(t, decision.Message, `spec.rules[0] asks for "namespaces"`) + m.refreshWatchedTypeTables() + after, _ := m.watchedTypeTableForGitDest(dest) + assert.Empty(t, after.Types, "the revoked type leaves the plan") +} diff --git a/internal/watch/config_plane_split_review_fixes_test.go b/internal/watch/config_plane_split_review_fixes_test.go index 4bbc32f49..ab329f096 100644 --- a/internal/watch/config_plane_split_review_fixes_test.go +++ b/internal/watch/config_plane_split_review_fixes_test.go @@ -272,7 +272,7 @@ func TestRefreshWatchedTypeTables_RetargetReResolvesAtEqualRevisions(t *testing. "both remotes are one scan in, so their registry revisions are equal on purpose") store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "namespaces"), + clusterRuleForResource("rule-1", "namespaces"), []string{"*"}, "t", "test-ns", "test-provider", "test-ns", "test-path", ) m.rememberGitTargetCluster(gitDestRef("t"), clusterA) diff --git a/internal/watch/event_router_test.go b/internal/watch/event_router_test.go index 66c228b1f..ff3fdbe73 100644 --- a/internal/watch/event_router_test.go +++ b/internal/watch/event_router_test.go @@ -586,7 +586,7 @@ func testGitBranchObject() *configv1alpha3.GitBranch { return &configv1alpha3.GitBranch{ ObjectMeta: metav1.ObjectMeta{Name: gitBranchNameForTest("team-a-provider", "main"), Namespace: "team-a"}, Spec: configv1alpha3.GitBranchSpec{ - GitProviderRef: meta.LocalObjectReference{Name: "team-a-provider"}, + GitProviderRef: meta.NamespacedObjectReference{Name: "team-a-provider"}, Branch: "main", }, } diff --git a/internal/watch/git_branch_helpers_test.go b/internal/watch/git_branch_helpers_test.go index b7fd675f4..348d0228a 100644 --- a/internal/watch/git_branch_helpers_test.go +++ b/internal/watch/git_branch_helpers_test.go @@ -30,6 +30,10 @@ func claimWorkerForTest( Created: time.Unix(0, 0), }, true) return git.WorkerRequest{ - GitBranch: gitBranch, Provider: provider, Branch: branch, Repo: repo, ParentBranch: parentBranch, + GitBranch: gitBranch, + Provider: k8stypes.NamespacedName{Namespace: namespace, Name: provider}, + Branch: branch, + Repo: repo, + ParentBranch: parentBranch, } } diff --git a/internal/watch/manager_startup_test.go b/internal/watch/manager_startup_test.go index 83c7784c0..76ba2196f 100644 --- a/internal/watch/manager_startup_test.go +++ b/internal/watch/manager_startup_test.go @@ -40,15 +40,33 @@ func TestManagerStart_MustSeedRuleStoreFromExistingWatchRules(t *testing.T) { existingGitTarget := &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "playground-target", Namespace: "tilt-playground"}, Spec: configv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("playground-provider", "main")}, - Path: "live-cluster", + GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("playground-provider", "main")}, + KubeProviderRef: meta.NamespacedObjectReference{Name: "local"}, + Path: "live-cluster", + }, + } + // A WatchRule's own namespace is watched only through a grant, so the fixture ships the provider + // that gives it. + localKubeProvider := &configv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: "local", Namespace: "tilt-playground"}, + Spec: configv1alpha3.KubeProviderSpec{ + InCluster: &configv1alpha3.KubeProviderInCluster{}, + AllowedGitTargets: []configv1alpha3.KubeProviderGitTargetGrant{{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromSame}, + SourceNamespaces: []configv1alpha3.ScopeNamespace{configv1alpha3.ConsumerNamespaceVariable}, + }}, }, } existingGitProvider := &configv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Name: "playground-provider", Namespace: "tilt-playground"}, Spec: configv1alpha3.GitProviderSpec{ - URL: "https://example.invalid/playground.git", - AllowedBranches: []string{"main"}, + URL: "https://example.invalid/playground.git", + AllowedGitBranches: []configv1alpha3.GitProviderGitBranchGrant{ + { + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromSame}, + BranchPatterns: []configv1alpha3.BranchPattern{"main"}, + }, + }, }, } @@ -59,6 +77,7 @@ func TestManagerStart_MustSeedRuleStoreFromExistingWatchRules(t *testing.T) { existingWatchRule, existingGitTarget, existingGitProvider, + localKubeProvider, ). Build() @@ -114,19 +133,30 @@ func TestManagerStart_MustSeedRuleStoreFromExistingClusterWatchRules(t *testing. existingGitProvider := &configv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Name: "ops-provider", Namespace: "ops"}, Spec: configv1alpha3.GitProviderSpec{ - URL: "https://example.invalid/ops.git", - AllowedBranches: []string{"main"}, + URL: "https://example.invalid/ops.git", + AllowedGitBranches: []configv1alpha3.GitProviderGitBranchGrant{ + { + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromSame}, + BranchPatterns: []configv1alpha3.BranchPattern{"main"}, + }, + }, }, } // existingGitTarget references the KubeProvider ops/local, in its own namespace. Bootstrap - // applies that provider's namespace admission before seeding a ClusterWatchRule, so the fixture - // must ship the provider (its own namespace is always admitted). Without it the seed is + // applies that provider's grants before seeding a ClusterWatchRule, so the fixture must ship the + // provider, granting its own namespace every cluster-scoped resource. Without it the seed is // correctly REFUSED, which is the behavior TestBootstrapClusterWatchRule_RefusesMissingKubeProvider // covers; this test is about the admitted path. localKubeProvider := &configv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: "local", Namespace: "ops"}, - Spec: configv1alpha3.KubeProviderSpec{InCluster: &configv1alpha3.KubeProviderInCluster{}}, + Spec: configv1alpha3.KubeProviderSpec{ + InCluster: &configv1alpha3.KubeProviderInCluster{}, + AllowedGitTargets: []configv1alpha3.KubeProviderGitTargetGrant{{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromSame}, + ClusterResources: []configv1alpha3.ClusterResourceIdentity{"*"}, + }}, + }, } fakeClient := fake.NewClientBuilder(). diff --git a/internal/watch/object_selector_test.go b/internal/watch/object_selector_test.go index 6cc4422f5..5f921cb2c 100644 --- a/internal/watch/object_selector_test.go +++ b/internal/watch/object_selector_test.go @@ -251,11 +251,11 @@ func TestCompileWatchRule_RefusesAnInvalidObjectSelector(t *testing.T) { func TestCompileClusterWatchRule_RefusesAnInvalidObjectSelector(t *testing.T) { store := rulestore.NewStore() rule := clusterRuleForResource("bad", "namespaces") - store.AddOrUpdateClusterWatchRule(rule, "t", "ns", "p", "ns", "path") + store.AddOrUpdateClusterWatchRule(rule, []string{"*"}, "t", "ns", "p", "ns", "path") rule.Spec.Rules[0].ObjectSelector = labels("bad key", "x") _, err := CompileClusterWatchRule(context.Background(), nil, store, rule, - configv1alpha3.GitTarget{}) + configv1alpha3.GitTarget{}, nil) var selectorErr *ObjectSelectorError require.ErrorAs(t, err, &selectorErr) diff --git a/internal/watch/source_namespace_test.go b/internal/watch/source_namespace_test.go index 691ee4f7d..c57cf4efa 100644 --- a/internal/watch/source_namespace_test.go +++ b/internal/watch/source_namespace_test.go @@ -27,7 +27,7 @@ const ( snbRule = "repo-config-rule" snbProvider = "workspaces" // snbProviderNS is the KubeProvider's namespace: another than the tenant's, so the tenant is - // admitted through spec.accessFrom. + // admitted through a Names grant. snbProviderNS = "platform" ) @@ -48,13 +48,23 @@ func snbGitProvider() *configv1alpha3.GitProvider { } } -func snbKubeProvider(delegate bool) *configv1alpha3.KubeProvider { +// snbKubeProvider admits snbTenantNS to watch its own namespace, and every namespace when wide. +func snbKubeProvider(wide bool) *configv1alpha3.KubeProvider { + grant := configv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{ + From: configv1alpha3.GrantFromNames, + Names: []configv1alpha3.GrantNamespace{snbTenantNS}, + }, + SourceNamespaces: []configv1alpha3.ScopeNamespace{configv1alpha3.ConsumerNamespaceVariable}, + } + if wide { + grant.SourceNamespaces = append(grant.SourceNamespaces, "*") + } return &configv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: snbProviderNS, Name: snbProvider}, Spec: configv1alpha3.KubeProviderSpec{ - InCluster: &configv1alpha3.KubeProviderInCluster{}, - AccessFrom: &configv1alpha3.NamespaceMatcher{Names: []string{snbTenantNS}}, - AllowCrossNamespace: &configv1alpha3.KubeProviderCrossNamespace{Sources: delegate}, + InCluster: &configv1alpha3.KubeProviderInCluster{}, + AllowedGitTargets: []configv1alpha3.KubeProviderGitTargetGrant{grant}, }, } } @@ -186,8 +196,8 @@ func TestCompileWatchRule_WildcardIsRefusedWithoutDelegation(t *testing.T) { require.NoError(t, err) assert.False(t, resolved.Admitted()) - assert.Contains(t, resolved.Message, "spec.allowCrossNamespace.sources", - "the refusal must name the flag a platform admin has to set") + assert.Contains(t, resolved.Message, "spec.allowedGitTargets[].sourceNamespaces", + "the refusal must name the grant a platform admin has to widen") assert.Empty(t, m.RuleStore.SnapshotWatchRules()) } @@ -209,11 +219,12 @@ func TestCompileWatchRule_TerminalRefusalRemovesAnAlreadyCompiledRule(t *testing require.True(t, resolved.Admitted()) require.Len(t, m.RuleStore.SnapshotWatchRules(), 1, "precondition: the rule is compiled") - // The platform admin withdraws the delegation. + // The platform admin narrows the grant to the tenant's own namespace. var stored configv1alpha3.KubeProvider providerKey := k8stypes.NamespacedName{Namespace: snbProviderNS, Name: snbProvider} require.NoError(t, m.Client.Get(ctx, providerKey, &stored)) - stored.Spec.AllowCrossNamespace = nil + stored.Spec.AllowedGitTargets[0].SourceNamespaces = []configv1alpha3.ScopeNamespace{ + configv1alpha3.ConsumerNamespaceVariable} require.NoError(t, m.Client.Update(ctx, &stored)) resolved, err = CompileWatchRule(ctx, m.Client, m.RuleStore, rule, target) diff --git a/internal/watch/watched_type_helpers_test.go b/internal/watch/watched_type_helpers_test.go index 649be2007..d192595df 100644 --- a/internal/watch/watched_type_helpers_test.go +++ b/internal/watch/watched_type_helpers_test.go @@ -57,7 +57,7 @@ func watchRuleForTarget(name, gitTargetName, namespace string) configv1alpha3.Wa func TestRefreshWatchedTypeTables_ConcurrentRefreshesConverge(t *testing.T) { manager, store := makeWatchedTypeManager(t) store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "namespaces"), + clusterRuleForResource("rule-1", "namespaces"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) @@ -74,7 +74,7 @@ func TestRefreshWatchedTypeTables_ConcurrentRefreshesConverge(t *testing.T) { } // Concurrently add a second rule mid-flight. store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-2", "nodes"), + clusterRuleForResource("rule-2", "nodes"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) wg.Wait() diff --git a/internal/watch/watched_type_metrics_test.go b/internal/watch/watched_type_metrics_test.go index 4621e51ae..01a48ae7a 100644 --- a/internal/watch/watched_type_metrics_test.go +++ b/internal/watch/watched_type_metrics_test.go @@ -23,7 +23,7 @@ func TestWatchTypeSamples_SumIsTheResolvedTypeCount(t *testing.T) { manager, store := makeWatchedTypeManager(t) store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "namespaces"), + clusterRuleForResource("rule-1", "namespaces"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) manager.refreshWatchedTypeTables() @@ -58,7 +58,7 @@ func TestWatchTypeSamples_DepartedTargetStopsPublishing(t *testing.T) { manager, store := makeWatchedTypeManager(t) store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "namespaces"), + clusterRuleForResource("rule-1", "namespaces"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) manager.refreshWatchedTypeTables() @@ -93,7 +93,7 @@ func TestWatchTypeSamples_DoesNotResolveOnTheScrapePath(t *testing.T) { manager, store := makeWatchedTypeManager(t) store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "namespaces"), + clusterRuleForResource("rule-1", "namespaces"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) manager.refreshWatchedTypeTables() @@ -105,7 +105,7 @@ func TestWatchTypeSamples_DoesNotResolveOnTheScrapePath(t *testing.T) { // A second type is declared, and nothing refreshes the tables. store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-2", "nodes"), + clusterRuleForResource("rule-2", "nodes"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) diff --git a/internal/watch/watched_type_resolver.go b/internal/watch/watched_type_resolver.go index 7f7a0294e..020e5ed07 100644 --- a/internal/watch/watched_type_resolver.go +++ b/internal/watch/watched_type_resolver.go @@ -386,9 +386,12 @@ func (m *Manager) collectClusterWatchRuleSelections( ts := get(gitTargetRef, rule.GitBranchNamespace, rule.GitBranchRef, rule.Path) selecting := selectingRule{kind: ruleKindClusterWatchRule, name: rule.Source.Name, createdAt: rule.CreatedAt} for _, rr := range rule.Rules { - matched := matchFollowableRecords( - records, rr.APIGroups, rr.APIVersions, rr.Resources, configv1alpha3.ResourceScopeCluster) - for _, rec := range matched { + for _, rec := range matchClusterRuleItem(records, rr.APIGroups, rr.APIVersions, rr.Resources) { + // The compile refused every type the rule names outside its grant. This keeps a type + // discovery serves only later, matched through an omitted apiGroups, out as well. + if !rule.AllowsType(rec.Identity.GVR.Group, rec.Identity.GVR.Resource) { + continue + } ts.selections = append(ts.selections, watchSelection{ record: rec, namespace: "", labelSelector: rr.LabelSelector, rule: selecting, }) @@ -397,6 +400,13 @@ func (m *Manager) collectClusterWatchRuleSelections( } } +// matchClusterRuleItem is the records a ClusterWatchRule item selects, before its grant: the planner +// filters them by the compiled rule's grant, and the compile refuses the rule when one falls outside +// it (ClusterResourcesSelectedFor), so the two check the same types. +func matchClusterRuleItem(records []typeset.TypeRecord, groups, versions, resources []string) []typeset.TypeRecord { + return matchFollowableRecords(records, groups, versions, resources, configv1alpha3.ResourceScopeCluster) +} + // matchFollowableRecords returns the followable records a rule selector names, applying // group/version/resource/scope semantics over the registry's already-followable set — so // a refused type (gvk-not-unique, denied-by-policy, verb-poor) simply never matches. It is @@ -576,14 +586,15 @@ func watchRuleFingerprint(rule rulestore.CompiledRule) string { } // clusterWatchRuleFingerprint hashes a compiled ClusterWatchRule. It carries no scope component: -// a ClusterWatchRule is cluster-scope-only, so there is no per-rule scope left that could change -// what it watches. +// a ClusterWatchRule is cluster-scope-only. It does carry the granted clusterResources, which the +// planner filters by (AllowsType): a recompile under a narrower grant must rebuild the plan. func clusterWatchRuleFingerprint(rule rulestore.CompiledClusterRule) string { var b strings.Builder - fmt.Fprintf(&b, "cwr|%s|created=%d|gt=%s/%s|dest=%s", + fmt.Fprintf(&b, "cwr|%s|created=%d|gt=%s/%s|dest=%s|grant=%s", rule.Source.Name, rule.CreatedAt.UnixNano(), rule.GitTargetNamespace, rule.GitTargetRef, - watchPlanDest(rule.GitBranchNamespace, rule.GitBranchRef, rule.Path)) + watchPlanDest(rule.GitBranchNamespace, rule.GitBranchRef, rule.Path), + strings.Join(rule.ClusterResources, ",")) for _, rr := range rule.Rules { fmt.Fprintf(&b, "|rr[g=%s;v=%s;r=%s;sel=%q]", strings.Join(rr.APIGroups, ","), strings.Join(rr.APIVersions, ","), diff --git a/internal/watch/watched_type_resolver_test.go b/internal/watch/watched_type_resolver_test.go index f5ea43209..a6d7bd5d0 100644 --- a/internal/watch/watched_type_resolver_test.go +++ b/internal/watch/watched_type_resolver_test.go @@ -38,7 +38,7 @@ func gitDestRef(name string) types.ResourceReference { func TestRefreshWatchedTypeTables_ClusterWatchRuleResolvesClusterWideType(t *testing.T) { manager, store := makeWatchedTypeManager(t) store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "namespaces"), + clusterRuleForResource("rule-1", "namespaces"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) @@ -79,7 +79,7 @@ func TestRefreshWatchedTypeTables_WatchRuleScopesTypeToItsNamespace(t *testing.T func TestRefreshWatchedTypeTables_RuleChangeReResolves(t *testing.T) { manager, store := makeWatchedTypeManager(t) store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "namespaces"), + clusterRuleForResource("rule-1", "namespaces"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) manager.refreshWatchedTypeTables() @@ -88,7 +88,7 @@ func TestRefreshWatchedTypeTables_RuleChangeReResolves(t *testing.T) { // A second rule selecting a different resource is reflected on the next refresh. store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-2", "nodes"), + clusterRuleForResource("rule-2", "nodes"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) manager.refreshWatchedTypeTables() @@ -108,7 +108,7 @@ func TestResolveWatchedTypeTables_NilRuleStoreIsEmpty(t *testing.T) { func TestRefreshWatchedTypeTables_NoChangeReusesResolvedTables(t *testing.T) { manager, store := makeWatchedTypeManager(t) store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "namespaces"), + clusterRuleForResource("rule-1", "namespaces"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) manager.refreshWatchedTypeTables() @@ -128,14 +128,14 @@ func TestRefreshWatchedTypeTables_NoChangeReusesResolvedTables(t *testing.T) { func TestRulesFingerprint_StableUntilRuleChanges(t *testing.T) { manager, store := makeWatchedTypeManager(t) store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "namespaces"), + clusterRuleForResource("rule-1", "namespaces"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) fp1 := manager.rulesFingerprint() assert.Equal(t, fp1, manager.rulesFingerprint(), "fingerprint must be stable for unchanged rules") store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-2", "nodes"), + clusterRuleForResource("rule-2", "nodes"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) assert.NotEqual(t, fp1, manager.rulesFingerprint(), "a new rule must move the fingerprint") @@ -146,7 +146,7 @@ func TestRefreshWatchedTypeTables_KeepsTargetWithUnresolvableRulesAsEmptyTable(t // "ghosts" is not served by the common catalog: the rule resolves to nothing, // but the GitTarget must still appear as an empty table, not vanish. store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "ghosts"), + clusterRuleForResource("rule-1", "ghosts"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) manager.refreshWatchedTypeTables() @@ -174,6 +174,7 @@ func TestRefreshWatchedTypeTables_ExcludesAmbiguousGVK(t *testing.T) { }}, }, }, + []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) diff --git a/internal/watch/watchrule_compile.go b/internal/watch/watchrule_compile.go index 4549e5f0b..e4131e668 100644 --- a/internal/watch/watchrule_compile.go +++ b/internal/watch/watchrule_compile.go @@ -5,7 +5,11 @@ package watch import ( "context" "fmt" + "slices" + "strconv" + "strings" + "k8s.io/apimachinery/pkg/runtime/schema" k8stypes "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" @@ -91,26 +95,31 @@ func itemNamespaces(resolved authz.ResolvedSourceScope) [][]string { return out } -// CompileClusterWatchRule is THE ONLY PATH from a ClusterWatchRule to a compiled cluster rule, and -// it is the compile-time half of the cluster-scope-only narrowing. +// CompileClusterWatchRule is THE ONLY PATH from a ClusterWatchRule to a compiled cluster rule. // // Two refusals, both terminal, in this order: // // 1. the referenced GitTarget's namespace must be admitted by that target's KubeProvider — a // ClusterWatchRule's gitTargetRef carries a namespace, so it can name a target in ANY namespace // and widen that target's mirror scope cluster-wide; -// 2. the rule must not carry a stored scope other than "Cluster". Admission rejects the value on -// write, but a pre-release object keeps it in etcd, and resolving it as if it had asked for -// cluster scope would silently change what a running rule mirrors. +// 2. every type the rule names must be in the clusterResources the grants that admit the target +// give it (clusterResourcesRefusal). One forbidden type refuses the whole rule. +// +// selected is what the planner selects for an item in the GitTarget's source cluster (see +// Manager.ClusterResourcesSelectedFor); nil, or a cluster not discovered yet, selects nothing. The +// compiled rule carries the granted clusterResources too, so the planner never watches a type the +// grant does not name, also one that discovery serves only after this compile. The rule reconciles +// again on its steady interval, and is refused then. // // Like CompileWatchRule it is shared by the reconciler and the startup bootstrap, so a restart -// cannot open an unauthorized or namespaced watch before the first reconcile can publish status. +// cannot open an unauthorized watch before the first reconcile can publish status. func CompileClusterWatchRule( ctx context.Context, reader client.Reader, store *rulestore.RuleStore, rule configv1alpha3.ClusterWatchRule, target configv1alpha3.GitTarget, + selected ClusterResourceSelection, ) (ClusterWatchRuleDecision, error) { key := k8stypes.NamespacedName{Name: rule.Name} @@ -119,7 +128,7 @@ func CompileClusterWatchRule( return ClusterWatchRuleDecision{}, &ObjectSelectorError{Message: err.Error()} } - admitted, err := authz.GitTargetAdmitted(ctx, reader, &target) + admitted, scope, err := authz.GitTargetSourceScope(ctx, reader, &target) if err != nil { // Transient: leave whatever is compiled alone and let the caller requeue. return ClusterWatchRuleDecision{}, err @@ -132,9 +141,22 @@ func CompileClusterWatchRule( target.Namespace, target.Name, admitted.Message), }, nil } + if forbidden := clusterResourcesRefusal(&rule, scope, selected); len(forbidden) > 0 { + store.DeleteClusterWatchRule(key) + return ClusterWatchRuleDecision{ + ResourcesRefused: true, + Reason: ClusterWatchRuleReasonClusterResourceNotAllowed, + Message: fmt.Sprintf( + "KubeProvider %s lets namespace %q mirror cluster-scoped resources %s "+ + "(spec.allowedGitTargets[].clusterResources), so the ClusterWatchRule is refused: %s", + target.KubeProviderKey(), target.Namespace, describeIdentities(scope.ClusterResources), + strings.Join(forbidden, "; ")), + }, nil + } store.AddOrUpdateClusterWatchRule( rule, + scope.ClusterResources, target.Name, target.Namespace, target.GitBranchKey().Name, target.GitBranchKey().Namespace, target.Spec.Path, @@ -146,9 +168,15 @@ func CompileClusterWatchRule( // bootstrap refuses on the same grounds without a controller in sight, and one vocabulary is what // keeps the two from drifting. const ( + // ClusterWatchRuleReasonClusterResourceNotAllowed is the terminal ResourcesResolved reason when + // the rule names a cluster-scoped type outside the clusterResources the KubeProvider's grants give + // the GitTarget's namespace, or "*" in apiGroups or resources without clusterResources: ["*"]. + ClusterWatchRuleReasonClusterResourceNotAllowed = "ClusterResourceNotAllowed" + // ClusterWatchRuleReasonGitTargetNamespaceNotAuthorized is the terminal reason when the // referenced GitTarget's namespace is not admitted by that target's KubeProvider — either - // because spec.accessFrom excludes it or because the provider does not exist at all. + // because no grant in its spec.allowedGitTargets admits it or because the provider does not + // exist at all. // // One rule-side reason covers both provider-side causes on purpose: from the ClusterWatchRule's // point of view the single fact that matters is that this rule may not compile against this @@ -160,6 +188,9 @@ const ( type ClusterWatchRuleDecision struct { // Admitted reports whether the rule compiled. Admitted bool + // ResourcesRefused reports a refusal of the rule's own resources rather than of its GitTarget, + // which lands on ResourcesResolved. + ResourcesRefused bool // Reason is the terminal condition reason when it did not. Reason string // Message explains the refusal to an operator. @@ -182,3 +213,64 @@ type ObjectSelectorError struct { } func (e *ObjectSelectorError) Error() string { return e.Message } + +// ClusterResourceSelection returns the cluster-scoped types the watch planner selects for a +// ClusterWatchRule item now. +type ClusterResourceSelection func(item configv1alpha3.ClusterResourceRule) []schema.GroupResource + +// clusterResourcesRefusal names what a ClusterWatchRule asks for outside scope's clusterResources. An +// item with "*" in apiGroups or resources needs clusterResources: ["*"], decided on the rule's TEXT as +// rules[].sourceNamespace: "*" is. Any other item asks for every pair its apiGroups and resources name, +// and for every type the planner selects for it, which is how an item that omits apiGroups is +// checked: by the planner's own matching, versions and ambiguity rule included. Under ["*"] nothing +// is refused. +func clusterResourcesRefusal( + rule *configv1alpha3.ClusterWatchRule, + scope configv1alpha3.KubeProviderGitTargetScope, + selected ClusterResourceSelection, +) []string { + if scope.AllowsAllClusterResources() { + return nil + } + var forbidden []string + for i := range rule.Spec.Rules { + item := rule.Spec.Rules[i] + if slices.Contains(item.APIGroups, "*") || slices.Contains(item.Resources, "*") { + forbidden = append(forbidden, fmt.Sprintf( + "spec.rules[%d] has \"*\" in apiGroups or resources, which needs clusterResources: [\"*\"]", i)) + continue + } + var asked []schema.GroupResource + for _, resource := range item.Resources { + for _, group := range item.APIGroups { + asked = append(asked, schema.GroupResource{Group: group, Resource: normalizeResource(resource)}) + } + } + if selected != nil { + asked = append(asked, selected(item)...) + } + var types []string + for _, gr := range asked { + identity := configv1alpha3.ClusterResourceIdentityFor(gr.Group, gr.Resource) + if !scope.AllowsClusterResource(gr.Group, gr.Resource) && !slices.Contains(types, identity) { + types = append(types, identity) + } + } + if len(types) > 0 { + forbidden = append(forbidden, fmt.Sprintf("spec.rules[%d] asks for %s", i, describeIdentities(types))) + } + } + return forbidden +} + +// describeIdentities renders cluster-resource identities for a message. +func describeIdentities(identities []string) string { + if len(identities) == 0 { + return "none" + } + quoted := make([]string, 0, len(identities)) + for _, identity := range identities { + quoted = append(quoted, strconv.Quote(identity)) + } + return strings.Join(quoted, ", ") +} diff --git a/internal/webhook/receiver_test.go b/internal/webhook/receiver_test.go index aa335ad9f..bea30a7ea 100644 --- a/internal/webhook/receiver_test.go +++ b/internal/webhook/receiver_test.go @@ -95,8 +95,13 @@ func testProvider(namespace, name, url string) *v1alpha3.GitProvider { return &v1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, Spec: v1alpha3.GitProviderSpec{ - URL: url, - AllowedBranches: []string{"*"}, + URL: url, + AllowedGitBranches: []v1alpha3.GitProviderGitBranchGrant{ + { + ConsumerNamespaces: v1alpha3.ConsumerNamespaces{From: v1alpha3.GrantFromSame}, + BranchPatterns: []v1alpha3.BranchPattern{"*"}, + }, + }, }, } } diff --git a/test/e2e/Taskfile-e2e.yml b/test/e2e/Taskfile-e2e.yml index ee0c08044..fa4d21a90 100644 --- a/test/e2e/Taskfile-e2e.yml +++ b/test/e2e/Taskfile-e2e.yml @@ -1268,6 +1268,7 @@ tasks: - charts/gitops-reverser/templates/** - charts/gitops-reverser/crds/*.yaml - charts/gitops-reverser/config/role.yaml + - test/e2e/helm-values-suite-grants.yaml generates: - '{{.CS}}/{{.NAMESPACE}}/helm/install.yaml' cmds: @@ -1292,6 +1293,7 @@ tasks: --set queue.redis.auth.existingSecret=valkey-auth \ --set queue.redis.auth.existingSecretKey=password \ --set service.clusterIP="{{.E2E_CONTROLLER_SERVICE_CLUSTER_IP}}" \ + --values test/e2e/helm-values-suite-grants.yaml \ ${skip_crds_arg} tmp_manifest="{{.CS}}/{{.NAMESPACE}}/helm/.install.yaml.tmp" {{.HELM}} --kube-context "{{.CTX}}" get manifest "{{.INSTALL_NAME}}" \ diff --git a/test/e2e/applier_claim.go b/test/e2e/applier_claim.go index da00603f0..42c22ebbc 100644 --- a/test/e2e/applier_claim.go +++ b/test/e2e/applier_claim.go @@ -56,17 +56,42 @@ spec: Expect(err).NotTo(HaveOccurred(), "failed to create ArgoCDClaim %s/%s", namespace, name) } -// allowCrossNamespaceAppliers lets the applier claims that reach their applier through the install's -// KubeProvider name one in another namespace, as the e2e appliers are: in flux-system and argocd, -// while each spec's applier claims live in its own namespace. Granting it is idempotent, and no spec +// grantAppliersInAnyNamespace lets the applier claims that reach their applier through the install's +// KubeProvider hold one in any namespace, as the e2e appliers are: in flux-system and argocd, while +// each spec's applier claims live in its own namespace. Granting it is idempotent, and no spec // relies on its absence on that provider, so parallel specs may all ask for it. -func allowCrossNamespaceAppliers() { +func grantAppliersInAnyNamespace() { GinkgoHelper() _, err := kubectlRunInNamespace(resolveE2ENamespace(), "patch", "kubeprovider", e2eKubeProviderName, - "--type=merge", "-p", `{"spec":{"allowCrossNamespace":{"appliers":true}}}`) + "--type=merge", "-p", + `{"spec":{"allowedApplierClaims":[{"consumerNamespaces":{"from":"All"},"applierNamespaces":["*"]}]}}`) Expect(err).NotTo(HaveOccurred(), "failed to let the install KubeProvider's applier claims reach other namespaces") } +// suiteKubeProviderGrants are the grants the suite needs on the install's KubeProvider: every +// namespace mirrors its own namespace and any cluster-scoped resource, and holds an applier in its +// own namespace. config/kubeprovider-local.yaml carries them, and the Helm install passes them as +// values (test/e2e/helm-values-suite-grants.yaml); the chart grants nothing by default, so the plain +// manifests need them patched in. On a config-dir install the patch restates what the manifest +// already carries. +const suiteKubeProviderGrants = `{"spec":{` + + `"allowedGitTargets":[{"consumerNamespaces":{"from":"All"},` + + `"sourceNamespaces":["{consumer.namespace}"],"clusterResources":["*"]}],` + + `"allowedApplierClaims":[{"consumerNamespaces":{"from":"All"},"applierNamespaces":["{consumer.namespace}"]}]}}` + +// grantSuiteAccess gives the install's KubeProvider the grants the suite needs. A Helm install already +// carries them as values, and a patch there would take the fields from Helm, whose next server-side +// upgrade then refuses the conflict. +func grantSuiteAccess() { + GinkgoHelper() + if mode, err := resolveE2EInstallMode(); err == nil && mode == "helm" { + return + } + _, err := kubectlRunInNamespace(resolveE2ENamespace(), "patch", "kubeprovider", e2eKubeProviderName, + "--type=merge", "-p", suiteKubeProviderGrants) + Expect(err).NotTo(HaveOccurred(), "failed to grant the suite's access on the install KubeProvider") +} + // nameApplierClaim points a GitTarget at the applier claim of kind (FluxClaim or ArgoCDClaim) and // name, and sets onRefusal when it is not empty. func nameApplierClaim(namespace, gitTarget, kind, claim, onRefusal string) { diff --git a/test/e2e/argocd_bi_directional_e2e_test.go b/test/e2e/argocd_bi_directional_e2e_test.go index 12597b230..ecfb7a15c 100644 --- a/test/e2e/argocd_bi_directional_e2e_test.go +++ b/test/e2e/argocd_bi_directional_e2e_test.go @@ -115,7 +115,7 @@ var _ = Describe("Bi Directional (Argo CD)", Label("bi-directional", "argocd"), BeforeAll(func() { skipUnlessBiDirectionalEnabled() requireArgoCDInstalled() - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() By("creating test namespace") testNs = testNamespaceFor("argo-bi-directional") @@ -307,7 +307,7 @@ var _ = Describe("Bi Directional (Argo CD)", Label("bi-directional", "argocd"), g.Expect(claimEventMessages(g, testNs, "ArgoCDClaim", run.appName, "ClaimedResourceReconciled")). To(ContainSubstring("was switched on outside GitOps Reverser, which switched it off again")) }, biEventuallyTimeout, biPollInterval).Should(Succeed()) - verifyResourceCondition(argoCDClaimResource, run.appName, testNs, "Held", "True", "AutomatedSyncOff", + verifyResourceCondition(argoCDClaimResource, run.appName, testNs, "InControl", "True", "AutomatedSyncOff", "held off by GitOps Reverser") Expect(run.applicationManagers()).To(ContainElement(argoReverserInitiator), "the switch is held under GitOps Reverser's field manager") @@ -376,7 +376,7 @@ var _ = Describe("Bi Directional (Argo CD)", Label("bi-directional", "argocd"), g.Expect(strings.TrimSpace(enabled)).To(Equal("false")) }, argoSyncTimeout, biPollInterval).Should(Succeed()) requestClaimReconcile(testNs, argoCDClaimResource, run.appSetClaimName) - verifyResourceCondition(argoCDClaimResource, run.appSetClaimName, testNs, "Held", "True", + verifyResourceCondition(argoCDClaimResource, run.appSetClaimName, testNs, "InControl", "True", "AutomatedSyncOff", "kept off by ApplicationSet "+run.argoNs+"/"+run.appSetName) verifyResourceCondition("gittarget", run.appSetTargetName, testNs, "ApplierClaimReady", "True", "Succeeded", "") @@ -405,7 +405,7 @@ var _ = Describe("Bi Directional (Argo CD) refusal", Label("bi-directional", "ar BeforeAll(func() { skipUnlessBiDirectionalEnabled() requireArgoCDInstalled() - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() testNs = testNamespaceFor("argo-refusal") _, _ = kubectlRun("create", "namespace", testNs) diff --git a/test/e2e/audit_route_attribution_e2e_test.go b/test/e2e/audit_route_attribution_e2e_test.go index e71f08400..2cd252c73 100644 --- a/test/e2e/audit_route_attribution_e2e_test.go +++ b/test/e2e/audit_route_attribution_e2e_test.go @@ -247,15 +247,22 @@ func sameClusterKubeConfig() string { } // applyKubeProviderWithAuditRoute applies a KubeProvider in namespace reaching its cluster through -// the kubeconfig Secret secretName in that namespace, admitting the namespaces listed, and declaring -// auditRoute when it is not empty. The route is what lets several providers on one cluster share -// the single stream that cluster's apiserver posts. +// the kubeconfig Secret secretName in that namespace, letting its own namespace and the namespaces +// listed mirror their own namespace (every namespace when wide), and declaring auditRoute when it is +// not empty. The route is what lets several providers on one cluster share the single stream that +// cluster's apiserver posts. func applyKubeProviderWithAuditRoute( - namespace, name, secretName string, admits []string, delegate bool, auditRoute string, + namespace, name, secretName string, admits []string, wide bool, auditRoute string, ) (string, error) { - extra := "" + scope := `["{consumer.namespace}"]` + if wide { + scope = `["{consumer.namespace}", "*"]` + } + extra := fmt.Sprintf( + "\n allowedGitTargets:\n - consumerNamespaces: {from: Same}\n sourceNamespaces: %s", scope) if len(admits) > 0 { - extra += fmt.Sprintf("\n accessFrom:\n names: [%s]", strings.Join(admits, ", ")) + extra += fmt.Sprintf("\n - consumerNamespaces: {from: Names, names: [%s]}\n sourceNamespaces: %s", + strings.Join(admits, ", "), scope) } if auditRoute != "" { extra += fmt.Sprintf("\n attribution:\n auditRoute: %s", auditRoute) @@ -267,16 +274,14 @@ metadata: namespace: %s spec: secretRef: - name: %s - allowCrossNamespace: - sources: %t%s -`, name, namespace, secretName, delegate, extra) + name: %s%s +`, name, namespace, secretName, extra) return kubectlRunWithStdin(namespace, manifest, "apply", "-f", "-") } // applyGitTargetForKubeProvider applies a GitTarget that mirrors through the KubeProvider // providerNS/provider. Which SOURCE namespaces may reach it is bounded by that provider's -// spec.allowCrossNamespace.sources plus the source credential's own RBAC, not by any field here. +// allowedGitTargets[].sourceNamespaces plus the source credential's own RBAC, not by any field here. func applyGitTargetForKubeProvider( ns, name, gitProvider, targetPath, providerNS, provider string, ) (string, error) { diff --git a/test/e2e/bi_directional_echoes_e2e_test.go b/test/e2e/bi_directional_echoes_e2e_test.go index 57a885f2c..c65c1f639 100644 --- a/test/e2e/bi_directional_echoes_e2e_test.go +++ b/test/e2e/bi_directional_echoes_e2e_test.go @@ -426,7 +426,7 @@ var _ = Describe("Bi Directional applier echoes (Flux)", Label("bi-directional", BeforeAll(func() { skipUnlessBiDirectionalEnabled() - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() testNs = testNamespaceFor("echoes-flux") _, _ = kubectlRun("create", "namespace", testNs) fluxName = "echoes-" + testNs @@ -440,7 +440,7 @@ var _ = Describe("Bi Directional applier echoes (Flux)", Label("bi-directional", nameApplierClaim(testNs, pipeline.targetName, "FluxClaim", fluxName, "") verifyResourceCondition("gittarget", pipeline.targetName, testNs, "ApplierClaimReady", "True", "Succeeded", "") - verifyResourceCondition(fluxClaimResource, fluxName, testNs, "Held", "True", "Suspended", "") + verifyResourceCondition(fluxClaimResource, fluxName, testNs, "InControl", "True", "Suspended", "") r = &echoRun{timingPipeline: pipeline, applier: fluxEchoApplier(testNs, fluxName, repo)} }) @@ -471,7 +471,7 @@ var _ = Describe("Bi Directional applier echoes (Argo CD)", Label("bi-directiona BeforeAll(func() { skipUnlessBiDirectionalEnabled() requireArgoCDInstalled() - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() testNs = testNamespaceFor("echoes-argo") _, _ = kubectlRun("create", "namespace", testNs) diff --git a/test/e2e/bi_directional_push_race_e2e_test.go b/test/e2e/bi_directional_push_race_e2e_test.go index d7b7efa90..10758dea9 100644 --- a/test/e2e/bi_directional_push_race_e2e_test.go +++ b/test/e2e/bi_directional_push_race_e2e_test.go @@ -73,7 +73,7 @@ var _ = Describe("Bi Directional push race (Flux)", Label("bi-directional", "flu BeforeAll(func() { skipUnlessBiDirectionalEnabled() - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() testNs = testNamespaceFor("push-race-flux") _, _ = kubectlRun("create", "namespace", testNs) fluxName = "push-race-" + testNs @@ -87,7 +87,7 @@ var _ = Describe("Bi Directional push race (Flux)", Label("bi-directional", "flu nameApplierClaim(testNs, pipeline.targetName, "FluxClaim", fluxName, "") verifyResourceCondition("gittarget", pipeline.targetName, testNs, "ApplierClaimReady", "True", "Succeeded", "") - verifyResourceCondition(fluxClaimResource, fluxName, testNs, "Held", "True", "Suspended", "") + verifyResourceCondition(fluxClaimResource, fluxName, testNs, "InControl", "True", "Suspended", "") }) AfterAll(func() { @@ -120,7 +120,7 @@ var _ = Describe("Bi Directional push race (Argo CD)", Label("bi-directional", " BeforeAll(func() { skipUnlessBiDirectionalEnabled() requireArgoCDInstalled() - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() testNs = testNamespaceFor("push-race-argo") _, _ = kubectlRun("create", "namespace", testNs) diff --git a/test/e2e/bi_directional_restart_e2e_test.go b/test/e2e/bi_directional_restart_e2e_test.go index bf010a481..e00719f4c 100644 --- a/test/e2e/bi_directional_restart_e2e_test.go +++ b/test/e2e/bi_directional_restart_e2e_test.go @@ -93,7 +93,7 @@ var _ = Describe("Bi Directional restart with work made while down (Flux)", BeforeAll(func() { skipUnlessBiDirectionalEnabled() - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() testNs = testNamespaceFor("echoes-restart-flux") _, _ = kubectlRun("create", "namespace", testNs) fluxName = "echoes-restart-" + testNs @@ -138,7 +138,7 @@ var _ = Describe("Bi Directional restart with work made while down (Argo CD)", BeforeAll(func() { skipUnlessBiDirectionalEnabled() requireArgoCDInstalled() - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() testNs = testNamespaceFor("echoes-restart-argo") _, _ = kubectlRun("create", "namespace", testNs) diff --git a/test/e2e/bi_directional_timing_e2e_test.go b/test/e2e/bi_directional_timing_e2e_test.go index 381717f19..e85f35585 100644 --- a/test/e2e/bi_directional_timing_e2e_test.go +++ b/test/e2e/bi_directional_timing_e2e_test.go @@ -254,7 +254,7 @@ var _ = Describe("Bi Directional timing (Flux)", Label("bi-directional", "flux", BeforeAll(func() { skipUnlessBiDirectionalEnabled() - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() testNs = testNamespaceFor("bi-timing-flux") _, _ = kubectlRun("create", "namespace", testNs) fluxName = "bi-timing-" + testNs @@ -269,7 +269,7 @@ var _ = Describe("Bi Directional timing (Flux)", Label("bi-directional", "flux", nameApplierClaim(testNs, pipeline.targetName, "FluxClaim", fluxName, "") verifyResourceCondition("gittarget", pipeline.targetName, testNs, "ApplierClaimReady", "True", "Succeeded", "") - verifyResourceCondition(fluxClaimResource, fluxName, testNs, "Held", "True", "Suspended", "") + verifyResourceCondition(fluxClaimResource, fluxName, testNs, "InControl", "True", "Suspended", "") }) AfterAll(func() { @@ -338,7 +338,7 @@ var _ = Describe("Bi Directional timing (Argo CD)", Label("bi-directional", "arg BeforeAll(func() { skipUnlessBiDirectionalEnabled() requireArgoCDInstalled() - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() testNs = testNamespaceFor("bi-timing-argo") _, _ = kubectlRun("create", "namespace", testNs) diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go index fc148c309..a9031f130 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/e2e_suite_test.go @@ -3,7 +3,6 @@ package e2e import ( - "errors" "fmt" "math" "os" @@ -64,6 +63,7 @@ var _ = SynchronizedBeforeSuite(func() []byte { By("preparing e2e cluster prerequisites via Task target") prepareE2EClusterOnce() waitForPreviousRunNamespaces() + grantSuiteAccess() // After prepare, while this process still holds the exclusive cluster lock, so every spec runs // against a Prometheus with no retained samples and a controller whose counters start at zero. @@ -555,13 +555,6 @@ func resolveE2EInstallName(namespace string) string { return namespace } -func resolveE2EInstallMode() (string, error) { - if value := strings.TrimSpace(os.Getenv("INSTALL_MODE")); value != "" { - return value, nil - } - return "", errors.New("INSTALL_MODE environment variable must be set") -} - func resolveE2EContext() string { if value := strings.TrimSpace(os.Getenv("CTX")); value != "" { return value diff --git a/test/e2e/flux_applier_e2e_test.go b/test/e2e/flux_applier_e2e_test.go index 7bf794101..7534ca928 100644 --- a/test/e2e/flux_applier_e2e_test.go +++ b/test/e2e/flux_applier_e2e_test.go @@ -39,7 +39,7 @@ var _ = Describe("Manager Flux Applier", Label("manager", "flux-applier"), Order ) BeforeAll(func() { - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() testNs = testNamespaceFor("manager-flux-applier") appliedNs = testNs + "-applied" fluxName = "flux-applier-" + testNs @@ -91,7 +91,7 @@ var _ = Describe("Manager Flux Applier", Label("manager", "flux-applier"), Order createGitTarget(destName, testNs, providerName, gitPath, "main") createFluxClaim(testNs, fluxName, fluxName) verifyResourceCondition(fluxClaimResource, fluxName, testNs, "Ready", "True", "NotReferenced", "") - verifyResourceCondition(fluxClaimResource, fluxName, testNs, "Held", "False", "NotReferenced", "") + verifyResourceCondition(fluxClaimResource, fluxName, testNs, "InControl", "False", "NotReferenced", "") Expect(kustomizationSuspended(Default, fluxName)).To(BeFalse(), "Flux applies it on its own") By("naming it from a GitTarget that writes main: the applier claim is refused, and holds nothing") @@ -154,18 +154,20 @@ var _ = Describe("Manager Flux Applier", Label("manager", "flux-applier"), Order g.Expect(fluxLastAppliedRevision(g, fluxName)).To(ContainSubstring(head)) g.Expect(appliedColor(g, appliedNs)).To(Equal("green")) }, 5*time.Minute, 5*time.Second).Should(Succeed()) - verifyResourceCondition(fluxClaimResource, fluxName, testNs, "Held", "True", "Suspended", "") + verifyResourceCondition(fluxClaimResource, fluxName, testNs, "InControl", "True", "Suspended", "") Expect(kustomizationSuspended(Default, fluxName)).To(BeTrue(), "the applier claim holds its Kustomization") - others, err := kubectlRunInNamespace(testNs, "get", fluxClaimResource, fluxName, - "-o", "jsonpath={.status.source.otherConsumers}") + alsoAppliedBy, err := kubectlRunInNamespace(testNs, "get", fluxClaimResource, fluxName, + "-o", "jsonpath={.status.source.alsoAppliedBy}") Expect(err).NotTo(HaveOccurred()) - Expect(strings.TrimSpace(others)).To(BeEmpty(), "its Kustomization is the GitRepository's only consumer") + Expect( + strings.TrimSpace(alsoAppliedBy), + ).To(BeEmpty(), "nothing but its Kustomization applies the GitRepository") By("dropping the reference: the applier claim hands the Kustomization back, and is idle again") _, err = kubectlRunInNamespace(testNs, "patch", "gittarget", destName, "--type=merge", "-p", `{"spec":{"applierClaimRef":null}}`) Expect(err).NotTo(HaveOccurred(), "failed to drop the GitTarget's applierClaimRef") - verifyResourceCondition(fluxClaimResource, fluxName, testNs, "Held", "False", "NotReferenced", "") + verifyResourceCondition(fluxClaimResource, fluxName, testNs, "InControl", "False", "NotReferenced", "") verifyResourceCondition(fluxClaimResource, fluxName, testNs, "Ready", "True", "NotReferenced", "") Eventually(func(g Gomega) { g.Expect(kustomizationSuspended(g, fluxName)).To(BeFalse()) @@ -176,7 +178,7 @@ var _ = Describe("Manager Flux Applier", Label("manager", "flux-applier"), Order By("naming the applier claim again: it adopts the Kustomization anew") nameApplierClaim(testNs, destName, "FluxClaim", fluxName, "") - verifyResourceCondition(fluxClaimResource, fluxName, testNs, "Held", "True", "Suspended", "") + verifyResourceCondition(fluxClaimResource, fluxName, testNs, "InControl", "True", "Suspended", "") Expect(kustomizationSuspended(Default, fluxName)).To(BeTrue()) }) @@ -290,7 +292,7 @@ data: To(ContainSubstring("was resumed outside GitOps Reverser")) g.Expect(kustomizationSuspended(g, fluxName)).To(BeTrue(), "suspended again") }, 2*time.Minute, 2*time.Second).Should(Succeed()) - verifyResourceCondition(fluxClaimResource, fluxName, testNs, "Held", "True", "Suspended", "") + verifyResourceCondition(fluxClaimResource, fluxName, testNs, "InControl", "True", "Suspended", "") }) // Deleting the applier claim hands the Kustomization back: its finalizer waits until nothing is diff --git a/test/e2e/flux_bi_directional_e2e_test.go b/test/e2e/flux_bi_directional_e2e_test.go index 4e645fb9b..4f561eb97 100644 --- a/test/e2e/flux_bi_directional_e2e_test.go +++ b/test/e2e/flux_bi_directional_e2e_test.go @@ -96,7 +96,7 @@ var _ = Describe("Bi Directional (Flux)", Label("bi-directional", "flux"), Order BeforeAll(func() { skipUnlessBiDirectionalEnabled() - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() By("creating test namespace") testNs = testNamespaceFor("bi-directional") diff --git a/test/e2e/flux_restart_recovery_e2e_test.go b/test/e2e/flux_restart_recovery_e2e_test.go index eec7d0bdd..27facff82 100644 --- a/test/e2e/flux_restart_recovery_e2e_test.go +++ b/test/e2e/flux_restart_recovery_e2e_test.go @@ -39,7 +39,7 @@ var _ = Describe("Flux Restart Recovery", Label("flux-restart-recovery"), Serial ) BeforeAll(func() { - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() testNs = testNamespaceFor("flux-restart-recovery") appliedNs = testNs + "-applied" fluxName = "flux-recovery-" + testNs diff --git a/test/e2e/gitprovider_validation_e2e_test.go b/test/e2e/gitprovider_validation_e2e_test.go index 5c2ddc24c..c1bf7c29d 100644 --- a/test/e2e/gitprovider_validation_e2e_test.go +++ b/test/e2e/gitprovider_validation_e2e_test.go @@ -100,8 +100,8 @@ var _ = Describe("Manager GitProvider Validation", Label("manager"), Ordered, fu By("verifying the GitBranch fails branch validation") verifyResourceStatus("gitbranch", gitBranchName(gitProviderName, "different-branch"), testNs, - "False", "BranchNotAllowed", "allowedBranches") - verifyResourceStatus("gittarget", destName, testNs, "False", "GitBranchNotReady", "allowedBranches") + "False", "BranchNotAllowed", `requested branch "different-branch" is not allowed`) + verifyResourceStatus("gittarget", destName, testNs, "False", "GitBranchNotReady", "spec.allowedGitBranches") cleanupGitTarget(destName, testNs) deleteGitProvider(testNs, gitProviderName) diff --git a/test/e2e/helm-values-suite-grants.yaml b/test/e2e/helm-values-suite-grants.yaml new file mode 100644 index 000000000..a542e767d --- /dev/null +++ b/test/e2e/helm-values-suite-grants.yaml @@ -0,0 +1,13 @@ +# The grants the e2e suite needs on the chart's `local` KubeProvider, which grants nothing by default: +# every namespace mirrors its own namespace and any cluster-scoped resource, and holds an applier in +# its own namespace. The Helm install passes them as values, so Helm owns the fields and a later +# upgrade (the quickstart spec, the image-refresh spec) keeps them. The other install modes patch the +# same grants in the suite's setup (grantSuiteAccess); config/kubeprovider-local.yaml carries them. +kubeProvider: + allowedGitTargets: + - consumerNamespaces: {from: All} + sourceNamespaces: ["{consumer.namespace}"] + clusterResources: ["*"] + allowedApplierClaims: + - consumerNamespaces: {from: All} + applierNamespaces: ["{consumer.namespace}"] diff --git a/test/e2e/helpers.go b/test/e2e/helpers.go index 61d57974b..dcd63a2be 100644 --- a/test/e2e/helpers.go +++ b/test/e2e/helpers.go @@ -220,7 +220,8 @@ func deleteGitProvider(namespace, name string) { // gitBranchManifest is the GitBranch gitBranchName(providerName, "main") in namespace, as a YAML // document followed by a separator, for a spec that applies its GitTarget as an inline manifest. -func gitBranchManifest(namespace, providerName string) string { +// GitTargets in its own namespace may write through it, and in sharedWith. +func gitBranchManifest(namespace, providerName string, sharedWith ...string) string { const branch = "main" return fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 kind: GitBranch @@ -233,8 +234,20 @@ spec: gitProviderRef: name: %s branch: %s + allowedGitTargets:%s --- -`, gitBranchName(providerName, branch), namespace, gitBranchProviderLabel, providerName, providerName, branch) +`, gitBranchName(providerName, branch), namespace, gitBranchProviderLabel, providerName, providerName, branch, + gitBranchWritersYAML(sharedWith...)) +} + +// gitBranchWritersYAML is the entries of a GitBranch's allowedGitTargets, each on its own line and +// indented under that key: its own namespace, and the namespaces in sharedWith. +func gitBranchWritersYAML(sharedWith ...string) string { + writers := "\n - consumerNamespaces: {from: Same}" + if len(sharedWith) > 0 { + writers += fmt.Sprintf("\n - consumerNamespaces: {from: Names, names: [%s]}", strings.Join(sharedWith, ", ")) + } + return writers } // createGitTarget creates a GitTarget on the GitBranch of a GitProvider's branch, creating that diff --git a/test/e2e/namespace.go b/test/e2e/namespace.go index f71c91697..312025188 100644 --- a/test/e2e/namespace.go +++ b/test/e2e/namespace.go @@ -3,6 +3,7 @@ package e2e import ( + "errors" "fmt" "os" "strings" @@ -19,6 +20,13 @@ func resolveE2ENamespace() string { return defaultE2ENamespace } +func resolveE2EInstallMode() (string, error) { + if value := strings.TrimSpace(os.Getenv("INSTALL_MODE")); value != "" { + return value, nil + } + return "", errors.New("INSTALL_MODE environment variable must be set") +} + // testNamespaceFor returns the test namespace for a given suite label. // The name is scoped to the current Ginkgo run via the random seed, // tying it to the repo and other per-run resources. diff --git a/test/e2e/new_branch_parent_e2e_test.go b/test/e2e/new_branch_parent_e2e_test.go index 9b03bec06..307831ea6 100644 --- a/test/e2e/new_branch_parent_e2e_test.go +++ b/test/e2e/new_branch_parent_e2e_test.go @@ -78,7 +78,9 @@ metadata: namespace: %s spec: url: %s - allowedBranches: ["main", %q] + allowedGitBranches: + - consumerNamespaces: {from: Same} + branchPatterns: ["main", %q] secretRef: name: %s `, providerName, namespace, repo.RepoURLHTTP, writeBranch, repo.GitSecretHTTP) diff --git a/test/e2e/provider_access_e2e_test.go b/test/e2e/provider_access_e2e_test.go new file mode 100644 index 000000000..fec191091 --- /dev/null +++ b/test/e2e/provider_access_e2e_test.go @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "fmt" + "path" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Access between objects (docs/configuration.md): a platform keeps one repository credential and +// lets labelled tenant namespaces claim branches under their own prefix (GitProvider +// spec.allowedGitBranches). A tenant's GitBranch decides who writes it (spec.allowedGitTargets), so +// the tenant shares it with another team without asking the platform. Claims on main, and on +// another team's prefix, are refused with messages that name the grant. +var _ = Describe("Manager branch grants", Label("manager"), Ordered, func() { + const ( + providerName = "shared-repo" + tenantLabel = "e2e.configbutler.ai/tenant" + ) + + var ( + platformNs, teamA, teamB string + repo *RepoArtifacts + ) + + teamBranch := func(team string) string { return "teams/" + team + "/live" } + + BeforeAll(func() { + platformNs = testNamespaceFor("grants-platform") + teamA = testNamespaceFor("grants-team-a") + teamB = testNamespaceFor("grants-team-b") + for _, ns := range []string{platformNs, teamA, teamB} { + _, _ = kubectlRun("create", "namespace", ns) // idempotent; ignore AlreadyExists + } + for _, ns := range []string{teamA, teamB} { + _, err := kubectlRun("label", "namespace", ns, tenantLabel+"=true", "--overwrite") + Expect(err).NotTo(HaveOccurred()) + } + + By("setting up the repository, with its credential in the platform namespace only") + repo = SetupRepo(resolveE2EContext(), platformNs, fmt.Sprintf("e2e-branch-grants-%d", GinkgoRandomSeed())) + _, err := kubectlRunInNamespace(platformNs, "apply", "-f", repo.SecretsYAML) + Expect(err).NotTo(HaveOccurred(), "failed to apply git secrets to the platform namespace") + + By("creating the platform's GitProvider: main for itself, teams/{consumer.namespace}/* for tenants") + _, err = kubectlRunWithStdin("", fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 +kind: GitProvider +metadata: + name: %s + namespace: %s +spec: + url: %s + secretRef: + name: %s + allowedGitBranches: + - consumerNamespaces: {from: Same} + branchPatterns: [main] + - consumerNamespaces: + from: Selector + selector: + matchLabels: + %s: "true" + branchPatterns: ["teams/{consumer.namespace}/*"] +`, providerName, platformNs, repo.RepoURLHTTP, repo.GitSecretHTTP, tenantLabel), "apply", "-f", "-") + Expect(err).NotTo(HaveOccurred()) + verifyResourceStatus("gitprovider", providerName, platformNs, + "True", "Succeeded", "Repository connectivity validated") + }) + + AfterAll(func() { + for _, ns := range []string{teamA, teamB, platformNs} { + cleanupNamespace(ns) + } + }) + + SetDefaultEventuallyTimeout(90 * time.Second) + SetDefaultEventuallyPollingInterval(2 * time.Second) + + // applyTenantGitBranch applies a GitBranch in namespace claiming branch through the platform's + // GitProvider, written by its own namespace and the namespaces in sharedWith. + applyTenantGitBranch := func(name, namespace, branch string, sharedWith ...string) { + GinkgoHelper() + _, err := kubectlRunWithStdin("", fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 +kind: GitBranch +metadata: + name: %s + namespace: %s +spec: + gitProviderRef: + name: %s + namespace: %s + branch: %s + allowedGitTargets:%s +`, name, namespace, providerName, platformNs, branch, gitBranchWritersYAML(sharedWith...)), "apply", "-f", "-") + Expect(err).NotTo(HaveOccurred(), "failed to apply GitBranch %s/%s", namespace, name) + } + + // expectFileOnBranch waits until file is in the tip of branch on the remote. + expectFileOnBranch := func(branch, file string) { + GinkgoHelper() + Eventually(func(g Gomega) { + _, err := gitRun(repo.CheckoutDir, "fetch", "origin", + "+refs/heads/"+branch+":refs/remotes/origin/"+branch) + g.Expect(err).NotTo(HaveOccurred()) + _, err = gitRun(repo.CheckoutDir, "cat-file", "-e", "origin/"+branch+":"+file) + g.Expect(err).NotTo(HaveOccurred(), "%s is on branch %s", file, branch) + }).Should(Succeed()) + } + + It("lets a tenant claim a branch under its own prefix and write it", func() { + applyTenantGitBranch("live", teamA, teamBranch(teamA)) + verifyResourceStatus("gitbranch", "live", teamA, "True", "Succeeded", "holds") + + applySharedGitTarget("apps", teamA, "live", "", "team-a/apps") + verifyResourceCondition("gittarget", "apps", teamA, "Validated", "True", "Succeeded", "") + applyIsolationWatchRule("apps-configmaps", teamA, "apps", `"configmaps"`) + waitForStreamsRunning("apps", teamA) + applyIsolationConfigMap("grants-cm", teamA) + + expectFileOnBranch(teamBranch(teamA), path.Join("team-a/apps", teamA, "configmaps", "grants-cm.yaml")) + + out, err := kubectlRunInNamespace(teamA, "get", "secrets", "-o", "name") + Expect(err).NotTo(HaveOccurred()) + Expect(out).NotTo(ContainSubstring(repo.GitSecretHTTP), "the tenant namespace holds no copy of the credential") + }) + + It("refuses a tenant's claims on main and on another team's branch", func() { + applyTenantGitBranch("main", teamA, "main") + verifyResourceCondition("gitbranch", "main", teamA, "Stalled", "True", "BranchNotAllowed", + `requested branch "main" is not allowed`) + verifyResourceCondition("gitbranch", "main", teamA, "Ready", "False", "BranchNotAllowed", + fmt.Sprintf(`namespace %q may claim "teams/%s/*"`, teamA, teamA)) + + applyTenantGitBranch("other", teamA, teamBranch(teamB)) + verifyResourceCondition("gitbranch", "other", teamA, "Stalled", "True", "BranchNotAllowed", + fmt.Sprintf(`requested branch %q is not allowed`, teamBranch(teamB))) + }) + + It("lets the tenant share its branch with a second team, without the platform", func() { + applySharedGitTarget("shared", teamB, "live", teamA, "team-b/apps") + verifyResourceCondition("gittarget", "shared", teamB, "Validated", "False", "NamespaceNotAuthorized", + fmt.Sprintf("GitBranch %s/live admits %s in spec.allowedGitTargets", teamA, teamA)) + + By("team A adds a from: Names grant for team B; the GitProvider does not change") + applyTenantGitBranch("live", teamA, teamBranch(teamA), teamB) + verifyResourceCondition("gittarget", "shared", teamB, "Validated", "True", "Succeeded", "") + applyIsolationWatchRule("shared-configmaps", teamB, "shared", `"configmaps"`) + waitForStreamsRunning("shared", teamB) + applyIsolationConfigMap("shared-cm", teamB) + + expectFileOnBranch(teamBranch(teamA), path.Join("team-b/apps", teamB, "configmaps", "shared-cm.yaml")) + }) +}) diff --git a/test/e2e/refusal_reapply_e2e_test.go b/test/e2e/refusal_reapply_e2e_test.go index d7a6ec01e..1eb1e9225 100644 --- a/test/e2e/refusal_reapply_e2e_test.go +++ b/test/e2e/refusal_reapply_e2e_test.go @@ -45,7 +45,7 @@ var _ = Describe("Manager Refusal Reapply", Label("manager", "refusal-reapply"), ) BeforeAll(func() { - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() By("creating the test namespace") testNs = testNamespaceFor("manager-refusal-reapply") _, _ = kubectlRun("create", "namespace", testNs) diff --git a/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml b/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml index 402fac77a..e0ab732cf 100644 --- a/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml +++ b/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml @@ -4,9 +4,9 @@ metadata: name: demo spec: url: http://gitea-http.gitea-e2e.svc.cluster.local:13000/testorg/demo.git - allowedBranches: - - main - - nl-stuff + allowedGitBranches: + - consumerNamespaces: {from: Same} + branchPatterns: [main, nl-stuff] secretRef: name: git-creds-demo --- @@ -18,6 +18,8 @@ spec: gitProviderRef: name: demo branch: nl-stuff + allowedGitTargets: + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/e2e/setup/demo-only/voter-gitops/test/coffeeconfig-reverse-gitops.yaml b/test/e2e/setup/demo-only/voter-gitops/test/coffeeconfig-reverse-gitops.yaml index 19b4da223..37c5e600b 100644 --- a/test/e2e/setup/demo-only/voter-gitops/test/coffeeconfig-reverse-gitops.yaml +++ b/test/e2e/setup/demo-only/voter-gitops/test/coffeeconfig-reverse-gitops.yaml @@ -4,9 +4,9 @@ metadata: name: demo-coffeeconfig spec: url: http://gitea-http.gitea-e2e.svc.cluster.local:13000/testorg/demo.git - allowedBranches: - - main - - demo-test + allowedGitBranches: + - consumerNamespaces: {from: Same} + branchPatterns: [main, demo-test] secretRef: name: git-creds-demo --- @@ -18,6 +18,8 @@ spec: gitProviderRef: name: demo-coffeeconfig branch: demo-test + allowedGitTargets: + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/e2e/shared_gitbranch_e2e_test.go b/test/e2e/shared_gitbranch_e2e_test.go index dc52a0f33..7c7c429b3 100644 --- a/test/e2e/shared_gitbranch_e2e_test.go +++ b/test/e2e/shared_gitbranch_e2e_test.go @@ -17,8 +17,8 @@ import ( // One GitBranch per repository branch, shared across namespaces (docs/architecture.md, // "GitBranch"). A GitBranch claims its branch once its GitProvider's credential opens a push // session, and holds it for every GitTarget that references it: those in other namespaces are -// admitted by the GitProvider's spec.accessFrom and write with its credential, so the tenant -// namespace holds no copy of it. A second GitBranch for the same repository branch, however it +// admitted by the GitBranch's spec.allowedGitTargets and write with its GitProvider's credential, so +// the tenant namespace holds no copy of it. A second GitBranch for the same repository branch, however it // spells the repository, holds nothing and reports BranchConflict; one with a bad credential // claims nothing at all, so creating it first blocks nobody. var _ = Describe("Manager shared GitBranch", Label("manager"), Ordered, func() { @@ -45,11 +45,11 @@ var _ = Describe("Manager shared GitBranch", Label("manager"), Ordered, func() { _, err := kubectlRunInNamespace(platformNs, "apply", "-f", repo.SecretsYAML) Expect(err).NotTo(HaveOccurred(), "failed to apply git secrets to the platform namespace") - By("creating the GitProvider, admitting the tenant namespace, and its GitBranch") - applySharedGitProvider(providerName, platformNs, repo.RepoURLHTTP, repo.GitSecretHTTP, tenantNs) + By("creating the GitProvider, and its GitBranch admitting the tenant namespace") + applySharedGitProvider(providerName, platformNs, repo.RepoURLHTTP, repo.GitSecretHTTP) verifyResourceStatus("gitprovider", providerName, platformNs, "True", "Succeeded", "Repository connectivity validated") - applySharedGitBranch(branchName, platformNs, providerName, "main") + applySharedGitBranch(branchName, platformNs, providerName, "main", tenantNs) verifyResourceStatus("gitbranch", branchName, platformNs, "True", "Succeeded", "holds") }) @@ -101,18 +101,18 @@ var _ = Describe("Manager shared GitBranch", Label("manager"), Ordered, func() { }).Should(Succeed()) }) - It("refuses a namespace the GitBranch's GitProvider does not admit", func() { + It("refuses a namespace the GitBranch does not admit", func() { applySharedGitTarget("intruder", outsiderNs, branchName, platformNs, "outsider") verifyResourceCondition("gittarget", "intruder", outsiderNs, - "Validated", "False", "NamespaceNotAuthorized", "spec.accessFrom") + "Validated", "False", "NamespaceNotAuthorized", "spec.allowedGitTargets") }) It("holds the branch against a second GitBranch, even through another GitProvider", func() { By("giving the tenant a GitProvider of its own, with a valid credential, for the same repository") _, err := kubectlRunInNamespace(tenantNs, "apply", "-f", repo.SecretsYAML) Expect(err).NotTo(HaveOccurred()) - applySharedGitProvider("own-monorepo", tenantNs, repo.RepoURLHTTP, repo.GitSecretHTTP, "") - applySharedGitBranch("own-main", tenantNs, "own-monorepo", "main") + applySharedGitProvider("own-monorepo", tenantNs, repo.RepoURLHTTP, repo.GitSecretHTTP) + applySharedGitBranch("own-main", tenantNs, "own-monorepo", "main", "") verifyResourceCondition("gitbranch", "own-main", tenantNs, "Stalled", "True", "BranchConflict", fmt.Sprintf("%s/%s", platformNs, branchName)) @@ -127,12 +127,12 @@ var _ = Describe("Manager shared GitBranch", Label("manager"), Ordered, func() { By("creating a GitBranch for the release branch whose GitProvider's credential is wrong") _, err := kubectlRunInNamespace(outsiderNs, "apply", "-f", repo.SecretsYAML) Expect(err).NotTo(HaveOccurred()) - applySharedGitProvider("squat", outsiderNs, repo.RepoURLHTTP, repo.GitSecretInvalid, "") - applySharedGitBranch("squat-release", outsiderNs, "squat", "release") + applySharedGitProvider("squat", outsiderNs, repo.RepoURLHTTP, repo.GitSecretInvalid) + applySharedGitBranch("squat-release", outsiderNs, "squat", "release", "") verifyResourceStatus("gitbranch", "squat-release", outsiderNs, "False", "PushSessionFailed", "") By("creating a valid GitBranch for the same branch afterwards") - applySharedGitBranch("monorepo-release", platformNs, providerName, "release") + applySharedGitBranch("monorepo-release", platformNs, providerName, "release", "") verifyResourceStatus("gitbranch", "monorepo-release", platformNs, "True", "Succeeded", "holds") }) @@ -155,13 +155,10 @@ var _ = Describe("Manager shared GitBranch", Label("manager"), Ordered, func() { }) }) -// applySharedGitProvider applies a GitProvider for url with secret, admitting accessFrom when set. -func applySharedGitProvider(name, namespace, url, secret, accessFrom string) { +// applySharedGitProvider applies a GitProvider for url with secret, letting GitBranches in its own +// namespace claim main and release. +func applySharedGitProvider(name, namespace, url, secret string) { By(fmt.Sprintf("creating GitProvider '%s/%s'", namespace, name)) - access := "" - if accessFrom != "" { - access = fmt.Sprintf("\n accessFrom:\n names: [%s]", accessFrom) - } _, err := kubectlRunWithStdin("", fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 kind: GitProvider metadata: @@ -169,17 +166,24 @@ metadata: namespace: %s spec: url: %s - allowedBranches: ["main", "release"] + allowedGitBranches: + - consumerNamespaces: {from: Same} + branchPatterns: ["main", "release"] secretRef: - name: %s%s -`, name, namespace, url, secret, access), "apply", "-f", "-") + name: %s +`, name, namespace, url, secret), "apply", "-f", "-") Expect(err).NotTo(HaveOccurred(), "failed to apply GitProvider %s/%s", namespace, name) } -// applySharedGitBranch applies a GitBranch writing branch through the GitProvider provider. -func applySharedGitBranch(name, namespace, provider, branch string) { +// applySharedGitBranch applies a GitBranch writing branch through the GitProvider provider, in its +// own namespace. GitTargets in its own namespace may write through it, and in sharedWith when set. +func applySharedGitBranch(name, namespace, provider, branch, sharedWith string) { By(fmt.Sprintf("creating GitBranch '%s/%s' for branch '%s' through GitProvider '%s'", namespace, name, branch, provider)) + var shared []string + if sharedWith != "" { + shared = []string{sharedWith} + } _, err := kubectlRunWithStdin("", fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 kind: GitBranch metadata: @@ -189,7 +193,8 @@ spec: gitProviderRef: name: %s branch: %s -`, name, namespace, provider, branch), "apply", "-f", "-") + allowedGitTargets:%s +`, name, namespace, provider, branch, gitBranchWritersYAML(shared...)), "apply", "-f", "-") Expect(err).NotTo(HaveOccurred(), "failed to apply GitBranch %s/%s", namespace, name) } diff --git a/test/e2e/source_cluster_e2e_test.go b/test/e2e/source_cluster_e2e_test.go index b9097f676..a1f6c5244 100644 --- a/test/e2e/source_cluster_e2e_test.go +++ b/test/e2e/source_cluster_e2e_test.go @@ -17,7 +17,7 @@ import ( // cluster it mirrors FROM by referencing a namespaced KubeProvider (spec.kubeProviderRef). The // KubeProvider is the home for that cluster's credential (spec.secretRef, a kubeconfig Secret in // the provider's OWN namespace, or spec.inCluster where the installation allows it), for which -// other namespaces may use it (spec.accessFrom), and for its audit route. +// other namespaces may use it (spec.allowedGitTargets), and for its audit route. // // Three kinds of spec live here: // - Input-validation specs (Scenario 1) assert the KubeProvider reconciler's Validated verdict on @@ -164,17 +164,20 @@ func writeKubeConfigSecret(namespace, name, key, kubeconfig string) { } // applyKubeProvider applies a KubeProvider in namespace whose secretRef names a kubeconfig Secret in -// that same namespace, admitting the other namespaces listed. It returns the kubectl error so a spec -// can assert the apply succeeded (the CR is well-formed; the kubeconfig, not the CR, is what a -// validation case makes bad). +// that same namespace, letting its own namespace and the other namespaces listed mirror their own +// namespace. It returns the kubectl error so a spec can assert the apply succeeded (the CR is +// well-formed; the kubeconfig, not the CR, is what a validation case makes bad). func applyKubeProvider(namespace, name, secretName, key string, admits ...string) (string, error) { keyLine := "" if key != "" { keyLine = "\n key: " + key } - accessFrom := "" + grants := "\n allowedGitTargets:\n - consumerNamespaces: {from: Same}\n sourceNamespaces: [\"{consumer.namespace}\"]" if len(admits) > 0 { - accessFrom = fmt.Sprintf("\n accessFrom:\n names: [%s]", strings.Join(admits, ", ")) + grants += fmt.Sprintf( + "\n - consumerNamespaces: {from: Names, names: [%s]}\n sourceNamespaces: [\"{consumer.namespace}\"]", + strings.Join(admits, ", "), + ) } manifest := fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 kind: KubeProvider @@ -184,7 +187,7 @@ metadata: spec: secretRef: name: %s%s%s -`, name, namespace, secretName, keyLine, accessFrom) +`, name, namespace, secretName, keyLine, grants) return kubectlRunWithStdin(namespace, manifest, "apply", "-f", "-") } @@ -203,7 +206,10 @@ func applyGitTargetWithKubeProvider( manifest := "" if branchNS == "" { branchNS = ns - manifest = gitBranchManifest(ns, gitProvider) + // Applied only when missing: applying it again would drop a writer grant a spec added. + if _, err := kubectlRunInNamespace(ns, "get", "gitbranch", gitBranchName(gitProvider, "main")); err != nil { + manifest = gitBranchManifest(ns, gitProvider) + } } manifest += fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 kind: GitTarget @@ -315,11 +321,10 @@ var _ = Describe("Manager source cluster / KubeProvider", Label("source-cluster" Expect(err).NotTo(HaveOccurred(), "failed to apply repo secrets") createReadyGitProvider(providerName, testNs, repo.GitSecretHTTP, repo.RepoURLHTTP) - // The tenant writes through this namespace's GitBranch, so the GitProvider admits it; which + // The tenant writes through this namespace's GitBranch, so the GitBranch admits it; which // KubeProvider it may READ through is what the specs below decide. - _, err = kubectlRunInNamespace(testNs, "patch", "gitprovider", providerName, "--type=merge", "-p", - fmt.Sprintf(`{"spec":{"accessFrom":{"names":[%q]}}}`, tenantNs)) - Expect(err).NotTo(HaveOccurred(), "failed to admit the tenant namespace to the GitProvider") + _, err = kubectlRunWithStdin(testNs, gitBranchManifest(testNs, providerName, tenantNs), "apply", "-f", "-") + Expect(err).NotTo(HaveOccurred(), "failed to admit the tenant namespace to the GitBranch") if kcpAvailable() { kcp = startKcpTunnel() @@ -433,6 +438,9 @@ kind: KubeProvider metadata: {name: %s, namespace: %s} spec: inCluster: {} + allowedGitTargets: + - consumerNamespaces: {from: Same} + sourceNamespaces: ["{consumer.namespace}"] `, name, testNs), "apply", "-f", "-") Expect(err).NotTo(HaveOccurred()) verifyResourceCondition("kubeprovider", name, testNs, "Validated", "False", "InClusterNotAuthorized", "") @@ -465,8 +473,8 @@ spec: }) // Scenario 2b — a tenant's KubeProvider is the tenant's. Another namespace may not mirror - // through it until its accessFrom admits that namespace. - It("refuses a second tenant a KubeProvider whose accessFrom does not admit it", func() { + // through it until a grant in its allowedGitTargets admits that namespace. + It("refuses a second tenant a KubeProvider whose grants do not admit it", func() { const name = "sc-unreachable" target := "sc-tenant-target" _, err := applyGitTargetWithKubeProvider( @@ -488,7 +496,7 @@ spec: }) // Scenario 3 — the install's own provider: "local" in the operator's namespace mirrors the - // operator's own cluster, and admits a GitTarget in any namespace through its accessFrom. + // operator's own cluster, and admits a GitTarget in any namespace through the suite's grant. It("mirrors the operator's own cluster through the install's KubeProvider", func() { target := "sc-local-target" _, err := applyGitTargetWithKubeProvider(testNs, target, providerName, "", "clusters/local", diff --git a/test/e2e/source_namespace_e2e_test.go b/test/e2e/source_namespace_e2e_test.go index 921100df4..0734862d2 100644 --- a/test/e2e/source_namespace_e2e_test.go +++ b/test/e2e/source_namespace_e2e_test.go @@ -271,7 +271,7 @@ var _ = Describe("WatchRule source namespace", Label("manager"), Ordered, func() By("asserting the terminal refusal is published with a fix-naming message") verifyResourceCondition("watchrule", refusedRule, configNS, "SourceNamespaceAuthorized", "False", "SourceNamespaceNotAllowed", - "spec.allowCrossNamespace.sources") + "spec.allowedGitTargets[].sourceNamespaces") verifyResourceCondition("watchrule", refusedRule, configNS, "Stalled", "True", "SourceNamespaceNotAllowed", "") verifyResourceCondition("watchrule", refusedRule, configNS, diff --git a/test/e2e/templates/gitprovider-signing.tmpl b/test/e2e/templates/gitprovider-signing.tmpl index 577d1c95f..a4a3c7f1b 100644 --- a/test/e2e/templates/gitprovider-signing.tmpl +++ b/test/e2e/templates/gitprovider-signing.tmpl @@ -5,8 +5,9 @@ metadata: namespace: {{.Namespace}} spec: url: {{.RepoURL}} - allowedBranches: - - "{{.Branch}}" + allowedGitBranches: + - consumerNamespaces: {from: Same} + branchPatterns: ["{{.Branch}}"] secretRef: name: {{.SecretName}} commit: diff --git a/test/e2e/templates/gitprovider.tmpl b/test/e2e/templates/gitprovider.tmpl index 08d53a3af..fd395168d 100644 --- a/test/e2e/templates/gitprovider.tmpl +++ b/test/e2e/templates/gitprovider.tmpl @@ -5,7 +5,8 @@ metadata: namespace: {{.Namespace}} spec: url: {{.RepoURL}} - allowedBranches: - - "{{.Branch}}" + allowedGitBranches: + - consumerNamespaces: {from: Same} + branchPatterns: ["{{.Branch}}"] secretRef: name: {{.SecretName}} diff --git a/test/e2e/templates/gittarget.tmpl b/test/e2e/templates/gittarget.tmpl index 6e1f67c53..b96a02df9 100644 --- a/test/e2e/templates/gittarget.tmpl +++ b/test/e2e/templates/gittarget.tmpl @@ -9,6 +9,8 @@ spec: gitProviderRef: name: {{ .ProviderName }} branch: {{ .Branch }} + allowedGitTargets: + - consumerNamespaces: {from: Same} {{- if .ParentBranch }} parentBranch: {{ .ParentBranch }} {{- end }} diff --git a/test/e2e/templates/manager/gittarget-prune.tmpl b/test/e2e/templates/manager/gittarget-prune.tmpl index 50dc7d066..6313ecd74 100644 --- a/test/e2e/templates/manager/gittarget-prune.tmpl +++ b/test/e2e/templates/manager/gittarget-prune.tmpl @@ -9,6 +9,8 @@ spec: gitProviderRef: name: {{ .ProviderName }} branch: {{ .Branch }} + allowedGitTargets: + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml index 181f6152d..d13f74b41 100644 --- a/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: mirror-repository branch: main + allowedGitTargets: + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget @@ -20,8 +22,8 @@ spec: name: prod path: mirror/prod # The WatchRule beside this file names source namespaces other than its own. The - # KubeProvider's allowCrossNamespace.sources is the only grant that needs, and the - # source credential's RBAC bounds what it can actually read. + # KubeProvider's allowedGitTargets grant gives this namespace sourceNamespaces "*", + # and the source credential's RBAC bounds what it can actually read. # Flat is a DECLARED shape. Without a template the built-in ladder ends at the # built-in identity path, which is a tree. One file per object, directly in the # folder, is what this template says. diff --git a/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/kubeprovider.yaml b/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/kubeprovider.yaml index 935413459..72b4ef414 100644 --- a/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/kubeprovider.yaml +++ b/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/kubeprovider.yaml @@ -1,8 +1,8 @@ -# Required, because the WatchRule beside this file names source namespaces other -# than its own. This is the ONE grant that permits that: the owner of the -# KubeProvider delegates the choice of source namespace to the GitTargets it -# admits, which here are those in its own namespace. What those targets can -# actually read is bounded by the source credential's own RBAC. +# Required, because nothing mirrors through a KubeProvider without a grant. The +# WatchRule beside this file names source namespaces other than its own, so the +# grant gives the GitTargets in the provider's own namespace every source +# namespace ("*"). What those targets can actually read is bounded by the source +# credential's own RBAC. apiVersion: configbutler.ai/v1alpha3 kind: KubeProvider metadata: @@ -12,5 +12,6 @@ spec: # A kubeconfig Secret in this provider's own namespace. secretRef: name: prod-kubeconfig - allowCrossNamespace: - sources: true + allowedGitTargets: + - consumerNamespaces: {from: Same} + sourceNamespaces: ["*"] diff --git a/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml index 1a854400f..ab04c08f3 100644 --- a/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml +++ b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: artifacts-repository branch: main + allowedGitTargets: + - consumerNamespaces: {from: Same} --- # The fence around "one namespace", as a fixture. Same folder and same flag as # gittarget.yaml; what differs is that TWO WatchRules reach it, from two source namespaces. diff --git a/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget.yaml index c432e5d7b..7a1e1cd1d 100644 --- a/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: artifacts-repository branch: main + allowedGitTargets: + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml index 78b585521..8fc7adaa9 100644 --- a/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: mirror-repository branch: main + allowedGitTargets: + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget @@ -20,8 +22,8 @@ spec: name: home path: clusters/home # The WatchRule beside this file names source namespaces other than its own. The - # KubeProvider's allowCrossNamespace.sources is the only grant that needs, and the - # source credential's RBAC bounds what it can actually read. + # KubeProvider's allowedGitTargets grant gives this namespace sourceNamespaces "*", + # and the source credential's RBAC bounds what it can actually read. # No placement declared and no kustomization anywhere in the subtree, so the # ladder falls through to the built-in identity path: # {namespace}/{groupPath}/{resource}/{name}.yaml diff --git a/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/kubeprovider.yaml b/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/kubeprovider.yaml index 9316cd8d8..74fae7304 100644 --- a/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/kubeprovider.yaml +++ b/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/kubeprovider.yaml @@ -1,8 +1,8 @@ -# Required, because the WatchRule beside this file names source namespaces other -# than its own. This is the ONE grant that permits that: the owner of the -# KubeProvider delegates the choice of source namespace to the GitTargets it -# admits, which here are those in its own namespace. What those targets can -# actually read is bounded by the source credential's own RBAC. +# Required, because nothing mirrors through a KubeProvider without a grant. The +# WatchRule beside this file names source namespaces other than its own, so the +# grant gives the GitTargets in the provider's own namespace every source +# namespace ("*"). What those targets can actually read is bounded by the source +# credential's own RBAC. apiVersion: configbutler.ai/v1alpha3 kind: KubeProvider metadata: @@ -12,5 +12,6 @@ spec: # A kubeconfig Secret in this provider's own namespace. secretRef: name: home-kubeconfig - allowCrossNamespace: - sources: true + allowedGitTargets: + - consumerNamespaces: {from: Same} + sourceNamespaces: ["*"] diff --git a/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/gittarget.yaml index 59c455262..f26ac2c39 100644 --- a/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/gittarget.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: artifacts-repository branch: main + allowedGitTargets: + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml index ee2494af8..4d506746d 100644 --- a/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml +++ b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: app-repository branch: main + allowedGitTargets: + - consumerNamespaces: {from: Same} --- # The same folder, when it does not exist yet. This is the ONLY difference # between adopting a kustomize folder and creating one. diff --git a/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget.yaml index f64b4398d..106579d1a 100644 --- a/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: app-repository branch: main + allowedGitTargets: + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml index 516edd6a3..d0eed3cc3 100644 --- a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml +++ b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: app-repository branch: main + allowedGitTargets: + - consumerNamespaces: {from: Same} --- # The MISTAKE this shape exists to name, kept as a fixture because a set where every write # succeeds is advertising rather than specification. Same repository as gittarget-prod.yaml, diff --git a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml index a7761f93a..bebea87a6 100644 --- a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml +++ b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: app-repository branch: main + allowedGitTargets: + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml index e4af09cf2..ef98d210a 100644 --- a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml +++ b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: app-repository branch: main + allowedGitTargets: + - consumerNamespaces: {from: Same} --- # The second and third targets are the same object with the environment swapped. # Nothing is shared between them except the byType map, if one were declared. diff --git a/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/gittarget-prod.yaml b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/gittarget-prod.yaml index c63de1c68..be0f71d4c 100644 --- a/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/gittarget-prod.yaml +++ b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/gittarget-prod.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: app-repository branch: main + allowedGitTargets: + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml b/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml index 7dab35e8e..9ae8dfe4a 100644 --- a/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml +++ b/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: app-repository branch: main + allowedGitTargets: + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/gittarget.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/gittarget.yaml index eda89caf8..0b4b0eff8 100644 --- a/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/gittarget.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: homelab-repository branch: main + allowedGitTargets: + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget-media.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget-media.yaml index 9bfe4b360..d8e0b4850 100644 --- a/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget-media.yaml +++ b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget-media.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: homelab-repository branch: main + allowedGitTargets: + - consumerNamespaces: {from: Same} --- # The second target. HelmReleases are a different layer from the sources they pull # from, so they get their own folder and their own GitTarget rather than sharing one diff --git a/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget.yaml index d5945fef9..94e34f5f9 100644 --- a/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: homelab-repository branch: main + allowedGitTargets: + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/specific-examples/prerequisites/README.md b/test/fixtures/layout-corpus/specific-examples/prerequisites/README.md index c8f66e619..915f5c03b 100644 --- a/test/fixtures/layout-corpus/specific-examples/prerequisites/README.md +++ b/test/fixtures/layout-corpus/specific-examples/prerequisites/README.md @@ -15,25 +15,25 @@ namespace demo GitTarget/demo -> GitProvider/app-repository ``` -The provider is namespace-local because it carries credentials. A `GitTarget` may refer to a -provider in another namespace only when that provider's `spec.accessFrom` admits the target's -namespace, and every `GitTarget` writing one repository branch must refer to the same provider. +The provider carries credentials, so nothing uses it without a grant. Its +`spec.allowedGitBranches` says which namespaces' `GitBranch`es may claim which branches, and each +`GitBranch`'s `spec.allowedGitTargets` says which namespaces' `GitTarget`s may write through it; the +provider's own namespace needs a grant too (`from: Same`). ## Source cluster authority Every `GitTarget` names its source cluster with `spec.kubeProviderRef`. The examples here use the `KubeProvider` the chart renders, `local` in `gitops-reverser`, which uses the operator's own -identity. A target that captures objects from a different source namespace needs one grant, and it -belongs to the owner of the `KubeProvider`: the provider delegates the *ability* to choose a source -namespace (`allowCrossNamespace.sources`). What may actually be read is bounded by that provider -credential's own RBAC in the source cluster. Concrete specimens are in +identity. Every target needs a grant from the owner of the `KubeProvider` +(`spec.allowedGitTargets`), and the grant's `sourceNamespaces` bound which source namespaces its +`WatchRule`s may watch, its own included only when listed (`"{consumer.namespace}"`). What may actually be read +is bounded by that provider credential's own RBAC in the source cluster. Concrete specimens are in [`../../shapes/1-flat-serialized/config/`](../../shapes/1-flat-serialized/config/kubeprovider.yaml) and [`../../shapes/3-tree-serialized/config/`](../../shapes/3-tree-serialized/config/kubeprovider.yaml). -The source-cluster rule and source-namespace authorization are separate checks. A `KubeProvider` -admits consumers in its own namespace, and `accessFrom` decides which other configuration -namespaces may reference it; `allowCrossNamespace.sources` decides whether those namespaces' -`GitTarget`s may look outside their own. +The source-cluster rule and source-namespace authorization are one grant read twice. A grant in +`allowedGitTargets` decides which configuration namespaces may reference the `KubeProvider` at all, +and its `sourceNamespaces` decide what those namespaces' `GitTarget`s may watch. ## Credentials stay out of this design diff --git a/test/fixtures/layout-corpus/specific-examples/prerequisites/config/gitprovider.yaml b/test/fixtures/layout-corpus/specific-examples/prerequisites/config/gitprovider.yaml index 26188ab46..7d32a3e49 100644 --- a/test/fixtures/layout-corpus/specific-examples/prerequisites/config/gitprovider.yaml +++ b/test/fixtures/layout-corpus/specific-examples/prerequisites/config/gitprovider.yaml @@ -9,8 +9,9 @@ spec: name: app-repository-credentials knownHostsRef: name: example-com-known-hosts - allowedBranches: - - main + allowedGitBranches: + - consumerNamespaces: {from: Same} + branchPatterns: [main] commit: committer: name: GitOps Reverser diff --git a/test/mutationlab/e2e/configmap_scenarios_test.go b/test/mutationlab/e2e/configmap_scenarios_test.go index 31d8634d9..f0d1abd31 100644 --- a/test/mutationlab/e2e/configmap_scenarios_test.go +++ b/test/mutationlab/e2e/configmap_scenarios_test.go @@ -450,7 +450,11 @@ func TestDeletecollection(t *testing.T) { // N watch DELETED + one name-less audit deletecollection + N per-object // admission DELETE (validating admission fires once per object). - records := h.drain(t, s.id, drainSpec{minCount: 2*len(names) + 1, settle: 3 * time.Second, timeout: 60 * time.Second}) + records := h.drain( + t, + s.id, + drainSpec{minCount: 2*len(names) + 1, settle: 3 * time.Second, timeout: 60 * time.Second}, + ) if got := countSource(records, mutationlab.SourceWatch); got != len(names) { t.Errorf("deletecollection produced %d watch deletes; want %d (per-object fan-out)", got, len(names)) } diff --git a/test/mutationlab/e2e/crd_conversion_test.go b/test/mutationlab/e2e/crd_conversion_test.go index 6517650b1..f92f6abd5 100644 --- a/test/mutationlab/e2e/crd_conversion_test.go +++ b/test/mutationlab/e2e/crd_conversion_test.go @@ -32,8 +32,16 @@ const ( ) var ( - crdGVR = schema.GroupVersionResource{Group: "apiextensions.k8s.io", Version: "v1", Resource: "customresourcedefinitions"} - vwcGVR = schema.GroupVersionResource{Group: "admissionregistration.k8s.io", Version: "v1", Resource: "validatingwebhookconfigurations"} + crdGVR = schema.GroupVersionResource{ + Group: "apiextensions.k8s.io", + Version: "v1", + Resource: "customresourcedefinitions", + } + vwcGVR = schema.GroupVersionResource{ + Group: "admissionregistration.k8s.io", + Version: "v1", + Resource: "validatingwebhookconfigurations", + } widgetV1 = schema.GroupVersionResource{Group: widgetGroup, Version: "v1", Resource: "widgets"} widgetV2 = schema.GroupVersionResource{Group: widgetGroup, Version: "v2", Resource: "widgets"} vwcName = "gitops-reverser-validating-webhook" diff --git a/test/mutationlab/e2e/workload_scenarios_test.go b/test/mutationlab/e2e/workload_scenarios_test.go index cb1d6be09..7010c613a 100644 --- a/test/mutationlab/e2e/workload_scenarios_test.go +++ b/test/mutationlab/e2e/workload_scenarios_test.go @@ -76,7 +76,9 @@ func TestStatusSubresource(t *testing.T) { ctx := context.Background() s := h.newScenario(ctx, t, "status-update") - if _, err := h.kube.AppsV1().Deployments(s.ns).Create(ctx, pausedDeployment(s, "d1"), metav1.CreateOptions{}); err != nil { + if _, err := h.kube.AppsV1(). + Deployments(s.ns). + Create(ctx, pausedDeployment(s, "d1"), metav1.CreateOptions{}); err != nil { t.Fatalf("create deployment: %v", err) } // create => admission CREATE + audit create + watch ADDED + the controller's @@ -113,7 +115,9 @@ func TestScaleSubresource(t *testing.T) { ctx := context.Background() s := h.newScenario(ctx, t, "scale-patch") - if _, err := h.kube.AppsV1().Deployments(s.ns).Create(ctx, pausedDeployment(s, "d1"), metav1.CreateOptions{}); err != nil { + if _, err := h.kube.AppsV1(). + Deployments(s.ns). + Create(ctx, pausedDeployment(s, "d1"), metav1.CreateOptions{}); err != nil { t.Fatalf("create deployment: %v", err) } h.quiesceAndClear(t, s.id, 3) diff --git a/test/playground/config/gitprovider.yaml b/test/playground/config/gitprovider.yaml index 2b58b8c6a..ee7cbe1c1 100644 --- a/test/playground/config/gitprovider.yaml +++ b/test/playground/config/gitprovider.yaml @@ -5,7 +5,8 @@ metadata: namespace: tilt-playground spec: url: http://gitea-http.gitea-e2e.svc.cluster.local:13000/testorg/playground.git - allowedBranches: - - "main" + allowedGitBranches: + - consumerNamespaces: {from: Same} + branchPatterns: [main] secretRef: name: git-creds-playground diff --git a/test/playground/config/gittarget.yaml b/test/playground/config/gittarget.yaml index 269f04796..8460d22ad 100644 --- a/test/playground/config/gittarget.yaml +++ b/test/playground/config/gittarget.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: playground-provider branch: main + allowedGitTargets: + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget