diff --git a/.coverage-baseline b/.coverage-baseline index 29179ad4..f5bfc226 100644 --- a/.coverage-baseline +++ b/.coverage-baseline @@ -1 +1 @@ -77.0 +77.8 diff --git a/AGENTS.md b/AGENTS.md index 7831d82a..c5e0b64d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,6 +69,18 @@ workflow or Dockerfile change is covered by the normal lint gate; you can also r - Include JSON tags and field descriptions - Run `task manifests` to update CRDs - Test CRD installation and usage +- **Keep CRD descriptions to the contract.** A doc comment on a type or field becomes the + `kubectl explain` text, so it should say what the field does, what its values mean, and the + gotchas — not why it was designed that way. Put design rationale in a comment block separated + from the doc comment **by a blank line**: Go does not treat it as the doc comment, so + controller-gen leaves it out of the schema while it stays in the source. +- **Where that block goes matters, and getting it wrong fails silently.** On a FIELD, put it above + the doc comment. On a TYPE, put it above the `+kubebuilder:` marker block, never between the + markers and the doc — that displaces the markers and they are dropped without any error + (a `+kubebuilder:resource:scope=Cluster` was silently lost this way, flipping a CRD to + Namespaced). After editing API comments, confirm only descriptions moved: + regenerate into a scratch dir with `controller-gen crd paths=./api/... output:crd:artifacts:config=`, + then compare against `config/crd/bases` with every `description` key stripped from both. ### Git Operations (`internal/git/`) - Handle Git errors gracefully diff --git a/api/v1alpha3/clusterprovider_types.go b/api/v1alpha3/clusterprovider_types.go index 1621f714..495867aa 100644 --- a/api/v1alpha3/clusterprovider_types.go +++ b/api/v1alpha3/clusterprovider_types.go @@ -5,7 +5,6 @@ package v1alpha3 import ( meta "github.com/fluxcd/pkg/apis/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/labels" ) // DefaultClusterProviderName is the conventionally opinionated ClusterProvider name that an @@ -39,24 +38,6 @@ type ClusterProviderReference struct { Name string `json:"name"` } -// AllowedNamespaces is the deny-by-default namespace-access policy a ClusterProvider carries. -// A cluster-scoped provider holds a credential that can read a lot of a remote cluster; any -// GitTarget that references it makes the operator mirror that cluster's state into the target's -// destination. So which namespaces may reference the provider is authorization, not routing: an -// empty policy (no names, no selector) means NO namespace may reference the provider. Names and -// selector are ORed — a namespace is allowed if it is listed OR matches the selector. -type AllowedNamespaces struct { - // Names is an explicit allow-list of namespace names that may reference this provider. - // +optional - // +listType=set - Names []string `json:"names,omitempty"` - - // Selector is a label selector matched against Namespace labels; a namespace whose labels - // match may reference this provider. ORed with Names. - // +optional - Selector *metav1.LabelSelector `json:"selector,omitempty"` -} - // ClusterProviderSpec defines the desired state of ClusterProvider. // // kubeConfig is IMMUTABLE and OPTIONAL: which physical cluster a provider name means must not @@ -75,21 +56,55 @@ type AllowedNamespaces struct { // but permits the empty string; an empty name can never resolve a Secret, so reject it here. // +kubebuilder:validation:XValidation:rule="!has(self.kubeConfig) || !has(self.kubeConfig.secretRef) || size(self.kubeConfig.secretRef.name) > 0",message="spec.kubeConfig.secretRef.name must not be empty" type ClusterProviderSpec struct { - // KubeConfig names the SOURCE CLUSTER this provider represents and the credentials to reach - // it (Flux's meta.KubeConfigReference, embedded verbatim). OMITTED means the operator's own - // in-cluster cluster, for any provider name. IMMUTABLE. The referenced Secret is - // resolved from the operator's namespace — the credential for a cluster never has to live on - // that cluster. When secretRef.key is empty the resolver reads "value" then "value.yaml" - // (Flux's order). Only secretRef is honored (configMapRef is rejected); unsafe kubeconfigs - // (exec auth, insecure-skip-tls-verify) are rejected with a Validated=False reason unless the + // KubeConfig names the SOURCE CLUSTER this provider represents and the credentials to reach it + // (Flux's meta.KubeConfigReference, embedded verbatim). OMITTED means the operator's own + // in-cluster cluster, for any provider name. IMMUTABLE. + // + // The referenced Secret is resolved from the operator's namespace, so a cluster's credential + // never has to live on that cluster. Only secretRef is honored; configMapRef is rejected. When + // secretRef.key is empty the resolver reads "value" then "value.yaml" (Flux's order). Unsafe + // kubeconfigs (exec auth, insecure-skip-tls-verify) are rejected with Validated=False unless the // operator opts in via flags. // +optional KubeConfig *meta.KubeConfigReference `json:"kubeConfig,omitempty"` - // AllowedNamespaces is the deny-by-default policy for which namespaces may reference this - // provider from a GitTarget. Empty (or omitted) means no namespace may reference it. + // AllowedNamespaces is the deny-by-default policy for which CONTROL-CLUSTER namespaces may + // reference this provider from a GitTarget. Empty (or omitted) means no namespace may + // reference it. Its selector matches labels on Namespaces in the control cluster — the + // cluster the operator's own CRs live in — never on the source cluster this provider names. // +optional - AllowedNamespaces *AllowedNamespaces `json:"allowedNamespaces,omitempty"` + AllowedNamespaces *NamespaceMatcher `json:"allowedNamespaces,omitempty"` + + // Design rationale, kept out of the generated CRD description by the blank line below. + // + // Remote and in-cluster providers use the same mechanism but deserve very different sign-off. + // For a REMOTE provider the config-plane namespace and the source namespace are on different + // clusters, so their sharing a name never was a boundary and naming one widens nothing. For an + // IN-CLUSTER provider (kubeConfig omitted) the same-name coupling WAS the boundary: setting this + // deliberately bypasses live namespace RBAC, letting the owner of an admitted GitTarget in one + // namespace mirror another namespace's objects — read through the operator's own cluster-wide + // credential — into a Git destination they control. That is legitimate for a cluster-admin to + // grant on purpose, and must never happen by default or as a side effect of another field, + // which is why this exists and defaults to false. LOCALITY is not the switch: in-cluster-ness + // follows from spec.kubeConfig, and neither that nor the provider's name decides this. + // + // A wildcard needs the flag for the same reason a named namespace does: it requests the + // target's policy SET, so a later policy edit could otherwise widen the watch with no + // platform-admin opt-in. + + // AllowSourceNamespaceOverride delegates SOURCE-namespace selection to the GitTargets this + // provider admits. While false (the default) a WatchRule mirroring through this provider may + // watch only its OWN namespace, whatever any GitTarget policy says. + // + // It grants no access by itself: an admitted GitTarget must still admit the namespace in its + // spec.allowedSourceNamespaces, and the source credential's own RBAC remains the hard maximum. + // What it delegates is the AUTHORITY to choose, so set it only when the owners of admitted + // GitTargets are trusted to pick a subset of what that credential may read. Every + // cross-namespace request needs it, including a rules[].sourceNamespace of "*". It does not + // apply to ClusterWatchRule, which selects no namespaces at all. + // +optional + // +kubebuilder:default=false + AllowSourceNamespaceOverride bool `json:"allowSourceNamespaceOverride,omitempty"` // QPS overrides the operator's outgoing kube-client query-per-second throttle for this // cluster's watches and discovery. Omitted, the operator-wide --source-cluster-qps applies. @@ -132,19 +147,17 @@ type ClusterProviderStatus struct { // +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].message`,priority=1 // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` -// ClusterProvider is the cluster-scoped, read-side peer of GitProvider: it names a SOURCE cluster -// a GitTarget mirrors FROM, and is the home for that cluster's connectivity credential -// (spec.kubeConfig), namespace-access authorization (spec.allowedNamespaces), and per-cluster -// status. Its NAME is the cluster's identity everywhere: the /audit-webhook/ ingress route -// and the attribution fact-index key. No name is special: "default" is merely the name -// GitTarget.spec.clusterProviderRef defaults to when omitted. Whether a provider is the operator's -// own cluster or a remote follows from spec.kubeConfig (omitted = in-cluster), not from its name, -// so "default" may just as well name a remote cluster. +// ClusterProvider is the cluster-scoped, read-side peer of GitProvider: it names a SOURCE cluster a +// GitTarget mirrors FROM, and owns that cluster's connectivity credential (spec.kubeConfig), +// namespace-access authorization (spec.allowedNamespaces), and per-cluster status. Its NAME is the +// cluster's identity everywhere — the /audit-webhook/ ingress route and the attribution +// fact-index key. No name is special: "default" is merely what an omitted +// GitTarget.spec.clusterProviderRef points at, and it may just as well name a remote cluster, since +// in-cluster-ness follows from spec.kubeConfig (omitted = in-cluster) rather than from the name. // -// Security model: -// - ClusterProvider is cluster-scoped and requires platform-admin permissions to create. -// - A GitTarget may reference it only from a namespace its spec.allowedNamespaces admits -// (deny-by-default), enforced at admission AND before any watch starts. +// It is cluster-scoped and requires platform-admin permissions to create. A GitTarget may reference +// it only from a namespace spec.allowedNamespaces admits — deny-by-default, enforced at admission +// and again before any watch starts. type ClusterProvider struct { metav1.TypeMeta `json:",inline"` @@ -188,26 +201,21 @@ func (p *ClusterProvider) IsInCluster() bool { // covers a policy tightened after the GitTarget was created, which an admission webhook could not // see. There is no admission webhook for this (docs/spec/where-validation-lives.md). A malformed // selector is a configuration error surfaced to the caller (not a silent allow). +// A malformed selector is a configuration error surfaced to the caller (not a silent allow). +// +// It is one of two thin wrappers over NamespaceMatcher.Matches — the other being +// GitTarget.AllowsSourceNamespace — so the control-cluster and source-cluster policies can never +// drift in their deny-by-default, names-OR-selector semantics. The labels passed here are always +// CONTROL-cluster Namespace labels. func (p *ClusterProvider) AllowsNamespace(nsName string, nsLabels map[string]string) (bool, error) { - policy := p.Spec.AllowedNamespaces - if policy == nil { - return false, nil - } - for _, n := range policy.Names { - if n == nsName { - return true, nil - } - } - if policy.Selector != nil { - sel, err := metav1.LabelSelectorAsSelector(policy.Selector) - if err != nil { - return false, err - } - if sel.Matches(labels.Set(nsLabels)) { - return true, nil - } - } - return false, nil + return p.Spec.AllowedNamespaces.Matches(nsName, nsLabels) +} + +// AllowsSourceNamespaceOverride reports whether this provider delegates source-namespace selection +// to the GitTargets it admits. See the field's documentation: false (the default) means a WatchRule +// mirroring through this provider may watch only its own namespace. +func (p *ClusterProvider) AllowsSourceNamespaceOverride() bool { + return p.Spec.AllowSourceNamespaceOverride } func init() { diff --git a/api/v1alpha3/clusterwatchrule_types.go b/api/v1alpha3/clusterwatchrule_types.go index 4d36b183..50a759ac 100644 --- a/api/v1alpha3/clusterwatchrule_types.go +++ b/api/v1alpha3/clusterwatchrule_types.go @@ -6,8 +6,11 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// ResourceScope defines the scope of resources. -// +kubebuilder:validation:Enum=Cluster;Namespaced +// ResourceScope names a Kubernetes resource's scope. It is an INTERNAL matching vocabulary: the +// resolver uses both constants to align a rule's selector with the discovered scope of each type +// (a WatchRule always resolves Namespaced records, a ClusterWatchRule always Cluster ones). The +// only field that still exposes it — ClusterResourceRule.scope — is narrowed to Cluster alone, so +// "Namespaced" is no longer a public choice anywhere in the API. type ResourceScope string const ( @@ -50,17 +53,17 @@ type ClusterWatchRuleSpec struct { // +required TargetRef NamespacedTargetReference `json:"targetRef"` - // Rules define which resources to watch. + // Rules define which CLUSTER-SCOPED resources to watch. // Multiple rules create a logical OR - a resource matching ANY rule is watched. - // Each rule can specify cluster-scoped or namespaced resources. + // A rule that resolves to no cluster-scoped type simply watches nothing; use a WatchRule with + // spec.rules[].sourceNamespace for namespaced resources. // +required // +kubebuilder:validation:MinItems=1 Rules []ClusterResourceRule `json:"rules"` } -// ClusterResourceRule defines which resources to watch with scope control. -// Each rule independently specifies whether it watches cluster-scoped or -// namespaced resources. +// ClusterResourceRule defines which CLUSTER-SCOPED resources to watch. It deliberately has no +// sourceNamespace: cluster-scoped objects have no namespace, so there is nothing to select. type ClusterResourceRule struct { // Operations to watch. If empty, watches all operations (CREATE, UPDATE, DELETE). // Supports: CREATE, UPDATE, DELETE, or * (wildcard for all operations). @@ -108,13 +111,43 @@ type ClusterResourceRule struct { // +kubebuilder:validation:items:Pattern=`^[^/]*$` Resources []string `json:"resources"` - // Scope defines whether this rule watches Cluster-scoped or Namespaced resources. - // - "Cluster": For cluster-scoped resources (Nodes, ClusterRoles, CRDs, etc.). - // - "Namespaced": For namespaced resources (Pods, Deployments, Secrets, etc.), - // across all namespaces. - // +required - // +kubebuilder:validation:Enum=Cluster;Namespaced - Scope ResourceScope `json:"scope"` + // Design rationale, kept out of the generated CRD description by the blank line below. + // + // The field is retained in the schema purely so that re-applying a manifest that still says + // "Namespaced" FAILS. Deleting it outright would be worse and silent twice over: CRD pruning + // happens on write, so the value would be dropped without an error and the rule would quietly + // stop mirroring namespaced objects; and a stored pre-release object would keep its value in + // etcd with no Go field left to read, leaving the controller nothing to refuse. The narrowed + // enum rejects it at admission, and the compile path refuses a stored value. + + // Scope is REMOVED as a choice: a ClusterWatchRule is cluster-scoped only, so "Cluster" is the + // only accepted value and also the default, making the field omittable. To watch NAMESPACED + // resources, use a WatchRule in the tenant namespace and set spec.rules[].sourceNamespace. + // + // Deprecated: ClusterWatchRule is cluster-scope-only; use WatchRule with + // spec.rules[].sourceNamespace for namespaced resources. Removed one release from now, or at + // v1beta1. + // +optional + // +kubebuilder:default=Cluster + // +kubebuilder:validation:Enum=Cluster + Scope ResourceScope `json:"scope,omitempty"` +} + +// DeclaresNamespacedScope reports whether a STORED ClusterWatchRule still selects namespaced +// resources through the removed scope choice. Admission rejects the value, but an object written +// before this release keeps it in etcd, so the compile path must refuse it rather than let the +// rule resolve as if it had asked for cluster scope. +// +// It keys on the STORED value, not on what the selector happens to resolve: `resources: ["*"]` +// legitimately resolves cluster-scoped records, so inferring the refusal from the resolution would +// be ambiguous exactly where it matters. +func (s *ClusterWatchRuleSpec) DeclaresNamespacedScope() bool { + for i := range s.Rules { + if s.Rules[i].Scope != "" && s.Rules[i].Scope != ResourceScopeCluster { + return true + } + } + return false } // ClusterWatchRuleStatus defines the observed state of ClusterWatchRule. @@ -136,6 +169,13 @@ type ClusterWatchRuleStatus struct { Streams *WatchRuleStreamsStatus `json:"streams,omitempty"` } +// Design rationale, kept out of the generated CRD description by the blank line below. +// +// Cluster-scoped objects have no namespace, so GitTarget.spec.allowedSourceNamespaces is neither +// consulted nor a bound for them: a ClusterWatchRule is intentionally cluster-global and is limited +// only by its source credential's Kubernetes RBAC. Isolating cluster-scoped objects between tenants +// therefore takes separate credentials/ClusterProviders, not a namespace allow-list. + // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:resource:scope=Cluster @@ -147,20 +187,14 @@ type ClusterWatchRuleStatus struct { // +kubebuilder:printcolumn:name="StreamsRunning",type=string,JSONPath=`.status.conditions[?(@.type=="StreamsRunning")].status`,priority=1 // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` -// ClusterWatchRule watches resources across the entire cluster. -// It provides the ability to audit both cluster-scoped resources (Nodes, ClusterRoles, CRDs) -// and namespaced resources across multiple namespaces with per-rule filtering. -// -// Security model: -// - ClusterWatchRule is cluster-scoped and requires cluster-admin permissions -// - It references a GitTarget via targetRef (namespace required) -// - Each rule can independently specify Cluster or Namespaced scope +// ClusterWatchRule selects CLUSTER-SCOPED resources on the source cluster its GitTarget mirrors +// from — Nodes, PersistentVolumes, StorageClasses, ClusterRoles, CRDs, and the like. Scope is +// carried by the rule KIND, so it has no per-rule scope choice and no source-namespace selection. // -// Use cases: -// - Audit cluster infrastructure (Nodes, PersistentVolumes, StorageClasses) -// - Audit RBAC changes (ClusterRoles, ClusterRoleBindings) -// - Audit CRD installations and updates -// - Audit resources across multiple namespaces (e.g., all production namespaces) +// It is cluster-scoped and requires cluster-admin permissions. Its targetRef names a GitTarget +// (namespace required), whose namespace must be admitted by that target's ClusterProvider. To +// mirror NAMESPACED resources use a WatchRule in the tenant namespace and set +// spec.rules[].sourceNamespace, whose "*" reaches every namespace the GitTarget admits. type ClusterWatchRule struct { metav1.TypeMeta `json:",inline"` diff --git a/api/v1alpha3/gittarget_types.go b/api/v1alpha3/gittarget_types.go index cfb8951f..86dd19c4 100644 --- a/api/v1alpha3/gittarget_types.go +++ b/api/v1alpha3/gittarget_types.go @@ -87,17 +87,49 @@ type GitTargetSpec struct { // +optional Placement *GitTargetPlacementSpec `json:"placement,omitempty"` + // Design rationale, kept out of the generated CRD description by the blank line below. + // + // It defaults to a concrete {name: "default"} rather than an implicit nil so a target that omits + // it persists with a ref a reader can jump to. The operator never creates that provider: a + // GitTarget naming one that does not exist is held unready rather than silently defaulting to + // in-cluster access. + // ClusterProviderRef names the SOURCE cluster this GitTarget mirrors FROM, by referencing a - // cluster-scoped ClusterProvider by name. The ClusterProvider is the home for that cluster's - // connectivity credential, namespace-access authorization, and author-attribution mode. This - // DEFAULTS to {name: "default"} — a user-created provider by that conventional name, which may - // be in-cluster or remote — so a target that omits it persists with a concrete, jumpable ref - // rather than an implicit nil. The operator never creates that provider; a GitTarget naming one - // that does not exist is held unready. Immutable: a folder's source cluster is part of what the - // folder means; delete and recreate to change it. + // cluster-scoped ClusterProvider by name. That ClusterProvider owns the cluster's connectivity + // credential, namespace-access authorization, and author-attribution mode. The default provider + // name is "default" and must exist; it may be in-cluster or remote. + // Immutable: a folder's source cluster is part of what the folder means; delete and recreate. // +kubebuilder:default={name: "default"} // +optional ClusterProviderRef *ClusterProviderReference `json:"clusterProviderRef,omitempty"` + + // Design rationale, kept out of the generated CRD description by the blank line below. + // + // There is deliberately NO self-namespace exception. An implicit carve-out would mean the field + // does not actually bound what arrives here, so a reader auditing it would be wrong about the + // target's contents — which is the whole reason the field exists. The resulting authoring + // footgun (adding a policy for one override silently denies co-resident legacy rules) is + // mitigated by being LOUD: SourceNamespaceAuthorized=False, Stalled=True, and a message naming + // the exact fix. `selector: {}` is the replacement for the removed cluster-wide namespaced + // ClusterWatchRule — declared by the destination owner rather than the rule author, and + // self-updating as namespaces come and go. The exact-names half stays answerable without any + // source-cluster Namespace access; that degradation path is deliberate, and it is the half most + // likely to regress unnoticed. + + // AllowedSourceNamespaces bounds which SOURCE-cluster namespaces may be mirrored INTO this + // target. It belongs to the DESTINATION, not to any requesting rule: once declared it is + // exhaustive for every WatchRule that writes here, with no exception for a rule's own namespace. + // + // Omitted and empty differ. Omitted declares no policy, and a WatchRule keeps its own namespace; + // a declared-but-empty policy admits nothing; `selector: {}` admits every source namespace. + // Selector labels are read in the SOURCE cluster, so evaluating one needs Namespace + // get/list/watch for that cluster's credential, while exact names need no such access. This is + // also what a rules[].sourceNamespace of "*" resolves through. Naming any namespace other than + // the WatchRule's own — including "*" — additionally requires the ClusterProvider to set + // spec.allowSourceNamespaceOverride. It does NOT bound ClusterWatchRule, whose cluster-scoped + // objects have no namespace. Full resolution table: docs/configuration.md. + // +optional + AllowedSourceNamespaces *NamespaceMatcher `json:"allowedSourceNamespaces,omitempty"` } // GitTargetPlacementSpec declares where NEW resources are written when no document @@ -243,6 +275,26 @@ func (g *GitTarget) IsLocalSource() bool { return g.SourceCluster() == DefaultClusterProviderName } +// DeclaresSourceNamespacePolicy reports whether this target declares spec.allowedSourceNamespaces +// at all. A declared policy is EXHAUSTIVE — it bounds every WatchRule item writing here, with no +// self-namespace exception — while an absent one leaves a WatchRule its own namespace. Callers +// must branch on this rather than on emptiness: a declared-but-empty policy admits nothing. +func (g *GitTarget) DeclaresSourceNamespacePolicy() bool { + return g.Spec.AllowedSourceNamespaces.Declared() +} + +// AllowsSourceNamespace reports whether a SOURCE-cluster namespace (by name and by the labels it +// carries IN THE SOURCE CLUSTER) may be mirrored into this target, per spec.allowedSourceNamespaces. +// +// It is the source-side twin of ClusterProvider.AllowsNamespace, and both are thin wrappers over +// NamespaceMatcher.Matches so the two policies cannot drift. It answers only the POLICY question: +// the delegation flag, the provider's own admission of this target's namespace, and the +// three-valued "can the labels be read at all" question are the caller's (see internal/authz). +// An undeclared policy admits nothing here — callers apply the legacy rule themselves. +func (g *GitTarget) AllowsSourceNamespace(nsName string, nsLabels map[string]string) (bool, error) { + return g.Spec.AllowedSourceNamespaces.Matches(nsName, nsLabels) +} + // +kubebuilder:object:root=true // GitTargetList contains a list of GitTarget. diff --git a/api/v1alpha3/helpers_test.go b/api/v1alpha3/helpers_test.go index daac6abb..6f1c35be 100644 --- a/api/v1alpha3/helpers_test.go +++ b/api/v1alpha3/helpers_test.go @@ -77,7 +77,7 @@ func TestAllowsNamespace_Authorization(t *testing.T) { tests := []struct { name string - policy *AllowedNamespaces + policy *NamespaceMatcher nsName string labels map[string]string want bool @@ -96,13 +96,13 @@ func TestAllowsNamespace_Authorization(t *testing.T) { // 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: &AllowedNamespaces{}, + policy: &NamespaceMatcher{}, nsName: "team-a", want: false, }, { name: "listed name is allowed", - policy: &AllowedNamespaces{Names: []string{"team-a", "team-b"}}, + policy: &NamespaceMatcher{Names: []string{"team-a", "team-b"}}, nsName: "team-b", want: true, }, @@ -110,19 +110,19 @@ func TestAllowsNamespace_Authorization(t *testing.T) { // 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: &AllowedNamespaces{Names: []string{"team-a"}}, + policy: &NamespaceMatcher{Names: []string{"team-a"}}, nsName: "team-a-evil", want: false, }, { name: "name match is case-sensitive and exact", - policy: &AllowedNamespaces{Names: []string{"team-a"}}, + policy: &NamespaceMatcher{Names: []string{"team-a"}}, nsName: "Team-A", want: false, }, { name: "selector match is allowed", - policy: &AllowedNamespaces{ + policy: &NamespaceMatcher{ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "prod"}}, }, nsName: "anything", @@ -131,7 +131,7 @@ func TestAllowsNamespace_Authorization(t *testing.T) { }, { name: "selector miss is denied", - policy: &AllowedNamespaces{ + policy: &NamespaceMatcher{ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "prod"}}, }, nsName: "anything", @@ -143,7 +143,7 @@ func TestAllowsNamespace_Authorization(t *testing.T) { // labels do not match. Requiring both would be a silent tightening that breaks // existing grants. name: "name allows even when the selector misses", - policy: &AllowedNamespaces{ + policy: &NamespaceMatcher{ Names: []string{"team-a"}, Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "prod"}}, }, @@ -153,7 +153,7 @@ func TestAllowsNamespace_Authorization(t *testing.T) { }, { name: "selector allows even when the name is unlisted", - policy: &AllowedNamespaces{ + policy: &NamespaceMatcher{ Names: []string{"team-a"}, Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "prod"}}, }, @@ -168,14 +168,14 @@ func TestAllowsNamespace_Authorization(t *testing.T) { // 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: &AllowedNamespaces{Selector: &metav1.LabelSelector{}}, + 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: &AllowedNamespaces{Selector: &metav1.LabelSelector{}}, + policy: &NamespaceMatcher{Selector: &metav1.LabelSelector{}}, nsName: "bare", labels: nil, want: true, @@ -185,7 +185,7 @@ func TestAllowsNamespace_Authorization(t *testing.T) { // 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: &AllowedNamespaces{ + policy: &NamespaceMatcher{ Selector: &metav1.LabelSelector{ MatchExpressions: []metav1.LabelSelectorRequirement{ {Key: "tier", Operator: "NotAnOperator", Values: []string{"prod"}}, @@ -202,7 +202,7 @@ func TestAllowsNamespace_Authorization(t *testing.T) { // 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: &AllowedNamespaces{ + policy: &NamespaceMatcher{ Names: []string{"team-a"}, Selector: &metav1.LabelSelector{ MatchExpressions: []metav1.LabelSelectorRequirement{ diff --git a/api/v1alpha3/namespace_matcher.go b/api/v1alpha3/namespace_matcher.go new file mode 100644 index 00000000..305717df --- /dev/null +++ b/api/v1alpha3/namespace_matcher.go @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha3 + +import ( + "fmt" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/util/validation" +) + +// NamespaceMatcher is the one deny-by-default namespace-policy SHAPE this API uses wherever 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. Every use of this shape is authorization, and the fail-open reading +// is the catastrophic one — so an absent field means "no policy declared" (which each call site +// interprets in its own legacy terms) while a declared-but-empty one means "admit nothing". +// +// Two fields use it, and they mean namespaces in DIFFERENT clusters — which is exactly why the +// shape is shared but the fields are not: +// +// - ClusterProvider.spec.allowedNamespaces — control-cluster namespaces that may create a +// GitTarget using the provider. Selector labels come from the CONTROL cluster. +// - GitTarget.spec.allowedSourceNamespaces — source-cluster namespaces that may be mirrored +// into this target, by any rule kind. Selector labels come from the SOURCE cluster. +// +// Because the two clusters differ, the LABEL half cannot be evaluated by one shared helper: only +// the caller knows which cluster's Namespace labels to read. Matches therefore takes the labels +// rather than fetching them, and MatchesName exists so an exact-name policy stays answerable when +// the labels cannot be read at all (see the source-scope service's degradation path). +type NamespaceMatcher struct { + // Design rationale, kept out of the generated CRD description by the blank line below. + // + // `*` is rejected for a reason that is not cosmetic: Kubernetes treats it as a LITERAL namespace + // name — a list or watch against `namespaces/*` matches nothing — so `names: ["*"]` would + // resolve a `sourceNamespace: "*"` item to a namespace that cannot exist. The rule would report + // itself authorized, plan a stream, and mirror NOTHING: a silent no-op wearing a green + // condition, which is precisely what the NoAdmittedSourceNamespaces reason exists to make loud. + // `selector: {}` is the "every namespace" form because it resolves live and keeps the snapshot + // and audit guarantees a pattern would bypass. + + // 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 on purpose: the name half needs NO Namespace read, so a policy that +// admits by name keeps working against a cluster whose Namespace list/watch is Forbidden. Callers +// that can fail to read labels must consult this FIRST and only then fall through to the selector. +// 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 +} + +// ValidateNames reports the first entry in Names that could never be a namespace name, or nil when +// every entry could be one. +// +// The schema rejects these at admission, so this is the DEFENSIVE half: an object stored before that +// validation shipped keeps its value in etcd, where the only two options are to refuse it loudly or +// to honour the entries that happen to be valid. The second is silent narrowing — a policy that +// mirrors less than its author asked for, with nothing in status saying so — which is why callers +// must treat a non-nil error as "this policy cannot be evaluated as written" rather than as a +// smaller policy. +func (m *NamespaceMatcher) ValidateNames() error { + if m == nil { + return nil + } + for _, n := range m.Names { + if errs := validation.IsDNS1123Label(n); len(errs) > 0 { + return fmt.Errorf("names[%q] is not a namespace name: %s", n, strings.Join(errs, "; ")) + } + } + return nil +} + +// HasSelector reports whether the matcher declares a label selector, i.e. whether evaluating it +// requires reading the Namespace's labels in that field's own cluster. +func (m *NamespaceMatcher) HasSelector() bool { + return m != nil && m.Selector != nil +} + +// Declared reports whether a policy exists at all. A nil matcher is "no policy declared"; a +// non-nil one is a declared policy even when it is empty (and an empty declared policy admits +// nothing). The distinction is load-bearing: an absent field keeps a caller's legacy scope, while +// a declared one is exhaustive. +func (m *NamespaceMatcher) Declared() bool { + return m != nil +} + +// SelectorAdmits reports whether the matcher's SELECTOR half alone — ignoring Names — admits a +// namespace carrying these labels. It exists for ENUMERATION: expanding a wildcard means asking +// this of every namespace in a snapshot, which Matches cannot do because its name check would +// short-circuit per candidate. +// +// A nil matcher or a nil selector admits nothing; a present-but-EMPTY selector admits everything. +// That asymmetry is not incidental — LabelSelectorAsSelector returns labels.Nothing() for nil and +// labels.Everything() for an empty selector, which is exactly the absent-versus-declared +// distinction this type is built around, and `selector: {}` is the deliberate "every namespace" +// declaration. +func (m *NamespaceMatcher) SelectorAdmits(nsLabels map[string]string) (bool, error) { + if m == nil || 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 +} + +// 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 new file mode 100644 index 00000000..71c27993 --- /dev/null +++ b/api/v1alpha3/namespace_matcher_test.go @@ -0,0 +1,216 @@ +// 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 both policies depend 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") + assert.False(t, nilMatcher.Declared(), "a nil matcher declares no policy") + + 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") + assert.True(t, empty.Declared(), "but it IS declared, which is what makes it exhaustive") +} + +// TestNamespaceMatcher_ValidateNamesRejectsPatterns pins the half of the policy that cannot be +// expressed as a pattern. +// +// `*` is the case that matters and the reason is counter-intuitive: it is NOT interpreted as "every +// namespace" anywhere in the stack. Kubernetes treats `namespaces/*` as a literal name, so a policy +// carrying it resolves a wildcard item to a namespace that can never exist — and the rule then +// reports itself authorized while mirroring nothing. Refusing the name is what turns that silent +// no-op into something an operator can see. +func TestNamespaceMatcher_ValidateNamesRejectsPatterns(t *testing.T) { + var nilMatcher *NamespaceMatcher + assert.NoError(t, nilMatcher.ValidateNames(), "a nil matcher has no names to reject") + assert.NoError(t, (&NamespaceMatcher{}).ValidateNames(), "nor does a declared-but-empty one") + + valid := &NamespaceMatcher{Names: []string{"repo-config", "tenant-acme", "a"}} + assert.NoError(t, valid.ValidateNames(), "real namespace names stay valid") + + tests := map[string]string{ + "the wildcard": "*", + "a prefix pattern": "tenant-*", + "an empty name": "", + "an uppercase name": "Repo-Config", + "a path-ish name": "team/repo-config", + "a trailing separator": "repo-config-", + "a name over 63 chars": "n0123456789012345678901234567890123456789012345678901234567890123", + } + for name, value := range tests { + t.Run(name, func(t *testing.T) { + err := (&NamespaceMatcher{Names: []string{value}}).ValidateNames() + require.Error(t, err, "%q could never be a namespace name", value) + assert.Contains(t, err.Error(), "is not a namespace name") + }) + } + + // One bad entry condemns the whole policy: honouring the valid remainder is silent narrowing. + mixed := &NamespaceMatcher{Names: []string{"repo-config", "*"}} + assert.Error(t, mixed.ValidateNames(), "a policy is not partially evaluatable") +} + +// TestNamespaceMatcher_NamesAndSelectorAreOred covers the OR contract and, more importantly, that +// the NAME half never consults labels — the property that keeps exact-name policies working +// against a cluster whose Namespace reads are denied. +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")) + assert.True(t, matcher.HasSelector()) +} + +// 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) +} + +// TestNamespaceMatcher_EmptySelectorMatchesEverything pins the asymmetry the whole "*" design rests +// on: LabelSelectorAsSelector returns labels.Nothing() for a NIL selector and labels.Everything() +// for a present-but-EMPTY one, which is exactly the absent-versus-declared distinction this type is +// built around. `selector: {}` is the deliberate "every source namespace" declaration, so if this +// ever flipped a target would silently stop admitting anything. +func TestNamespaceMatcher_EmptySelectorMatchesEverything(t *testing.T) { + declared := &NamespaceMatcher{Selector: &metav1.LabelSelector{}} + admits, err := declared.SelectorAdmits(nil) + require.NoError(t, err) + assert.True(t, admits, "a present-but-empty selector admits EVERY namespace, labels or not") + + admits, err = declared.SelectorAdmits(map[string]string{"anything": "at-all"}) + require.NoError(t, err) + assert.True(t, admits) + + absent := &NamespaceMatcher{Names: []string{"repo-config"}} + admits, err = absent.SelectorAdmits(map[string]string{"anything": "at-all"}) + require.NoError(t, err) + assert.False(t, admits, "a nil selector admits nothing — names are the caller's to union") + + var nilMatcher *NamespaceMatcher + admits, err = nilMatcher.SelectorAdmits(nil) + require.NoError(t, err) + assert.False(t, admits) +} + +// 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, even against a policy that lists only the rule's own namespace: it + // asks to follow the policy's set, and a later policy edit must not widen the watch without the + // platform-admin opt-in. + item.SourceNamespace = SourceNamespaceWildcard + assert.True(t, item.IsSourceNamespaceWildcard()) + assert.True(t, item.OverridesSourceNamespace(own)) +} + +// TestClusterWatchRuleSpec_DeclaresNamespacedScope covers the other half of decision 10. The +// refusal keys on the STORED value, never on what the selector happens to resolve. +func TestClusterWatchRuleSpec_DeclaresNamespacedScope(t *testing.T) { + clusterOnly := ClusterWatchRuleSpec{Rules: []ClusterResourceRule{ + {Resources: []string{"customresourcedefinitions"}, Scope: ResourceScopeCluster}, + {Resources: []string{"*"}}, + }} + assert.False(t, clusterOnly.DeclaresNamespacedScope(), + "an omitted scope defaults to Cluster and a wildcard selector is not itself a refusal") + + stored := ClusterWatchRuleSpec{Rules: []ClusterResourceRule{ + {Resources: []string{"nodes"}, Scope: ResourceScopeCluster}, + {Resources: []string{"configmaps"}, Scope: ResourceScopeNamespaced}, + }} + assert.True(t, stored.DeclaresNamespacedScope(), "one namespaced item refuses the whole rule") +} + +// TestGitTarget_SourceNamespacePolicy checks the two thin wrappers stay thin: a declared policy is +// distinguishable from an absent one, and the source-side predicate matches the shared shape. +func TestGitTarget_SourceNamespacePolicy(t *testing.T) { + target := &GitTarget{} + assert.False(t, target.DeclaresSourceNamespacePolicy()) + + allowed, err := target.AllowsSourceNamespace("repo-config", nil) + require.NoError(t, err) + assert.False(t, allowed, "an undeclared policy admits nothing; the legacy rule is the caller's") + + target.Spec.AllowedSourceNamespaces = &NamespaceMatcher{Names: []string{"repo-config"}} + assert.True(t, target.DeclaresSourceNamespacePolicy()) + + allowed, err = target.AllowsSourceNamespace("repo-config", nil) + require.NoError(t, err) + assert.True(t, allowed) +} + +// TestClusterProvider_DelegationFlagDefaultsClosed is the security default in one line: the flag +// must be false on a provider that never mentions it. +func TestClusterProvider_DelegationFlagDefaultsClosed(t *testing.T) { + provider := &ClusterProvider{} + assert.False(t, provider.AllowsSourceNamespaceOverride(), + "source-namespace override must never be on by default") +} diff --git a/api/v1alpha3/watchrule_types.go b/api/v1alpha3/watchrule_types.go index 8619cfe2..05fae6f6 100644 --- a/api/v1alpha3/watchrule_types.go +++ b/api/v1alpha3/watchrule_types.go @@ -3,6 +3,8 @@ package v1alpha3 import ( + "fmt" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -42,16 +44,18 @@ type LocalTargetReference struct { } // WatchRuleSpec defines the desired state of WatchRule. -// WatchRule watches resources ONLY within its own namespace. +// 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 GitTarget admits. type WatchRuleSpec struct { // TargetRef references the GitTarget to use. // Must be in the same namespace. // +required TargetRef LocalTargetReference `json:"targetRef"` - // Rules define which resources to watch within this namespace. + // Rules define which resources to watch, and in which source namespaces. // Multiple rules create a logical OR - a resource matching ANY rule is watched. - // Each rule can specify operations, API groups, versions, and resource types. + // Each rule can specify operations, API groups, versions, resource types, and a source namespace. // +required // +kubebuilder:validation:MinItems=1 Rules []ResourceRule `json:"rules"` @@ -115,6 +119,78 @@ type ResourceRule struct { // +kubebuilder:validation:items:MinLength=1 // +kubebuilder:validation:items:Pattern=`^[^/]*$` Resources []string `json:"resources"` + + // Design rationale, kept out of the generated CRD description by the blank line below. + // + // Every item's outcome is aggregated into the ONE SourceNamespaceAuthorized condition, so + // automation has a single condition to inspect. A denied explicit name refuses the whole + // WatchRule rather than silently trimming that item: mirroring two of the three namespaces a + // rule asked for is worse than a loud failure. A "*" that currently admits nothing is not a + // refusal — it is valid, starts no stream, and says so as NoAdmittedSourceNamespaces, because a + // rule that mirrors nothing while reporting Ready with no explanation is a silent no-op. + // + // Cost: a "*" item opens one watch stream per (matched type × admitted namespace) and one + // resync scope each, rather than one cluster-wide stream. That is deliberate — it keeps every + // replay scoped to a single namespace — but it is a real fan-out on a broad policy. + + // SourceNamespace is the namespace this item watches IN THE SOURCE CLUSTER its GitTarget + // mirrors from: omitted for this WatchRule's own namespace, an exact name for one other, or + // "*" for every namespace the GitTarget's spec.allowedSourceNamespaces currently admits. + // + // "*" never means "every namespace that exists" — it expands to exactly what that policy + // admits, so a target declaring no policy denies it. Naming any namespace other than this + // rule's own, "*" included, additionally requires the GitTarget's ClusterProvider to admit the + // target's namespace AND to set spec.allowSourceNamespaceOverride. Once the GitTarget declares + // a policy it is exhaustive, so even an omitted sourceNamespace is checked against it. + // + // This changes only which namespace is WATCHED, never where objects are written: Git placement + // follows each mirrored object's own namespace. + // +optional + // +kubebuilder:validation:MaxLength=63 + // +kubebuilder:validation:Pattern=`^(\*|[a-z0-9]([-a-z0-9]*[a-z0-9])?)$` + SourceNamespace string `json:"sourceNamespace,omitempty"` +} + +// SourceNamespaceWildcard is the literal rules[].sourceNamespace token meaning "every source +// namespace this rule's GitTarget admits" — resolved live through +// GitTarget.spec.allowedSourceNamespaces, never "every namespace that exists". +const SourceNamespaceWildcard = "*" + +// EffectiveSourceNamespace is the source-cluster namespace this ITEM names, given the namespace of +// the WatchRule that carries it: spec.rules[].sourceNamespace when set, and the rule's OWN +// namespace otherwise. For a wildcard item it returns "*" — the caller must expand that through +// the GitTarget's policy rather than treat it as a namespace name. +// +// It is controller logic rather than an API-server default because an apiserver default cannot +// refer to metadata.namespace. +func (r *ResourceRule) EffectiveSourceNamespace(ruleNamespace string) string { + if r.SourceNamespace != "" { + return r.SourceNamespace + } + return ruleNamespace +} + +// IsSourceNamespaceWildcard reports whether this item asks to follow its GitTarget's admitted set. +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 ClusterProvider'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, even against a policy that happens to list only that namespace, because a later +// policy edit would otherwise widen the watch with no platform-admin opt-in. +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 { + if r.SourceNamespace == "" { + return fmt.Sprintf("%q (omitted; the WatchRule's own namespace)", ruleNamespace) + } + return fmt.Sprintf("%q", r.SourceNamespace) } // WatchRuleStatus defines the observed state of WatchRule. @@ -165,6 +241,14 @@ type WatchRuleStreamsStatus struct { ObservedTime *metav1.Time `json:"observedTime,omitempty"` } +// Design rationale, kept out of the generated CRD description by the blank line below. +// +// The source-namespace gate is deny-by-default and re-evaluated on EVERY reconcile, which is what +// makes a policy tightened after a rule was accepted revoke that rule rather than grandfather it. +// Where the source is the operator's OWN cluster, an authorized override deliberately bypasses live +// namespace RBAC — the operator reads through its own cluster-wide credential — which is why it +// takes an explicit platform-admin delegation on the ClusterProvider to enable at all. + // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:resource:scope=Namespaced @@ -174,16 +258,17 @@ type WatchRuleStreamsStatus struct { // +kubebuilder:printcolumn:name="Streams",type=string,JSONPath=`.status.streams.summary` // +kubebuilder:printcolumn:name="GitTargetReady",type=string,JSONPath=`.status.conditions[?(@.type=="GitTargetReady")].status`,priority=1 // +kubebuilder:printcolumn:name="StreamsRunning",type=string,JSONPath=`.status.conditions[?(@.type=="StreamsRunning")].status`,priority=1 +// +kubebuilder:printcolumn:name="SourceAuthorized",type=string,JSONPath=`.status.conditions[?(@.type=="SourceNamespaceAuthorized")].status`,priority=1 // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` -// WatchRule watches namespaced resources within its own namespace. -// It provides fine-grained control over which resources trigger Git commits, -// with filtering by operation type, API group, version, and labels. +// WatchRule selects NAMESPACED resources on the source cluster its GitTarget mirrors from, with +// filtering by operation, API group, version, and source namespace. Scope is carried by the rule +// KIND: a WatchRule never selects cluster-scoped types — use a ClusterWatchRule for those. // -// Security model: -// - WatchRule is namespace-scoped and can only watch resources in its own namespace -// - Use ClusterWatchRule for watching cluster-scoped resources (Nodes, ClusterRoles, etc.) -// - RBAC controls who can create/modify WatchRules per namespace +// Each spec.rules[] item watches its own source namespace: this WatchRule's OWN namespace when +// omitted, an explicit name, or "*" for every namespace the GitTarget admits. Anything other than +// its own namespace passes the gate described on rules[].sourceNamespace. RBAC controls who may +// create or modify WatchRules per namespace. type WatchRule struct { metav1.TypeMeta `json:",inline"` diff --git a/api/v1alpha3/zz_generated.deepcopy.go b/api/v1alpha3/zz_generated.deepcopy.go index f9c991ed..ca596b1d 100644 --- a/api/v1alpha3/zz_generated.deepcopy.go +++ b/api/v1alpha3/zz_generated.deepcopy.go @@ -48,31 +48,6 @@ func (in *AgeRecipientsSpec) DeepCopy() *AgeRecipientsSpec { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *AllowedNamespaces) DeepCopyInto(out *AllowedNamespaces) { - *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 AllowedNamespaces. -func (in *AllowedNamespaces) DeepCopy() *AllowedNamespaces { - if in == nil { - return nil - } - out := new(AllowedNamespaces) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterProvider) DeepCopyInto(out *ClusterProvider) { *out = *in @@ -157,7 +132,7 @@ func (in *ClusterProviderSpec) DeepCopyInto(out *ClusterProviderSpec) { } if in.AllowedNamespaces != nil { in, out := &in.AllowedNamespaces, &out.AllowedNamespaces - *out = new(AllowedNamespaces) + *out = new(NamespaceMatcher) (*in).DeepCopyInto(*out) } if in.QPS != nil { @@ -778,6 +753,11 @@ func (in *GitTargetSpec) DeepCopyInto(out *GitTargetSpec) { *out = new(ClusterProviderReference) **out = **in } + if in.AllowedSourceNamespaces != nil { + in, out := &in.AllowedSourceNamespaces, &out.AllowedSourceNamespaces + *out = new(NamespaceMatcher) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitTargetSpec. @@ -886,6 +866,31 @@ func (in *LocalTargetReference) DeepCopy() *LocalTargetReference { 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 *NamespacedTargetReference) DeepCopyInto(out *NamespacedTargetReference) { *out = *in diff --git a/config/crd/bases/configbutler.ai_clusterproviders.yaml b/config/crd/bases/configbutler.ai_clusterproviders.yaml index 2135e940..71a8385e 100644 --- a/config/crd/bases/configbutler.ai_clusterproviders.yaml +++ b/config/crd/bases/configbutler.ai_clusterproviders.yaml @@ -36,19 +36,17 @@ spec: schema: openAPIV3Schema: description: |- - ClusterProvider is the cluster-scoped, read-side peer of GitProvider: it names a SOURCE cluster - a GitTarget mirrors FROM, and is the home for that cluster's connectivity credential - (spec.kubeConfig), namespace-access authorization (spec.allowedNamespaces), and per-cluster - status. Its NAME is the cluster's identity everywhere: the /audit-webhook/ ingress route - and the attribution fact-index key. No name is special: "default" is merely the name - GitTarget.spec.clusterProviderRef defaults to when omitted. Whether a provider is the operator's - own cluster or a remote follows from spec.kubeConfig (omitted = in-cluster), not from its name, - so "default" may just as well name a remote cluster. + ClusterProvider is the cluster-scoped, read-side peer of GitProvider: it names a SOURCE cluster a + GitTarget mirrors FROM, and owns that cluster's connectivity credential (spec.kubeConfig), + namespace-access authorization (spec.allowedNamespaces), and per-cluster status. Its NAME is the + cluster's identity everywhere — the /audit-webhook/ ingress route and the attribution + fact-index key. No name is special: "default" is merely what an omitted + GitTarget.spec.clusterProviderRef points at, and it may just as well name a remote cluster, since + in-cluster-ness follows from spec.kubeConfig (omitted = in-cluster) rather than from the name. - Security model: - - ClusterProvider is cluster-scoped and requires platform-admin permissions to create. - - A GitTarget may reference it only from a namespace its spec.allowedNamespaces admits - (deny-by-default), enforced at admission AND before any watch starts. + It is cluster-scoped and requires platform-admin permissions to create. A GitTarget may reference + it only from a namespace spec.allowedNamespaces admits — deny-by-default, enforced at admission + and again before any watch starts. properties: apiVersion: description: |- @@ -70,22 +68,42 @@ spec: spec: description: spec defines the desired state of ClusterProvider. properties: + allowSourceNamespaceOverride: + default: false + description: |- + AllowSourceNamespaceOverride delegates SOURCE-namespace selection to the GitTargets this + provider admits. While false (the default) a WatchRule mirroring through this provider may + watch only its OWN namespace, whatever any GitTarget policy says. + + It grants no access by itself: an admitted GitTarget must still admit the namespace in its + spec.allowedSourceNamespaces, and the source credential's own RBAC remains the hard maximum. + What it delegates is the AUTHORITY to choose, so set it only when the owners of admitted + GitTargets are trusted to pick a subset of what that credential may read. Every + cross-namespace request needs it, including a rules[].sourceNamespace of "*". It does not + apply to ClusterWatchRule, which selects no namespaces at all. + type: boolean allowedNamespaces: description: |- - AllowedNamespaces is the deny-by-default policy for which namespaces may reference this - provider from a GitTarget. Empty (or omitted) means no namespace may reference it. + AllowedNamespaces is the deny-by-default policy for which CONTROL-CLUSTER namespaces may + reference this provider from a GitTarget. Empty (or omitted) means no namespace may + reference it. Its selector matches labels on Namespaces in the control cluster — the + cluster the operator's own CRs live in — never on the source cluster this provider names. properties: names: - description: Names is an explicit allow-list of namespace names - that may reference this provider. + 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 may reference this provider. ORed with Names. + match is admitted. ORed with Names. properties: matchExpressions: description: matchExpressions is a list of label selector @@ -140,13 +158,14 @@ spec: type: integer kubeConfig: description: |- - KubeConfig names the SOURCE CLUSTER this provider represents and the credentials to reach - it (Flux's meta.KubeConfigReference, embedded verbatim). OMITTED means the operator's own - in-cluster cluster, for any provider name. IMMUTABLE. The referenced Secret is - resolved from the operator's namespace — the credential for a cluster never has to live on - that cluster. When secretRef.key is empty the resolver reads "value" then "value.yaml" - (Flux's order). Only secretRef is honored (configMapRef is rejected); unsafe kubeconfigs - (exec auth, insecure-skip-tls-verify) are rejected with a Validated=False reason unless the + KubeConfig names the SOURCE CLUSTER this provider represents and the credentials to reach it + (Flux's meta.KubeConfigReference, embedded verbatim). OMITTED means the operator's own + in-cluster cluster, for any provider name. IMMUTABLE. + + The referenced Secret is resolved from the operator's namespace, so a cluster's credential + never has to live on that cluster. Only secretRef is honored; configMapRef is rejected. When + secretRef.key is empty the resolver reads "value" then "value.yaml" (Flux's order). Unsafe + kubeconfigs (exec auth, insecure-skip-tls-verify) are rejected with Validated=False unless the operator opts in via flags. properties: configMapRef: diff --git a/config/crd/bases/configbutler.ai_clusterwatchrules.yaml b/config/crd/bases/configbutler.ai_clusterwatchrules.yaml index ba75983d..549b38bd 100644 --- a/config/crd/bases/configbutler.ai_clusterwatchrules.yaml +++ b/config/crd/bases/configbutler.ai_clusterwatchrules.yaml @@ -42,20 +42,14 @@ spec: schema: openAPIV3Schema: description: |- - ClusterWatchRule watches resources across the entire cluster. - It provides the ability to audit both cluster-scoped resources (Nodes, ClusterRoles, CRDs) - and namespaced resources across multiple namespaces with per-rule filtering. + ClusterWatchRule selects CLUSTER-SCOPED resources on the source cluster its GitTarget mirrors + from — Nodes, PersistentVolumes, StorageClasses, ClusterRoles, CRDs, and the like. Scope is + carried by the rule KIND, so it has no per-rule scope choice and no source-namespace selection. - Security model: - - ClusterWatchRule is cluster-scoped and requires cluster-admin permissions - - It references a GitTarget via targetRef (namespace required) - - Each rule can independently specify Cluster or Namespaced scope - - Use cases: - - Audit cluster infrastructure (Nodes, PersistentVolumes, StorageClasses) - - Audit RBAC changes (ClusterRoles, ClusterRoleBindings) - - Audit CRD installations and updates - - Audit resources across multiple namespaces (e.g., all production namespaces) + It is cluster-scoped and requires cluster-admin permissions. Its targetRef names a GitTarget + (namespace required), whose namespace must be admitted by that target's ClusterProvider. To + mirror NAMESPACED resources use a WatchRule in the tenant namespace and set + spec.rules[].sourceNamespace, whose "*" reaches every namespace the GitTarget admits. properties: apiVersion: description: |- @@ -79,14 +73,14 @@ spec: properties: rules: description: |- - Rules define which resources to watch. + Rules define which CLUSTER-SCOPED resources to watch. Multiple rules create a logical OR - a resource matching ANY rule is watched. - Each rule can specify cluster-scoped or namespaced resources. + A rule that resolves to no cluster-scoped type simply watches nothing; use a WatchRule with + spec.rules[].sourceNamespace for namespaced resources. items: description: |- - ClusterResourceRule defines which resources to watch with scope control. - Each rule independently specifies whether it watches cluster-scoped or - namespaced resources. + ClusterResourceRule defines which CLUSTER-SCOPED resources to watch. It deliberately has no + sourceNamespace: cluster-scoped objects have no namespace, so there is nothing to select. properties: apiGroups: description: |- @@ -150,22 +144,20 @@ spec: minItems: 1 type: array scope: - allOf: - - enum: - - Cluster - - Namespaced - - enum: - - Cluster - - Namespaced + default: Cluster description: |- - Scope defines whether this rule watches Cluster-scoped or Namespaced resources. - - "Cluster": For cluster-scoped resources (Nodes, ClusterRoles, CRDs, etc.). - - "Namespaced": For namespaced resources (Pods, Deployments, Secrets, etc.), - across all namespaces. + Scope is REMOVED as a choice: a ClusterWatchRule is cluster-scoped only, so "Cluster" is the + only accepted value and also the default, making the field omittable. To watch NAMESPACED + resources, use a WatchRule in the tenant namespace and set spec.rules[].sourceNamespace. + + Deprecated: ClusterWatchRule is cluster-scope-only; use WatchRule with + spec.rules[].sourceNamespace for namespaced resources. Removed one release from now, or at + v1beta1. + enum: + - Cluster type: string required: - resources - - scope type: object minItems: 1 type: array diff --git a/config/crd/bases/configbutler.ai_gittargets.yaml b/config/crd/bases/configbutler.ai_gittargets.yaml index eb1310be..8a1bf299 100644 --- a/config/crd/bases/configbutler.ai_gittargets.yaml +++ b/config/crd/bases/configbutler.ai_gittargets.yaml @@ -93,6 +93,81 @@ spec: spec: description: spec defines the desired state of GitTarget properties: + allowedSourceNamespaces: + description: |- + AllowedSourceNamespaces bounds which SOURCE-cluster namespaces may be mirrored INTO this + target. It belongs to the DESTINATION, not to any requesting rule: once declared it is + exhaustive for every WatchRule that writes here, with no exception for a rule's own namespace. + + Omitted and empty differ. Omitted declares no policy, and a WatchRule keeps its own namespace; + a declared-but-empty policy admits nothing; `selector: {}` admits every source namespace. + Selector labels are read in the SOURCE cluster, so evaluating one needs Namespace + get/list/watch for that cluster's credential, while exact names need no such access. This is + also what a rules[].sourceNamespace of "*" resolves through. Naming any namespace other than + the WatchRule's own — including "*" — additionally requires the ClusterProvider to set + spec.allowSourceNamespaceOverride. It does NOT bound ClusterWatchRule, whose cluster-scoped + objects have no namespace. Full resolution table: docs/configuration.md. + 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: + 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 + type: object branch: description: |- Branch to use for this target. @@ -105,13 +180,10 @@ spec: name: default description: |- ClusterProviderRef names the SOURCE cluster this GitTarget mirrors FROM, by referencing a - cluster-scoped ClusterProvider by name. The ClusterProvider is the home for that cluster's - connectivity credential, namespace-access authorization, and author-attribution mode. This - DEFAULTS to {name: "default"} — a user-created provider by that conventional name, which may - be in-cluster or remote — so a target that omits it persists with a concrete, jumpable ref - rather than an implicit nil. The operator never creates that provider; a GitTarget naming one - that does not exist is held unready. Immutable: a folder's source cluster is part of what the - folder means; delete and recreate to change it. + cluster-scoped ClusterProvider by name. That ClusterProvider owns the cluster's connectivity + credential, namespace-access authorization, and author-attribution mode. The default provider + name is "default" and must exist; it may be in-cluster or remote. + Immutable: a folder's source cluster is part of what the folder means; delete and recreate. properties: group: default: configbutler.ai diff --git a/config/crd/bases/configbutler.ai_watchrules.yaml b/config/crd/bases/configbutler.ai_watchrules.yaml index d47d05f4..af458b92 100644 --- a/config/crd/bases/configbutler.ai_watchrules.yaml +++ b/config/crd/bases/configbutler.ai_watchrules.yaml @@ -35,6 +35,10 @@ spec: name: StreamsRunning priority: 1 type: string + - jsonPath: .status.conditions[?(@.type=="SourceNamespaceAuthorized")].status + name: SourceAuthorized + priority: 1 + type: string - jsonPath: .metadata.creationTimestamp name: Age type: date @@ -42,14 +46,14 @@ spec: schema: openAPIV3Schema: description: |- - WatchRule watches namespaced resources within its own namespace. - It provides fine-grained control over which resources trigger Git commits, - with filtering by operation type, API group, version, and labels. + WatchRule selects NAMESPACED resources on the source cluster its GitTarget mirrors from, with + filtering by operation, API group, version, and source namespace. Scope is carried by the rule + KIND: a WatchRule never selects cluster-scoped types — use a ClusterWatchRule for those. - Security model: - - WatchRule is namespace-scoped and can only watch resources in its own namespace - - Use ClusterWatchRule for watching cluster-scoped resources (Nodes, ClusterRoles, etc.) - - RBAC controls who can create/modify WatchRules per namespace + Each spec.rules[] item watches its own source namespace: this WatchRule's OWN namespace when + omitted, an explicit name, or "*" for every namespace the GitTarget admits. Anything other than + its own namespace passes the gate described on rules[].sourceNamespace. RBAC controls who may + create or modify WatchRules per namespace. properties: apiVersion: description: |- @@ -73,9 +77,9 @@ spec: properties: rules: description: |- - Rules define which resources to watch within this namespace. + Rules define which resources to watch, and in which source namespaces. Multiple rules create a logical OR - a resource matching ANY rule is watched. - Each rule can specify operations, API groups, versions, and resource types. + Each rule can specify operations, API groups, versions, resource types, and a source namespace. items: description: |- ResourceRule defines a set of namespaced resources to watch. @@ -152,6 +156,23 @@ spec: type: string minItems: 1 type: array + sourceNamespace: + description: |- + SourceNamespace is the namespace this item watches IN THE SOURCE CLUSTER its GitTarget + mirrors from: omitted for this WatchRule's own namespace, an exact name for one other, or + "*" for every namespace the GitTarget's spec.allowedSourceNamespaces currently admits. + + "*" never means "every namespace that exists" — it expands to exactly what that policy + admits, so a target declaring no policy denies it. Naming any namespace other than this + rule's own, "*" included, additionally requires the GitTarget's ClusterProvider to admit the + target's namespace AND to set spec.allowSourceNamespaceOverride. Once the GitTarget declares + a policy it is exhaustive, so even an omitted sourceNamespace is checked against it. + + This changes only which namespace is WATCHED, never where objects are written: Git placement + follows each mirrored object's own namespace. + maxLength: 63 + pattern: ^(\*|[a-z0-9]([-a-z0-9]*[a-z0-9])?)$ + type: string required: - resources type: object diff --git a/config/samples/clusterwatchrule.yaml b/config/samples/clusterwatchrule.yaml index 8759cce9..da563599 100644 --- a/config/samples/clusterwatchrule.yaml +++ b/config/samples/clusterwatchrule.yaml @@ -7,8 +7,7 @@ spec: name: example-target namespace: default rules: - - scope: Cluster - operations: [CREATE, UPDATE, DELETE] + - operations: [CREATE, UPDATE, DELETE] apiGroups: ["apiextensions.k8s.io"] apiVersions: ["v1"] resources: ["customresourcedefinitions"] diff --git a/docs/INDEX.md b/docs/INDEX.md index ce92c857..0ecf433b 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -78,7 +78,7 @@ Eleven other open items: | [`e2e-finish-plan.md`](design/e2e-finish-plan.md) | remaining e2e harness work | | [`sensitive-resource-diagnostics-follow-up.md`](design/sensitive-resource-diagnostics-follow-up.md) | deferred diagnostics | | [`e2e-git-server-choice.md`](design/e2e-git-server-choice.md) | stay on Gitea or move to Forgejo — the `_csrf` pin is fixable in place on both, so the migration is now a preference call, not a fix; also why we adopt no SDK either way | -| [`watchrule-source-namespace/`](design/watchrule-source-namespace/README.md) | letting a WatchRule address a differently-named namespace on its source cluster — a deny-by-default `allowedSourceNamespaces` on the **GitTarget** (so scope is per-tenant, not a provider-wide union), unlocked by a false-by-default delegation flag on the ClusterProvider. Split into five implementable PRs: three prerequisite scope fixes (the namespace-blind resync sweep that would delete other namespaces' manifests, the cluster-wide/named stream collapse, and ClusterWatchRule's unchecked GitTarget attachment), the field and its gate, and the ceiling that makes the allow-list bind ClusterWatchRule too — required because a multi-tenant deployment runs a ClusterWatchRule per tenant from day one to capture CRDs | +| [`watchrule-source-namespace/`](design/watchrule-source-namespace/README.md) | letting a WatchRule address differently-named namespaces on its source cluster — a deny-by-default `allowedSourceNamespaces` on the **GitTarget** (so scope is per-tenant, not a provider-wide union), unlocked by a false-by-default delegation flag on the ClusterProvider. Five PRs: three landed prerequisite scope fixes (the namespace-blind resync sweep that would delete other namespaces' manifests, the cluster-wide/named stream collapse, and ClusterWatchRule's unchecked GitTarget attachment), then the breaking **scope-by-kind** change — `WatchRule.spec.rules[].sourceNamespace` (a name or `"*"` for the target's admitted set) and a cluster-scope-only ClusterWatchRule — and a GitTarget `prune.mode` that makes the resync sweep opt-in, released together with it | ## Deferred, but still wanted — [`future/`](future/) diff --git a/docs/TODO.md b/docs/TODO.md index 5914d2ae..8cfdce6e 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -65,6 +65,19 @@ This file is meant to track the smaller current backlog, not historical notes. - [ ] Reduce duplication between `WatchRule` and `ClusterWatchRule` code paths where it makes sense. +- [ ] Collapse wildcard source-namespace stream fan-out. + `WatchRule.spec.rules[].sourceNamespace: "*"` expands to one selection per admitted namespace, and + `targetWatchSpecs` opens one stream per `(GVR, namespace)` while `git.ResyncScope` names a single + namespace — so a wildcard over N admitted namespaces and M matched types costs N×M informers and + N×M resync scopes, where a cluster-wide ClusterWatchRule costs M. Expansion is deliberate (it is + what keeps the mark-and-sweep scope narrow, per + [pr1-namespace-scoped-resync.md](design/watchrule-source-namespace/pr1-namespace-scoped-resync.md)), + but the cost grows with tenant count. The direction is a cluster-wide stream whose resync scope + carries a namespace **set** rather than one name, so the gather stays exactly as narrow while the + stream count drops to M. Also revisit `WatchRuleStreamsStatus.PendingSample`, whose five-entry cap + stops being representative at N×M. Context: + [pr4-cluster-scope-only.md § wildcard fan-out](design/watchrule-source-namespace/pr4-cluster-scope-only.md#7-wildcard-fan-out-is-an-accepted-cost). + - [ ] Re-enable the `goconst` linter with a path-scoped exclusion instead of the current repo-wide disable in [.golangci.yml](../.golangci.yml). Exempting `test/` and `internal/git/commit.go` would silence the existing noise (~45 findings, mostly test fixtures) while still catching diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index 9a2a2a4e..2fa299fa 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -7,6 +7,103 @@ 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. +## Unreleased — scope is now carried by the rule kind (next minor; breaking) + +**Scope moved from a per-rule field onto the rule KIND.** `WatchRule` is the namespaced surface and +gained `spec.rules[].sourceNamespace`; `ClusterWatchRule` is cluster-scoped only and lost its scope +choice. There is deliberately **no migration tool**: the conversion is cross-kind, so a conversion +webhook cannot perform it. + +```yaml +# WatchRule — each rule item names the source namespace it watches. +spec: + rules: + - resources: [configmaps] # omitted → this WatchRule's own namespace + - resources: [secrets] + sourceNamespace: repo-config # one admitted source namespace + - resources: [deployments] + sourceNamespace: "*" # every namespace the GitTarget admits, live +``` + +### Two capabilities are removed on purpose + +- **Platform-authored namespaced mirroring from outside the tenant namespace.** A `ClusterWatchRule`'s + cross-namespace `targetRef` let a platform team mirror a tenant's namespaced resources with no + object in that tenant's namespace. A platform administrator may still own the manifest, but it must + now live in the tenant namespace. +- **Rule-author-declared all-namespace watching.** `scope: Namespaced` let the rule author reach every + namespace. The replacement is destination-declared: + `GitTarget.spec.allowedSourceNamespaces: {selector: {}}` plus `sourceNamespace: "*"` — same reach, + declared by the `GitTarget` owner rather than by the rule author, and legible on the target. + +### `ClusterWatchRule.spec.rules[].scope` is retained for one release as a loud rejection + +The field was not deleted, because deleting it is the **silent** option: CRD pruning happens on +write, so a re-applied legacy manifest would be accepted with the value dropped — no error anywhere — +and the rule would quietly stop mirroring namespaced objects. + +It is now optional, defaults to `Cluster`, and its enum accepts only `Cluster`. Applying +`scope: Namespaced` is **rejected at apply time**, and a stored one is refused at compile with +`ClusterScopeOnly`. The field is removed entirely one release from now, or at `v1beta1`. + +No such shim exists for `WatchRule.spec.sourceNamespace`, and none is needed: that field never +reached a release, so no stored object can carry it and no manifest in the wild sets it. It is simply +absent — the source namespace lives on `spec.rules[].sourceNamespace`. + +### `ClusterProvider.spec.allowWatchRuleSourceNamespaceOverride` → `allowSourceNamespaceOverride` + +A plain rename with no deprecation shim: the field never reached a release, so no stored object can +carry the old key. Semantics, and the `false` default, are unchanged. It is required for **every** +cross-source-namespace request, including `sourceNamespace: "*"`. + +### Migration + +List every affected object and its target: + +```bash +kubectl get clusterwatchrules -o json | jq -r ' + .items[] + | select(any(.spec.rules[]; .scope == "Namespaced")) + | "\(.metadata.name)\ttarget=\(.spec.targetRef.namespace)/\(.spec.targetRef.name)\tnamespaced-rules=\( + [.spec.rules[] | select(.scope == "Namespaced") | (.resources | join(","))] | join(" | ") + )"' +``` + +For each one: + +1. **Split it.** Any cluster-scoped items stay behind in a revised `ClusterWatchRule` (drop their + `scope:` line — it defaults to `Cluster`). If none remain, delete the object. +2. **Create a `WatchRule` in the TENANT namespace** — the namespace of the `GitTarget` the rule + targets — carrying the namespaced items. Each becomes `sourceNamespace: "*"` to keep cluster-wide + reach, or an explicit name where you know the one namespace you meant. +3. **Declare the target's policy.** This is the step to get right: + **a `GitTarget` that declares no `allowedSourceNamespaces` admits NOTHING to a `"*"` item, and a + declared policy is exhaustive with no self-namespace exception.** Converting without also + declaring the policy therefore *narrows production data*. Use `selector: {}` for "every source + namespace", `names: [...]` for an explicit set, and remember to include any co-resident legacy + `WatchRule`'s own namespace. +4. **Delegate on the provider.** Set `allowSourceNamespaceOverride: true` on the `ClusterProvider` + the target mirrors through; without it every non-own-namespace item, `"*"` included, is refused. + +A narrowing that slips through is visible rather than silent — `SourceNamespaceAuthorized=False`, +`Stalled=True`, streams stopped, and a message naming the failing item — but the documents already in +Git are governed by `GitTarget.spec.prune.mode`, which ships in the same release and defaults to +`onEvent`: prior documents are **left in place** rather than swept. (The two changes are never +released apart.) Verify with `kubectl get watchrules -o wide`, whose `SourceAuthorized` column carries +the verdict. + +### Rolling back + +**Rolling the controller back past this release is unsupported while migrated manifests exist.** The +previous controller neither understands `rules[].sourceNamespace` (so a rule resolves to its own +namespace — a *narrower* desired set) nor has `prune.mode` (so a resync sweeps). Together that +deletes the mirrored namespaces' documents. If a rollback is unavoidable, remove or narrow the +affected `WatchRule`s **first**. + +The same skew exists inside a rolling upgrade: CRDs are cluster-wide, so an old leader can observe a +new `WatchRule`, ignore `rules[].sourceNamespace`, and mirror the wrong namespace's content into Git. +**Complete the controller rollout before applying migrated manifests.** + ## Unreleased — unresolved attribution is visible in Git (next minor; author identity changes) When `attribution.enabled=true` and the operator cannot match a live watch event to an audit fact within diff --git a/docs/architecture.md b/docs/architecture.md index 85bb65e2..b65dd245 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,10 +15,12 @@ with the source, the source wins; deeper design records live under [docs/design/ Source and destination connections deliberately have different scopes. A namespaced `GitProvider` is a team's Git write boundary: its credential, branch policy, and targets usually belong together. -`ClusterProvider` is cluster-scoped because it represents one shared source identity whose client, -discovery surface, watch state, and attribution partition must remain consistent across namespaces. -`allowedNamespaces` then explicitly controls which control-cluster namespaces may reference that shared -source; it does not grant source-cluster RBAC or select source namespaces. +`ClusterProvider` is cluster-scoped because it represents one shared **logical source identity** whose +client, discovery surface, watch state, and attribution partition must remain consistent across +namespaces. `allowedNamespaces` then explicitly controls which control-cluster namespaces may reference +that shared source; it does not grant source-cluster RBAC or select source namespaces. The source identity +is the `ClusterProvider` name, not an API-server identity probe: two providers configured for the same +server deliberately remain separate source partitions. *** @@ -32,9 +34,10 @@ with a newer remote commit, the operator fetches the new remote state, resets it replays its retained writes from the API. **Watch is the only object-state source.** Each `GitTarget` names one `ClusterProvider` and opens one -Kubernetes watch per claimed `(GVR, scope)` against that source, with `sendInitialEvents=true`. Every -Git write derives from persisted state the watch observed. Audit never defines *what* changed — it only, -optionally, explains *who* caused it. +Kubernetes watch per claimed `(GVR, scope)` against that source, with `sendInitialEvents=true`. For a +namespaced type, a scope is one concrete source namespace; cluster-scoped types use the cluster-wide +scope. Every Git write derives from persisted state the watch observed. Audit never defines *what* +changed — it only, optionally, explains *who* caused it. **Sensitive resources are never written in plaintext.** Core Secrets and configured sensitive resource types must be encrypted before they touch the Git worktree. If encryption cannot be configured, the write @@ -166,11 +169,23 @@ Git destination. `ClusterProvider` supplies the source connection and authorizat ```mermaid graph LR - WR[WatchRule] -->|targetRef| GT[GitTarget] - CWR[ClusterWatchRule] -->|targetRef| GT - CR[CommitRequest] -->|targetRef| GT - GT -->|clusterProviderRef| CP[ClusterProvider] - GT -->|providerRef| GP[GitProvider] + subgraph CONTROL["Control plane: operator cluster"] + WR[WatchRule] -->|targetRef| GT[GitTarget] + CWR[ClusterWatchRule] -->|targetRef| GT + CR[CommitRequest] -->|targetRef| GT + GT -->|clusterProviderRef| CP[ClusterProvider] + GT -->|providerRef| GP[GitProvider] + KCFG[("Remote kubeconfig Secret\noperator namespace")] + WM["Watch manager\nper-provider source context"] + end + + subgraph SOURCE["Source cluster selected by ClusterProvider"] + API["Kubernetes API\ndiscovery · Namespace policy · watches"] + end + + CP -. "optional kubeconfig reference" .-> KCFG + CP -->|"source identity + config"| WM + WM -->|"source client"| API style GP fill:#e8f4fd,stroke:#2196f3 style CP fill:#e8f4fd,stroke:#2196f3 @@ -180,10 +195,17 @@ graph LR style CR fill:#f3e5f5,stroke:#8e24aa ``` +The diagram follows one `GitTarget`. A provider with no `spec.kubeConfig` selects the operator's own +cluster as its source; any provider name, including `default`, can instead select a remote cluster through +a kubeconfig Secret in the operator namespace. Each referenced `ClusterProvider` gets an independent +source context — client, discovery catalog, followability registry, source-namespace snapshot, and +reachability state. Its `GitTarget`s open their source watches through that context. The contexts are keyed +by provider name and are released when no `GitTarget` references them. + | CRD | Scope | One line role | |---|---|---| -| `WatchRule` | namespaced | which resources in *this* namespace route to a GitTarget | -| `ClusterWatchRule` | cluster | which cluster scoped or cluster wide resources route to a GitTarget | +| `WatchRule` | namespaced | which NAMESPACED resources, in which source namespaces, route to a GitTarget | +| `ClusterWatchRule` | cluster | which CLUSTER SCOPED resources route to a GitTarget | | `CommitRequest` | namespaced | a one shot "save the open window now" signal | | `GitTarget` | namespaced | one materialization from a source provider to `(provider, branch, path)` | | `ClusterProvider` | cluster | one source-cluster connection plus namespace access policy | @@ -196,9 +218,10 @@ graph LR * **Controllers**: [watchrule_controller.go](../internal/controller/watchrule_controller.go), [clusterwatchrule_controller.go](../internal/controller/clusterwatchrule_controller.go) -A `WatchRule` selects resources in its own namespace and routes matching events to a same namespace -`GitTarget`. A `ClusterWatchRule` does the same for cluster scoped resources or namespaced resources -across the whole cluster, with an explicit namespace `targetRef`. Both share the rule model: +Scope is carried by the rule KIND. A `WatchRule` selects NAMESPACED resources and routes matching +events to a same namespace `GitTarget`; each of its rule items names the source namespace it watches. +A `ClusterWatchRule` selects CLUSTER SCOPED resources, with an explicit namespace `targetRef` and no +namespace selection of its own. Both share the rule model: * `spec.rules[]`: OR resource rules (`MinItems=1`). * `rules[].operations`: `CREATE` / `UPDATE` / `DELETE` / `*`; omitted means all. @@ -206,11 +229,27 @@ across the whole cluster, with an explicit namespace `targetRef`. Both share the group; `*` is all. * `rules[].apiVersions`: omitted means the preferred served version. * `rules[].resources`: plural resource names or `*`. -* `ClusterWatchRule` adds `rules[].scope`: `Cluster` or `Namespaced` (each rule independently scoped). +* `WatchRule` adds `rules[].sourceNamespace`: omitted for the rule's own namespace, an exact name, or + `*` for every namespace `GitTarget.spec.allowedSourceNamespaces` admits. Anything but the rule's own + namespace passes the source-namespace gate (`SourceNamespaceAuthorized`); the resolved set is + expanded to concrete names at compile time, so no wildcard reaches the data plane. A wildcard opens one + stream for each admitted namespace. +* `ClusterWatchRule` has no scope or namespace choice. `rules[].scope` is deprecated, accepts only + `Cluster`, and a stored `Namespaced` value is refused at compile time. Subresources are rejected in rule resources. Mirroring operates on top level resources; the selected `/scale` subresource effect is translated separately into a parent `spec.replicas` field patch. +The two namespace policies intentionally live in different planes: + +* `ClusterProvider.spec.allowedNamespaces` authorizes **control-cluster** namespaces to reference the + provider. It is a tenant/export boundary; it neither selects nor grants access to source namespaces. +* `GitTarget.spec.allowedSourceNamespaces` bounds what a target may mirror **from its source cluster**. + Exact names need no Namespace read; selector and `sourceNamespace: "*"` policies are evaluated from a + per-source-cluster Namespace-label snapshot. The source credential needs permission to list Namespaces + for that selector path; otherwise exact-name policies still work but selector policies are held + unevaluatable rather than widened or treated as empty. + ### CommitRequest * **Source**: [api/v1alpha3/commitrequest_types.go](../api/v1alpha3/commitrequest_types.go) @@ -305,39 +344,48 @@ Kubernetes native, Flux, and Argo CD Secret key dialects (see defaulting convention: a provider without `spec.kubeConfig` uses the operator's in-cluster client, while any name (including `default`) may instead carry a remote kubeconfig resolved from the operator namespace. -Its cluster scope is intentional. Several namespaces may mirror one source cluster, but the source -identity must not vary by target because it keys source clients, discovery, watches, and attribution. +Its cluster scope is intentional. Several namespaces may mirror through one provider, but that provider's +source identity must not vary by target because it keys source clients, discovery, watches, and +attribution. The identity is the provider name rather than a deduplicated physical-cluster identity, so +two providers pointing at the same API server still have separate contexts and authorization boundaries. `spec.allowedNamespaces` is therefore a deny-by-default control-cluster policy, enforced on every reconcile before watches start — 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 shared source; it -does not expand that source credential's Kubernetes permissions. The `ClusterProvider` validates its +admission-time check could not do. It guards which tenant may cause the operator to export a shared source; +it does not expand that source credential's Kubernetes permissions. The `ClusterProvider` validates its connection inputs, while a `GitTarget` projects provider readiness and live source reachability. +Remote credentials are re-read on the catalog-refresh cadence, not on every watch reconnect. A rotated +Secret or a `qps`/`burst` change rebuilds that provider's clients and restarts only the watches using that +provider; a definitively invalid or missing credential fails closed and stops those watches. An unreachable +remote similarly makes only its dependent targets unready — it does not block discovery or watches for +other source contexts. + *** ## Common Flows -Say a `GitTarget` in namespace `team-a` watches ConfigMaps, and a user runs `kubectl apply` to edit the -ConfigMap `team-a/app-config`. Here is the path that change takes. +Say a `GitTarget` in control-cluster namespace `team-a` watches ConfigMaps, and a user runs `kubectl apply` +to edit the ConfigMap `team-a/app-config` in that target's selected source cluster. Here is the path that +change takes. ```mermaid -flowchart TD - subgraph K8S["Kubernetes API server"] - WATCH["WATCH + sendInitialEvents replay
(per claimed GVR + scope)"] - DISC["Discovery: CRDs / APIServices"] - AUDIT["/audit-webhook/<provider> (optional)"] - end - - subgraph PERGT["Per GitTarget: internal/watch + internal/reconcile"] - OWN["GitTarget watch set
(claimed ∩ followable (GVR, scope))"] - RELEVANCE["Relevance filter
(sanitize · followability · no-op diff)"] - RESOLVE["Resolver
(bounded grace window)"] +flowchart LR + subgraph CONTROL["Control plane: operator cluster"] + CP[ClusterProvider] + KCFG[("Remote kubeconfig Secret\noperator namespace")] + OWN["GitTarget watch set\nclaimed ∩ followable (GVR, scope)"] + RELEVANCE["Relevance filter\nsanitize · followability · no-op diff"] + RESOLVE["Resolver\nbounded grace window"] GTES[GitTargetEventStream] + AFACTS["Audit fact extractor"] + AINDEX[("Redis attribution index\nTTL, keyed for join")] end - subgraph ATTR["Attribution (only when attribution enabled): internal/webhook"] - AFACTS["Audit fact extractor"] - AINDEX[("Redis attribution index
(TTL, keyed for join)")] + subgraph SOURCE["Source cluster selected by ClusterProvider\n(the control cluster when kubeConfig is omitted)"] + WATCH["WATCH + sendInitialEvents replay
(per claimed GVR + scope)"] + DISC["Discovery: CRDs / APIServices"] + NS["Namespace labels\nfor allowedSourceNamespaces selectors"] + AUDIT["/audit-webhook/<provider> (optional)"] end subgraph GIT["Git writes: internal/git"] @@ -346,7 +394,11 @@ flowchart TD PUSH["Atomic push"] end + CP -. "optional Secret reference" .-> KCFG + CP -->|"source client"| DISC + CP -->|"source client"| WATCH DISC --> OWN + NS -. "selector snapshot" .-> OWN WATCH --> OWN OWN --> RELEVANCE RELEVANCE --> RESOLVE @@ -514,9 +566,11 @@ that replay: 3. then the watch streams live events. **This mark-and-sweep is load-bearing and fires only on watch re-establishment, never on a timer** — -there is no periodic LIST or hourly drift sweep. It is the only thing that reconciles a delete that -happened while no watch was running, so it is what makes the watch safe to lose and restart. The sweep -is applied through the same per-type reconcile/writer machinery as live writes (see +there is no periodic **object** LIST or hourly object-drift sweep. (A target using a +selector-based `allowedSourceNamespaces` policy periodically lists only Namespace labels to maintain that +authorization scope; it never uses that list to infer object state or sweep Git.) The sweep is the only +thing that reconciles a delete that happened while no watch was running, so it is what makes the watch safe +to lose and restart. It is applied through the same per-type reconcile/writer machinery as live writes (see [Mark and Sweep Resync](#mark-and-sweep-resync)). If the apiserver forbids `sendInitialEvents` for a type, the operator logs an explicit warning, starts a @@ -677,9 +731,10 @@ Commit: GitOps Reverser CommitDate: Mon Jun 30 12:00:05 2026 +0000 ``` -When attribution is off, or no fact matched, the author is set to the operator as well, so both lines are -identical. A Git UI will show this as a single "author". Effectively we say "we -don't know who, so we don't claim": +When attribution is off, or when a reconcile/resync write has no actor because attribution was never +attempted, the author is set to the operator as well, so both lines are identical. A Git UI will show this +as a single "author". This is configured-author mode: the operator created the Git commit and makes no +claim about a Kubernetes actor. ```console $ git show --no-patch --format=fuller HEAD @@ -775,8 +830,10 @@ How a user's `WatchRule` becomes "this GitTarget follows these concrete types in * **Source**: [internal/rulestore/store.go](../internal/rulestore/store.go) -An in memory cache populated by the WatchRule and ClusterWatchRule controllers. Compiled rules carry the -full chain from rule to `GitTarget`, `GitProvider`, branch, and path. It is read by the watch manager (to +An in memory cache populated through the shared compile paths (`watch.CompileWatchRule` / +`watch.CompileClusterWatchRule`) that both the controllers and the startup bootstrap call, so a +restart cannot seed an ungated rule. Compiled rules carry the full chain from rule to `GitTarget`, +`GitProvider`, branch, and path, plus each WatchRule item's RESOLVED source-namespace set. It is read by the watch manager (to build watched type tables for GitTargets) and the rule change reconcile path. ### APIResourceCatalog @@ -788,8 +845,12 @@ keeps only mechanical bookkeeping. **All judgement across scans lives in the typ (`Registry.UpdateFromScan`): a failed group/version keeps serving last known facts instead of looking like an empty API surface, and a group/version that vanishes from a complete scan rides a removal grace rather than being pruned. Both protect against accidental Git deletions on a discovery blink (see -[typeset-owns-discovery-grace.md](spec/typeset-owns-discovery-grace.md)). The catalog refreshes on -startup, periodically, and when CRD/APIService trigger informers fire. +[typeset-owns-discovery-grace.md](spec/typeset-owns-discovery-grace.md)). Every active source context has +its own catalog and registry, so a CRD served only by `prod-eu-1` is never resolved for a target mirroring +`prod-us-1`. Catalogs refresh at startup and on the 30-second reconciliation cadence. CRD/APIService +trigger informers run only in the **control plane**, where the operator's own CRDs live; a remote source's +API-surface change is discovered on that periodic refresh or when a rule/target reconciliation explicitly +refreshes that source. ### TypeRegistry and Followability @@ -810,7 +871,8 @@ A projection for each `GitTarget` from the type registry, filtered by that targe resolved GVK/GVR/scope plus namespace and operation coverage. **This is where rule matching effectively happens:** it resolves the set of `(GVR, scope)` a `GitTarget` claims, so the watch manager opens one watch per claimed ∩ followable `(GVR, scope)` and scopes each watch's events back to that GitTarget's -namespaces. +source namespaces. A `sourceNamespace: "*"` rule is already expanded here, so each admitted namespace has +its own stream and mark-and-sweep boundary. *** @@ -831,8 +893,10 @@ because Git already holds current state. This section is the authoritative contr The watch manager is a controller runtime `Runnable` (`NeedLeaderElection`). It owns **type level** discovery, the per-GitTarget watch sets, and the watched type tables for GitTargets. Its object-state -intake is the watches themselves; its discovery watches/informers track the API surface (CRDs / -APIServices) rather than driving object state. +intake is the watches themselves. Every active `ClusterProvider` has an independent source context; its +catalog, registry, dynamic client, and reachability can fail or recover without borrowing facts from another +source. Discovery trigger informers (CRDs / APIServices) run only in the control plane and accelerate its +catalog refresh; remote source catalogs use the periodic refresh. On `Start` it bootstraps the RuleStore from existing rules, refreshes the API catalog, updates the TypeRegistry, builds watched type tables, and opens one watch per claimed ∩ followable `(GVR, scope)`. @@ -847,17 +911,20 @@ mark-and-sweep at `initial-events-end`, then streams live events through the rel author resolver into the GitTargetEventStream. On disconnect or `410 Gone` the goroutine reconnects and repeats the replay + sweep. See [Recovery: replay plus mark-and-sweep](#recovery-replay-plus-mark-and-sweep). -There is **no periodic LIST, no checkpoint, and no timer-driven drift sweep** — the sweep fires only on -watch re-establishment. +There is **no periodic object LIST, object checkpoint, or timer-driven object-drift sweep** — the sweep +fires only on watch re-establishment. Selector-based source-namespace authorization is separate: it may +periodically list Namespace labels in the relevant source cluster, but that list never materializes objects +or triggers a Git sweep. ### Rule Change Reconcile -A WatchRule / ClusterWatchRule / GitTarget / CRD / APIService change reaches a GitTarget through the -**GitTarget controller**, which `Watches` those objects (generation change predicates) and queues the -affected GitTarget again. On reconcile the GitTarget resolves its claimed `(GVR, scope)` set again; a type -a new rule starts watching gets a new watch opened (with a `sendInitialEvents` replay) and a type no -longer claimed has its watch closed. The watch manager refreshes the API catalog and the watched type -tables. +A WatchRule / ClusterWatchRule / GitTarget change, or a **control-plane** CRD / APIService change, reaches +a GitTarget through the **GitTarget controller**, which `Watches` those objects (generation change +predicates) and queues the affected GitTarget again. On reconcile the GitTarget refreshes its own source +catalog and resolves its claimed `(GVR, scope)` set again; a type a new rule starts watching gets a new +watch opened (with a `sendInitialEvents` replay) and a type no longer claimed has its watch closed. A remote +CRD / APIService change has no trigger informer; the next periodic source-catalog refresh performs the same +re-resolution. The watch manager then refreshes the watched type tables. ### Mark and Sweep Resync diff --git a/docs/configuration.md b/docs/configuration.md index 8b1052a9..a80c5a1e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -8,8 +8,9 @@ The short version: - `GitProvider` defines where and how to push - `ClusterProvider` defines the Kubernetes source cluster a target mirrors from - `GitTarget` defines which branch and repository path to write into -- `WatchRule` defines which namespaced resources should produce Git writes -- `ClusterWatchRule` does the same for cluster-scoped or cross-namespace watching +- `WatchRule` defines which namespaced resources should produce Git writes, and in which source + namespaces +- `ClusterWatchRule` does the same for cluster-scoped resources - `CommitRequest` optionally asks the operator to close the current commit window now The chart's optional `quickstart` values are just a convenience layer that creates starter @@ -386,9 +387,28 @@ spec: `allowedNamespaces` is evaluated against namespaces in the **control cluster**, where `GitTarget`s live. In this example, a `GitTarget` in `team-a`, or in a control-cluster namespace -with the shown label, may reference `prod-eu-1`. It has no effect on which namespaces are read from -the source cluster; that remains entirely the source connection's Kubernetes RBAC. `names` and -`selector` are ORed, and an omitted policy admits no control-cluster namespace. +with the shown label, may reference `prod-eu-1`. `names` and `selector` are ORed, and an omitted +policy admits no control-cluster namespace. + +Which namespaces are read *from the source cluster* is bounded by +[`GitTarget.spec.allowedSourceNamespaces`](#bounding-which-source-namespaces-reach-a-target) when +that target declares one, and by the source connection's Kubernetes RBAC in every case — the +credential's own RBAC is always the hard maximum. A `WatchRule` may name a source namespace other +than its own only when this provider also sets: + +```yaml + # Deny-by-default. While false, a WatchRule mirroring through this provider may + # watch only its OWN namespace, whatever any GitTarget policy says. + allowSourceNamespaceOverride: true +``` + +That flag delegates the *choice* of source namespace to the `GitTarget`s this provider admits; it +grants nothing on its own, since the target must still admit the namespace. It is required for +**every** cross-source-namespace request, including `sourceNamespace: "*"`. Setting it on an +**in-cluster** 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 `GitTarget` mirror another namespace's objects into a Git destination they control. That is +legitimate to grant on purpose, which is why it is explicit and defaults to false. `spec.kubeConfig` and `GitTarget.spec.clusterProviderRef` are immutable: changing either would silently make an existing materialization mean a different source cluster. Rotate credential *contents* in the @@ -555,6 +575,12 @@ The boundaries that keep it predictable: - A **sensitive** resource never infers from (or is appended into) a plaintext file; it only follows encrypted siblings, otherwise it uses the secure canonical path. +- A resource in a **namespace the target has never written before** only joins an existing cohort when + that cohort has *proven* it is namespace-agnostic by already holding more than one namespace. One + directory holding one namespace looks identical to a per-namespace layout whose second namespace has + simply not arrived yet, so it is not treated as shared — the resource takes the canonical path, which + carries its own namespace segment. Guessing here would file one namespace's objects under another's + folder. - When a type genuinely lives in two layouts at once, the tie-break is deterministic (the cohort with the most members wins, then the lexically smallest path) — never a coin-flip. - Inference can only **continue** a layout that already exists. It cannot invent a greenfield one — "I @@ -688,17 +714,122 @@ For design details and the exact boundary, see ## `WatchRule` -`WatchRule` is the normal namespaced watcher. It only watches resources in its own namespace and -writes them to the referenced `GitTarget`. +`WatchRule` is the **namespaced** watcher: it selects namespaced resources on its `GitTarget`'s +source cluster and writes them to that `GitTarget`. Scope is carried by the rule kind — a `WatchRule` +never selects cluster-scoped types, and a `ClusterWatchRule` never selects namespaced ones. -Status uses `ResourcesResolved` for selector resolution, `StreamsRunning` for source-watch readiness, and -`GitTargetReady` for the referenced target's write readiness. A rule can have `StreamsRunning=True` and -still remain `Ready=False` when its GitTarget reports `GitPathAccepted=False`. +Status uses `ResourcesResolved` for selector resolution, `StreamsRunning` for source-watch readiness, +`GitTargetReady` for the referenced target's write readiness, and `SourceNamespaceAuthorized` for the +source-namespace gate below. A rule can have `StreamsRunning=True` and still remain `Ready=False` +when its GitTarget reports `GitPathAccepted=False`. The important fields are: - `spec.targetRef.name`: target to write to - `spec.rules`: one or more resource-match rules +- `spec.rules[].sourceNamespace`: the source-cluster namespace that item watches; omitted means the + rule's own namespace + +### Watching a different source namespace + +Set `spec.rules[].sourceNamespace` to mirror a namespace other than the one the `WatchRule` lives in +— the case a shared config plane needs, where a tenant's configuration namespace and their source +namespace cannot share a name. It sits on the rule item, beside the resource selector it applies to, +so one `WatchRule` can follow different resource types in different namespaces. + +| `rules[].sourceNamespace` | Meaning | +|---|---| +| omitted | the `WatchRule`'s own namespace — legacy behavior, byte for byte | +| an exact name | one source namespace | +| `"*"` | every namespace `GitTarget.spec.allowedSourceNamespaces` admits, resolved live | + +Naming anything other than the rule's own namespace — **including `"*"`** — is authorized by three +things, all of which must hold: + +1. the `GitTarget`'s namespace is admitted by its `ClusterProvider`'s `allowedNamespaces`; +2. that `ClusterProvider` sets `allowSourceNamespaceOverride: true`; and +3. the `GitTarget`'s `allowedSourceNamespaces` admits the namespace. + +```yaml +apiVersion: configbutler.ai/v1alpha3 +kind: WatchRule +metadata: + name: repo-config + namespace: tenant-acme +spec: + targetRef: + name: acme + rules: + - resources: [configmaps] # omitted → tenant-acme, this rule's own namespace + - resources: [secrets] + sourceNamespace: repo-config # one admitted source namespace + - resources: [deployments] + sourceNamespace: "*" # every namespace the target admits, live +``` + +`"*"` never means "every namespace that exists": it expands to exactly what the target's policy +admits, so a target that declares no policy **denies** it. Each `"*"` item opens one watch stream per +(matched type × admitted namespace) — deliberate, because it keeps every replay scoped to a single +namespace, but a real fan-out on a broad policy. + +The outcome for all items is aggregated into one `SourceNamespaceAuthorized` condition, also shown by +`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. A +`"*"` that currently admits nothing is not a refusal: the rule stays `Ready` with reason +`NoAdmittedSourceNamespaces`, so a no-op rule is visible instead of looking healthy. Authorization is +re-evaluated on every reconcile, so tightening a policy 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/…`. + +### Bounding which source namespaces reach a target + +`GitTarget.spec.allowedSourceNamespaces` bounds which source-cluster namespaces may be mirrored into +that target by its `WatchRule`s: + +```yaml +spec: + allowedSourceNamespaces: + names: [repo-config] + selector: + matchLabels: + gitops.configbutler.ai/mirrorable: "true" +``` + +`names` and `selector` are ORed, and the selector matches labels on `Namespace`s in the **source** +cluster — so evaluating it needs `namespaces` `get`/`list`/`watch` for that cluster's credential. +Exact `names` keep working without that access, which is a deliberate degradation path. + +It is also what `sourceNamespace: "*"` resolves *through*: + +| Policy on the `GitTarget` | `sourceNamespace: "*"` resolves to | +|---|---| +| undeclared | **denied** — deny-by-default; the message names the fix | +| `{}` (declared, empty) | nothing | +| `names: [a, b]` | exactly `a` and `b`, statically, with no source-cluster access | +| `selector: {matchLabels: …}` | every source namespace carrying those labels, live | +| `selector: {}` | **every source namespace** — the deliberate "all namespaces" declaration | + +That last row is how a destination owner says *every* source namespace, and it stays self-updating as +namespaces come and go. It is the replacement for the removed cluster-wide namespaced +`ClusterWatchRule`, and it is declared by the destination owner rather than by the rule author. + +Two things about this field are easy to get wrong: + +- **Omitted and empty differ.** Omitted declares no policy and a `WatchRule` keeps its own namespace. + A declared-but-empty policy (`{}`) admits **nothing**. +- **A declared policy is exhaustive, with no self-namespace exception.** It must admit every namespace + that may reach the target, *including* a co-resident legacy `WatchRule`'s own namespace. Adding a + policy for one override therefore denies those rules until their namespace is admitted — loudly, with + a message naming the fix, but it will happen. + +A namespace allow-list cannot partition **cluster-scoped** objects, which have no namespace. A +`ClusterWatchRule` receives every such object its source credential can read, and this field is +neither consulted nor a bound for it. If a tenant must not see another tenant's cluster-scoped +objects, give each tenant its own `ClusterProvider` and credential, so that credential's RBAC is the +boundary. Each entry in `spec.rules` is a logical OR. A resource matching any rule is watched. The rule fields are: @@ -730,17 +861,18 @@ spec: resources: ["configmaps", "secrets"] ``` -Use `WatchRule` when the watched resources and the `GitTarget` live in the same namespace. +Use `WatchRule` for every **namespaced** resource, whether or not it lives in the `GitTarget`'s own +namespace. ## `ClusterWatchRule` -`ClusterWatchRule` is the cluster-scoped variant. Use it when you need to watch: - -- cluster-scoped resources such as `nodes`, `clusterroles`, or CRDs -- namespaced resources across multiple namespaces +`ClusterWatchRule` is the **cluster-scoped** variant. Use it for cluster-scoped resources such as +`nodes`, `clusterroles`, or CRDs. It has no scope choice and no source-namespace selection: to mirror +namespaced resources across namespaces, use a `WatchRule` with +[`rules[].sourceNamespace`](#watching-a-different-source-namespace). Because it is cluster-scoped, its `targetRef` must include the namespace of the referenced -`GitTarget`. +`GitTarget`, and that namespace must be admitted by the target's `ClusterProvider`. Example: @@ -758,11 +890,16 @@ spec: apiGroups: ["rbac.authorization.k8s.io"] apiVersions: ["v1"] resources: ["clusterroles", "clusterrolebindings"] - scope: Cluster ``` -Use this sparingly. It is the more powerful option and usually belongs to cluster-admin-managed -setups. +Cluster-scoped objects have no namespace, so `GitTarget.spec.allowedSourceNamespaces` does not bound +a `ClusterWatchRule` at all: it is intentionally cluster-global, limited only by its source +credential's Kubernetes RBAC. Use this sparingly. It is the more powerful option and usually belongs +to cluster-admin-managed setups. + +> `spec.rules[].scope` is deprecated and accepts only `Cluster` (its default). Re-applying a +> pre-release manifest that still says `scope: Namespaced` is **rejected** — see +> [UPGRADING.md](UPGRADING.md) for the conversion. ## `CommitRequest` diff --git a/docs/design/watchrule-source-namespace/README.md b/docs/design/watchrule-source-namespace/README.md index d042e680..184cd7a5 100644 --- a/docs/design/watchrule-source-namespace/README.md +++ b/docs/design/watchrule-source-namespace/README.md @@ -1,239 +1,146 @@ # Source-namespace addressing and per-target source scope -> **Design in five PRs. PRs 1 and 2 have landed; PRs 3–5 are not built.** Companion to upstream -> wishlist #14 and [config-plane-split.md](../../finished/config-plane-split.md). -> Written 2026-07-19, split into phases 2026-07-20. Index: [INDEX.md](../../INDEX.md). -> -> Every code reference in this folder was verified against the tree on 2026-07-20. Each PR page -> carries its own evidence section so a regression in any claim is detectable without re-deriving it. - -A WatchRule can only watch the namespace it lives in. On a shared config plane that forces a -tenant's configuration namespace and their source namespace to share a name, which collides as soon -as two tenants want the same one. This folder adds a source-namespace field, and — the part that -makes it safe — a per-GitTarget ceiling on which source namespaces may be mirrored into that target -at all, binding **both** rule kinds. Three pre-existing scope defects are fixed first, because each -of them would otherwise turn the new fan-out into silent Git data loss or an unenforced boundary. +> **Design in five PRs.** PRs 1–3 are landed. The current branch becomes **PR 4**, the selected +> scope-by-kind design. **PR 5** is the deletion-safety change, implemented in the PR immediately +> after it. **No release may be cut between the two merges** — the first release containing PR 4 also +> contains PR 5. Review findings still open against PR 4 are tracked in +> [PR 4 review follow-ups](pr4-review-followups.md). Index: [INDEX.md](../../INDEX.md). + +## Decision + +The selected end state is the two-object model: + +- **WatchRule** is the namespaced source-resource surface. `spec.rules[].sourceNamespace` is omitted + for the rule's own namespace, names one authorized source namespace, or is `"*"` for every + namespace its GitTarget admits — including a live, selector-resolved set. +- **ClusterWatchRule** is the cluster-scoped source-resource surface. It has no scope choice. +- **GitTarget** owns both the destination's source-namespace allow-list and the deletion policy. + +This deliberately removes platform-authored namespaced mirroring without a WatchRule in the tenant +namespace. A platform administrator can still manage the manifest, but must put it in that namespace. +If that placement is unacceptable for a deployment, stop before PR 4 and design a separate API for +that capability. ## The model -Authorization follows the object references that already exist: +Authorization follows the existing references: ~~~text WatchRule ──uses──> GitTarget ──uses──> ClusterProvider │ │ │ │ │ └ permits this target namespace - │ └ permits source namespaces mirrored into it (any rule kind) - └ requests one source namespace + │ └ admits source namespaces for this destination + └ selects resource types and source namespaces ~~~ -- **`WatchRule.spec.sourceNamespace`** — the requested source namespace. Omitted means the - WatchRule's own namespace. -- **`GitTarget.spec.allowedSourceNamespaces`** — the target owner's allow-list. It is a property of - the **destination**, not of the requesting rule: when declared it is exhaustive for every rule that - writes to this GitTarget, ClusterWatchRule included. -- **`ClusterProvider.spec.allowedNamespaces`** — unchanged platform-admin policy: which - control-cluster namespaces may create GitTargets using the provider. -- **`ClusterProvider.spec.allowWatchRuleSourceNamespaceOverride`** — new, false-by-default - delegation. While false a WatchRule may use only its own namespace. - -The one invariant everything else serves — **a declared policy is exhaustive**: +- `GitTarget.spec.allowedSourceNamespaces` is a destination-owned, exhaustive allow-list for + WatchRule source namespaces. A declared policy has no self-namespace exception. +- `ClusterProvider.spec.allowSourceNamespaceOverride` is the platform-admin delegation that + permits an admitted GitTarget to authorize a WatchRule outside its own namespace. +- `GitTarget.spec.prune.mode` controls deletion: `never`, `onEvent` (default), or `always`. -> When a GitTarget declares `allowedSourceNamespaces`, the source namespaces mirrored into it are -> **exactly** those the policy admits, for every rule of every kind. When it declares none, each rule -> kind keeps its legacy scope. +Cluster-scoped objects have no namespace. A ClusterWatchRule therefore receives every selected +cluster-scoped object its source credential can read; tenant isolation for such objects requires +separate source credentials/ClusterProviders, not a namespace allow-list. -| `GitTarget.allowedSourceNamespaces` | WatchRule | ClusterWatchRule (`scope: Namespaced` rules) | -|---|---|---| -| Undeclared | Own namespace only (legacy) | All source namespaces (legacy) | -| Declared | Exactly what the policy admits | Exactly what the policy admits | +## Why the scope lives on GitTarget -Nothing changes on upgrade: the field is new, so it is undeclared everywhere until a target owner -opts in. +A GitTarget already binds one source cluster to one Git destination, branch, and path. What may reach +that destination is therefore a GitTarget property, not a property of an individual requesting rule. +A declared `allowedSourceNamespaces` policy is exhaustive: it must list the WatchRule's own namespace +as well when that legacy WatchRule is to continue writing to this target. ### No self-namespace exception -An earlier revision let a declared policy still implicitly permit a WatchRule's own namespace, on the -theory that it would be rude to break a legacy rule when a policy is added for an unrelated override. -That is rejected. It would mean the field does not actually bound what reaches the target — a reader -auditing `allowedSourceNamespaces: [repo-config]` would be wrong about the target's contents, which -defeats the reason the field exists — and "ceiling" would be a false description of it. +An omitted `rules[].sourceNamespace` resolves to the WatchRule's own namespace. It is allowed without +a source-namespace policy only while the GitTarget declares no policy. Once a policy is declared, that +namespace must be explicitly admitted just like every other source namespace. -So a policy lists everything, including the target's own namespace when a legacy WatchRule needs it: +A declared policy is also how a destination says **every** source namespace: a present-but-empty +`selector: {}` admits all of them, live, and is the replacement for the removed +`ClusterWatchRule` + `scope: Namespaced` capability. -~~~yaml -allowedSourceNamespaces: - names: [tenant-acme, repo-config] # own namespace listed explicitly -~~~ - -The trade-off is a genuine authoring footgun: adding a policy for one override silently denies the -co-resident legacy rules unless their namespace is listed. It is mitigated three ways, and none of -them is "hope". The field is new, so no existing configuration is affected. The denial is loud, not -silent — `SourceNamespaceAuthorized=False` with reason `SourceNamespaceNotAllowed`, `Stalled=True`, -and the stream stopped. And the reason message must name the specific fix: *"namespace tenant-acme is -not in the GitTarget's allowedSourceNamespaces; add it to keep watching this rule's own namespace."* -A footgun you are told about, in the terms of the fix, is an acceptable price for a field that means -what it says. - -## The driving use case: multi-tenancy with CRDs on day one - -1. **Nothing changes for today's rules.** A WatchRule with no `sourceNamespace` still watches its - own namespace. -2. **Config namespace and source namespace differ.** A WatchRule in `tenant-acme` selects - `repo-config` in that tenant's workspace, removing the shared-config-plane collision. -3. **One source cluster, several tenants.** Each GitTarget declares what may be mirrored into it. - acme's target reaches its workspace namespace without widening zen's target. -4. **A tenant needs CRDs, so it needs a ClusterWatchRule from day one.** A ClusterWatchRule is the - only way to select cluster-scoped types, so a real multi-tenant deployment runs one per tenant - GitTarget immediately — this is not a later refinement. That rule kind can also carry - `scope: Namespaced` entries, and it points at a GitTarget by an explicit cross-namespace - reference. Without the ceiling, the operator's answer to "will this stream objects from - namespaces outside my allow-list?" is *hand-audit every ClusterWatchRule and hope*. With it, the - answer is a field you can read off the GitTarget. This is why - [PR 5](pr5-clusterwatchrule-source-ceiling.md) is part of the launch set, not a follow-up. -5. **A deliberately cross-namespace in-cluster source.** An explicit platform-admin delegation - through the operator's cluster RBAC — the same mechanism as remote, but a much sharper sign-off. - -### What the ceiling does not do - -**Cluster-scoped objects have no namespace, so a namespace allow-list cannot partition them.** A -tenant whose ClusterWatchRule selects CRDs receives *every* CRD the source credential can read, and -`allowedSourceNamespaces` neither narrows nor is consulted for those streams. That is defensible — -CRDs are genuinely cluster-global, and mirroring the cluster's type surface into a tenant repo is -usually the intent — but "my allow-list bounds what this tenant sees" is **false for the -cluster-scoped half**, and a multi-tenant operator must know that before relying on it. - -If a tenant must not see another tenant's cluster-scoped objects, this model is deliberately -insufficient. Use one ClusterProvider and source credential per tenant, so the credential's own RBAC -is the boundary. A per-target allow-list for cluster-scoped *types* is a plausible later field; it is -not in this workstream, because it is a different question (which types) from the one being answered -here (which namespaces). +The policy does not apply to ClusterWatchRule after PR 4, because a cluster-only rule has no namespace +to bound. This makes the audit question straightforward: either read the WatchRule's selected +namespace and target policy, or recognize a ClusterWatchRule as intentionally cluster-global. ## Implementation phases -Five PRs, ordered so each is independently reviewable and independently revertible. The first three -are pre-existing defect fixes that carry no API change; the last two are the feature. - -| # | PR | Scope | Depends on | Status | -|---|---|---|---|---| -| 1 | [Namespace-scoped resync](pr1-namespace-scoped-resync.md) | A per-namespace replay must not sweep other namespaces' manifests of the same type. Bug fix, no API. | — | **landed** | -| 2 | [Stream-scope collapse](pr2-stream-scope-collapse.md) | A cluster-wide selection stops silently widening a co-resident named-namespace stream for the same GVR. Bug fix, no API. | 1 | **landed** | -| 3 | [ClusterWatchRule target admission](pr3-clusterwatchrule-target-admission.md) | A ClusterWatchRule may no longer attach to a GitTarget whose namespace its ClusterProvider does not admit. Bug fix, no API. | — | **landed** | -| 4 | [The sourceNamespace field and gate](pr4-source-namespace-field.md) | `WatchRule.spec.sourceNamespace`, `GitTarget.spec.allowedSourceNamespaces`, the delegation flag, the gate, `SourceNamespaceAuthorized`, and the source-scope service. | 1, 2 | not started | -| 5 | [The ClusterWatchRule ceiling](pr5-clusterwatchrule-source-ceiling.md) | A declared `allowedSourceNamespaces` narrows a ClusterWatchRule's namespaced streams. | 1, 2, 4 | not started | - -**PR 1 gated everything, and has landed.** It was not a cleanup to slot in opportunistically: the -resync sweep was scoped by type but not by namespace, so the first change that let one GitTarget -watch a GVR in more than one namespace would have started deleting other namespaces' manifests from -Git. PRs 2, 4, and 5 each introduce exactly that fan-out, independently, so landing any of them -first would have been silent data loss in a tenant's repository. With PR 1 in, that floor is in -place and the remaining four are unblocked. - -**Release gate: do not cut a release between PR 4 and PR 5.** PR 4 ships the field; until PR 5 lands -the field is enforced on the rule kind that cannot bypass it and unenforced on the one that can, and -[use case 4](#the-driving-use-case-multi-tenancy-with-crds-on-day-one) is exactly the configuration -that hits the gap. They may merge separately; they must ship together. - -PR 3 is independent of the rest and can go at any point. - -### The shape all five are serving - -The end state is that a GitTarget's watch set is **exactly the streams that were declared for it** — -no accidental widening from a co-resident rule (PR 2), no attachment the provider never admitted -(PR 3), no scope -that outruns its declaration (PR 4, PR 5), and no sweep that acts outside the scope it was gathered -over (PR 1). Every defect in this folder is the same mistake in a different place: a scope that is -computed in one part of the system and then silently widened or dropped in another. That is why the -fixes precede the feature rather than accompanying it. - -## Why the scope lives on GitTarget - -GitTarget already binds one source cluster to one Git destination, branch, and path, so what may -arrive at that destination is a property of the target. The reference chain a reader follows is the -same one the controller follows: `WatchRule.sourceNamespace` → `targetRef` → -`GitTarget.allowedSourceNamespaces` → `clusterProviderRef` → `ClusterProvider.allowedNamespaces` and -the delegation flag. - -The decisive argument against putting both allow-lists on ClusterProvider is that two fields named -`allowedNamespaces` and `allowedSourceNamespaces` on the *same* object would mean namespaces in two -*different* clusters — an ambiguity no name can fix. Splitting them across the two objects that own -those clusters removes it. A provider-wide source list also cannot express ownership: a provider -admitting `tenant-acme` and `tenant-zen` and listing `acme-config` and `zen-config` has no way to say -which tenant owns which, and becomes a Cartesian product. - -The model works because `WatchRule.targetRef` is a `LocalTargetReference` with no namespace field -([watchrule_types.go:24-42](../../../api/v1alpha3/watchrule_types.go#L24-L42)), so every WatchRule -using a target is in that target's namespace. `ClusterWatchRule.targetRef` **is** cross-namespace, -which is precisely why PRs 2 and 4 exist. - -## Naming decisions - -- **`sourceNamespace`**, not `sourceClusterNamespace` or `remoteNamespace`. "source" is the - established axis in this API (~110 occurrences, plus `SourceClusterReachable`, - `SourceClusterResolver`, `SourceClusterAccessDenied`); `clusterProviderRef` already "names the - SOURCE cluster this GitTarget mirrors FROM" - ([gittarget_types.go:90](../../../api/v1alpha3/gittarget_types.go#L90)), while "remote" appears - only in informal prose. `sourceNamespaceOverride` is rejected for the field: it names the mechanism - relative to a default rather than the thing itself. The delegation *flag* keeps "Override" - deliberately, because a boolean gate does name a mechanism. -- **`allowedSourceNamespaces`**, not `sourceNamespaces`. The `allowed` prefix is load-bearing: a - reader meeting an absent field must not have to guess between "none" and "unrestricted", and the - fail-open reading is the catastrophic one. `allowed*` is already this repo's idiom for the shape - (`allowedNamespaces`, `allowedBranches`). -- **Deferred rename.** The fully symmetric pair would be `allowedGitTargetNamespaces` + - `allowedSourceNamespaces`. Do **not** rename in v1alpha3 — `allowedNamespaces` is shipped and is a - security control. The rename would fail *closed* (old field ignored → no policy → deny-by-default), - so it is churn rather than danger. Adopt it at the next API version, with conversion. - -Both policies use one generic `NamespaceMatcher` shape, with `names` and `selector` ORed, an -omitted-or-empty matcher admitting nothing, and a selector matching labels on the Namespace object in -that field's own cluster: - -| Field | Labels come from | Meaning | -|---|---|---| -| `ClusterProvider.allowedNamespaces` | control cluster | Which namespaces may create a GitTarget using the provider. | -| `GitTarget.allowedSourceNamespaces` | source cluster | Which namespaces may be mirrored into this target, by any rule kind. | +| # | PR | Scope | Status | +|---|---|---|---| +| 1 | [Namespace-scoped resync](pr1-namespace-scoped-resync.md) | A per-namespace replay cannot sweep another namespace's manifests of the same type. | landed | +| 2 | [Stream-scope collapse](pr2-stream-scope-collapse.md) | A cluster-wide stream cannot silently widen a co-resident named stream. | landed | +| 3 | [ClusterWatchRule target admission](pr3-clusterwatchrule-target-admission.md) | A ClusterWatchRule cannot attach to a GitTarget its ClusterProvider does not admit. | landed | +| 4 | [Scope by kind](pr4-cluster-scope-only.md) | Rework the unshipped source-namespace work for `rules[].sourceNamespace`, narrow ClusterWatchRule to cluster scope, refuse stored namespaced rules, and document the cross-kind migration. | current branch; breaking | +| 5 | [GitTarget deletion safety](pr5-gittarget-deletion-safety.md) | Add `prune.mode` and make resync sweep opt-in (`always`). | next PR; ships in the same release as PR 4 | + +The discarded top-level `sourceNamespace` plan is retained as a +[historical implementation baseline](historical-top-level-source-namespace-baseline.md). Its gate, +conditions, bootstrap enforcement, source-scope snapshot, and reactivity work are reusable; its public +field is not. [PR 4's keep/replace map](pr4-cluster-scope-only.md#existing-pr-4-work-to-keep) defines +the exact rework, including the retained ClusterProvider delegation flag; its +[closed decisions](pr4-cluster-scope-only.md#closed-design-decisions) resolve the former alternatives' +open questions. + +## Working and release order + +Keep the current branch and rework it in place as PR 4. Do not release a version containing the +top-level `WatchRule.spec.sourceNamespace` field. + +1. Implement and review PR 4 on the current branch, then merge it. **`main` is now in a + do-not-release window.** +2. Implement PR 5 in the next PR and merge it. The window closes. +3. Release both together, with the breaking change in the notes. + +There is no release in which PR 4 exists without PR 5, so there is no PR-5 rollback floor to fall +back to. Rolling the controller back past that release while migrated manifests exist is +**unsupported**: the older controller both ignores `rules[].sourceNamespace` (resolving a rule to its +own namespace — a narrower desired set) and lacks `prune.mode` (so a resync sweeps). Remove or narrow +the affected WatchRules first. + +## Deletion safety + +The product has two deletion paths: explicit source DELETE events and inferred mark-and-sweep drops +during resync. Scope mistakes threaten only the latter. PR 5 defaults targets to `prune.mode: onEvent`: +explicit source DELETE events remain mirrored, while a narrowed or incorrect desired set leaves prior +Git documents untouched. `always` restores full desired-state convergence; `never` creates an archive +that does not remove documents. + +PR 5 is intentionally narrow. It does not add a deletion-count or percentage guard. If experience +shows that genuine large delete cascades need another control, `prune` is already an object and can +later gain a non-breaking field such as `maxDeletesPerCommit`. ## Compatibility -This is a preliminary v1alpha3 API. We intentionally accept the following observable changes; no -migration, deprecation period, or conversion webhook is planned. The generated CRD remains served as -v1alpha3 with the existing status subresource and map-style conditions list, so no stored-object -migration is required — but release notes must call these out. - -| Change | Impact | PR | -|---|---|---| -| `sourceNamespace`, `allowedSourceNamespaces`, delegation flag | Additive. An omitted `sourceNamespace` still selects the rule's own namespace, but a manifest using an override requires the new controller. An older controller silently continues the legacy own-namespace watch and must not be used with such manifests. | 4 | -| `SourceNamespaceAuthorized` condition, `SourceAuthorized` printer column | Additive status surface. Scripts consuming a fixed condition set or column layout must tolerate it. PR 4 adds both to WatchRule; PR 5 adds them to ClusterWatchRule. | 4, 5 | -| Denied override is `Failed` | Intentional: an authorization refusal is `Ready=False`/`Reconciling=False`/`Stalled=True`, not a quietly inactive rule. An *unevaluatable* policy is not a refusal — see [establishing versus maintaining](pr4-source-namespace-field.md#establishing-versus-maintaining-a-scope). | 4 | -| Selector-based policies | The source credential now needs Namespace `get`, `list`, `watch`. Without them selector policies cannot be evaluated; exact-name policies keep working. | 4, 5 | -| Stream-scope fix | Narrows a stream that previously became cluster-wide through the named/cluster-wide collapse. Any configuration relying on that accidental widening changes behavior. | 2 | -| ClusterWatchRule target admission | Rejects a ClusterWatchRule whose GitTarget is not admitted by its ClusterProvider. | 3 | -| ClusterWatchRule ceiling | A ClusterWatchRule's namespaced streams narrow to a declared `allowedSourceNamespaces`. No existing config changes, since the field is undeclared everywhere on upgrade. Cluster-scoped rules unaffected. | 5 | - -## Alternatives considered - -- **No delegation flag** — rejected. A GitTarget policy would otherwise silently turn provider access - into cross-namespace source reachability. -- **Provider-wide `allowedSourceNamespaces`** — useful only for a one-tenant provider; cannot express - ownership. This was an earlier revision's recommendation, superseded by the ownership argument. -- **Provider-side pair policies** (`{gitTargetNamespace, sourceNamespaces}`) — can enforce a - platform-owned per-tenant maximum, which the GitTarget model deliberately cannot. More complex and - less followable. Reserve for a later stronger-boundary requirement; do not treat GitTarget's policy - as a substitute for it. -- **Gate the override on `kubeConfig` presence (remote-only)** — rejected. `kubeConfig` is - connectivity, not permission. It also welds the in-cluster case shut forever, conflating "unsafe by - default" with "impossible". See the `IsLocalSource()` trap in - [PR 4](pr4-source-namespace-field.md#locality-is-not-the-switch). -- **Use ClusterWatchRule instead of a WatchRule field** — mechanically viable, since a - ClusterWatchRule already resolves through the same source cluster, but it costs per-tenant - namespace ownership and tenant self-service authoring, and needs PRs 1 and 2 regardless. -- **Unique namespace naming** (`acme-config`) — works today with no code change and gives full - isolation, at the cost of an account-encoded namespace name the tenant sees in their own workspace. - This is the fallback if a tenant needs the capability before this ships. -- **`namespaceSelector` fan-out on WatchRule** — deferred. More powerful, but changes routing - semantics (which folder does each namespace map to?). The single-namespace field is the surgical - version; a selector can follow. -- **Define the watch CRs in the source cluster** — namespace-correct by construction, but re-diffuses - config across every workspace (the thing the config-plane split centralized) and drags the Git - token back onto the watched cluster, since `GitProvider.secretRef` is namespace-local. A reasonable - opt-in mode later, not the fix. +This is preliminary v1alpha3 API. PR 5 changes the default effective sweep behavior in the safe +direction. PR 4 is deliberately breaking: + +- The unshipped `WatchRule.spec.sourceNamespace` field is replaced with + `WatchRule.spec.rules[].sourceNamespace`. It never reached a release, so it is simply deleted: no + stored object can carry it and no manifest in the wild sets it, which is what makes a rejection shim + unnecessary rather than merely inconvenient. +- `ClusterResourceRule.scope` narrows to `Cluster` only. That field DID ship, so it stays in the schema + for one release as a **loud rejection** rather than being deleted, because a deleted field is + silently pruned from a re-applied legacy manifest. +- A legacy namespaced ClusterWatchRule cannot be converted automatically into a WatchRule: the move is + cross-kind and `sourceNamespace: "*"` requires an explicit target policy where legacy cluster rules + did not. + +There is no migration tool. The breaking change is carried by the release notes and +[UPGRADING.md](../../UPGRADING.md), which must state precisely what a target with no declared policy +admits, because the two halves pull in opposite directions: a WatchRule whose every item watches its +OWN namespace keeps working untouched (reason `LegacySourceNamespace`, no policy and no delegation +flag required), while every wildcard or cross-namespace item is denied. A conversion produces exactly +the second kind, so converting without also declaring `allowedSourceNamespaces` narrows what is +mirrored. Stating the denial unqualified would tell every existing operator their rules break on +upgrade, which is not true and is the more expensive error of the two. + +## Deferred work + +- Collapsing wildcard stream fan-out. A `"*"` item opens one stream per (type × admitted namespace); + a cluster-wide stream carrying a namespace **set** in its resync scope would collapse that without + widening the sweep. Tracked in [docs/TODO.md](../../TODO.md). +- A source-delete volume guard is deferred. An absolute count alone does not protect a small target's + whole folder, so it is better added later with a fully specified approval model if needed. +- A platform-owned, cross-namespace namespaced-watch API is deferred unless a real deployment needs + it. diff --git a/docs/design/watchrule-source-namespace/pr4-source-namespace-field.md b/docs/design/watchrule-source-namespace/historical-top-level-source-namespace-baseline.md similarity index 88% rename from docs/design/watchrule-source-namespace/pr4-source-namespace-field.md rename to docs/design/watchrule-source-namespace/historical-top-level-source-namespace-baseline.md index 4cf71c6b..be202363 100644 --- a/docs/design/watchrule-source-namespace/pr4-source-namespace-field.md +++ b/docs/design/watchrule-source-namespace/historical-top-level-source-namespace-baseline.md @@ -1,16 +1,21 @@ -# PR 4 — the sourceNamespace field and its authorization gate - -> Phase 4 of [source-namespace addressing](README.md). **Depends on:** -> [PR 1](pr1-namespace-scoped-resync.md) (two WatchRules on one target can now carry different -> `sourceNamespace` values, which is the fan-out PR 1 makes safe) and -> [PR 2](pr2-stream-scope-collapse.md). **Blocked from release without:** -> [PR 5](pr5-clusterwatchrule-source-ceiling.md) — see the -> [release gate](README.md#implementation-phases). API change: three new fields, one new condition, -> one printer column. - -Adds `WatchRule.spec.sourceNamespace`, `GitTarget.spec.allowedSourceNamespaces`, -`ClusterProvider.spec.allowWatchRuleSourceNamespaceOverride`, and the reconciler gate that binds -them. The model and naming rationale are in the [overview](README.md#the-model). +# Historical implementation baseline — the top-level sourceNamespace gate + +> This is an **unreleased, superseded implementation record**, not a deliverable phase of +> [source-namespace addressing](README.md). Its authorization model, conditions, bootstrap gate, +> source-scope snapshot, and reactivity work are the implementation baseline for +> [PR 4 scope by kind](pr4-cluster-scope-only.md). Its public top-level `sourceNamespace` field is +> replaced by `rules[].sourceNamespace` before reaching a release, and its ClusterProvider delegation +> boolean is renamed to `allowSourceNamespaceOverride`. Field names below are as built, not as +> shipped. +> +> Keep this document as evidence for reusable work. Development is reworked in PR 4; its release is +> blocked on [PR 5 deletion safety](pr5-gittarget-deletion-safety.md). Do not cut a release with the +> API described below. + +This original plan adds `WatchRule.spec.sourceNamespace`, `GitTarget.spec.allowedSourceNamespaces`, +`ClusterProvider.spec.allowWatchRuleSourceNamespaceOverride`, and the reconciler gate that binds them. +Only the first field's *location and cardinality* are superseded. The rest of the authorization model +is retained by PR 4. ## Example @@ -46,7 +51,7 @@ spec: clusterProviderRef: name: workspaces - # Source-cluster namespaces that may be mirrored into this target, by any rule kind. + # Source-cluster namespaces that WatchRules may mirror into this target. allowedSourceNamespaces: names: [repo-config] selector: @@ -78,11 +83,10 @@ grant: a target owner can configure a broad selector, including one matching eve so the source credential's RBAC remains the hard maximum. Set it only when the owner of an admitted GitTarget is trusted to choose a subset of what that credential may read. -The flag gates **granting only**. `allowedSourceNamespaces` plays two roles — widening a WatchRule -beyond its own namespace, and (in [PR 5](pr5-clusterwatchrule-source-ceiling.md)) narrowing a -ClusterWatchRule below cluster-wide — and only -the widening one is an authority grant. Gating a *restriction* behind a delegation flag would mean an -admin has to grant extra authority in order to reduce scope. +The flag gates **granting only**. In the selected design, `allowedSourceNamespaces` authorizes +WatchRule namespace selection; after PR 4 ClusterWatchRule is cluster-only and has no namespace to +narrow. The delegation flag therefore remains solely an explicit authority grant, never an accidental +side effect of connectivity configuration. ### Remote and in-cluster: same mechanism, very different sign-off @@ -200,10 +204,29 @@ Half of this is already wired: | Control-cluster Namespace labels | Reconcile affected GitTargets. | **Already wired** — `namespaceToGitTargets` with `LabelChangedPredicate` ([gittarget_controller.go:1147-1150](../../../internal/controller/gittarget_controller.go#L1147-L1150)). | | Source-cluster Namespace labels | Reconcile WatchRules whose GitTarget uses a source selector. | **Not wired** — there is no Namespace informer in `internal/watch` at all today. Entirely new. | -The watch manager already owns source-cluster watch lifecycles. This adds one label-filtered -Namespace informer **per active source cluster**, not one per WatchRule, emitting only meaningful -label changes and mapping them to WatchRules whose GitTarget resolves through that cluster. -[PR 5](pr5-clusterwatchrule-source-ceiling.md) extends the same informer to ClusterWatchRules. +The watch manager already owns source-cluster watch lifecycles. This adds one Namespace label +snapshot **per active source cluster**, not one per WatchRule, emitting only meaningful label +changes and mapping them to WatchRules whose GitTarget resolves through that cluster. +[PR 4](pr4-cluster-scope-only.md) reworks this snapshot for WatchRule rule-item scopes. It does not +extend it to ClusterWatchRule, which becomes cluster-only. + +> **As built: a refresh-cadence snapshot, not an informer.** The shipped implementation +> ([source_namespace_scope.go](../../../internal/watch/source_namespace_scope.go)) re-lists +> Namespaces on the manager's existing reconcile cadence (every 30s and on every rule change) +> instead of running a dedicated informer per cluster. Two reasons, and one accepted cost. +> +> It matches how this package already treats every other source-cluster input — the credential +> refresh explicitly "does not watch Secrets, it RE-CHECKS them" +> ([cluster_context.go:507-515](../../../internal/watch/cluster_context.go#L507-L515)) — so +> source-cluster state stays on one lifecycle rather than two, with one teardown path. And listing +> is armed LAZILY, by a selector policy actually asking, so an install with no selector policies +> never lists a namespace at all; an informer would have to decide its cluster set up front. +> +> The cost is that a revocation converges within a refresh interval rather than instantly. The gate +> tolerates that by construction: the compiled rule is what mirrors, and it is dropped the moment +> the reconcile the change enqueues observes it. Exact-name policies are unaffected — they never +> consult the cache. If a tighter bound is ever needed, the service's interface is the seam: +> swapping the snapshot for an informer changes nothing above `ResolveSourceNamespace`. ### The source-scope service — define this interface before writing the gate @@ -233,8 +256,9 @@ Exact-name policies must be answerable **without** the cache, so a source cluste access is denied still supports name-based policies. That is the degradation path below, and it falls out naturally if resolution checks names before consulting the cache. -The same service is what [PR 5](pr5-clusterwatchrule-source-ceiling.md) resolves its ceiling through, -so its shape is worth settling here rather than retrofitting. +PR 4 replaces the current one-namespace retained grant with an atomically replaced, whole-WatchRule +resolved scope. The service shape is still worth settling here, but its current representation cannot +be reused verbatim for rule-item namespaces. The in-cluster manager role already grants `namespaces` `get`/`list`/`watch` ([config/rbac/role.yaml](../../../config/rbac/role.yaml)). A remote provider used with a source @@ -254,7 +278,7 @@ substituted with the empty set, and never with the full set. | | **Establishing** a grant — no previously resolved scope for this rule | **Maintaining** a scope — a previously resolved scope exists | |---|---|---| -| Where it applies | This PR's gate: a WatchRule asking for a namespace it has not been granted. | [PR 5](pr5-clusterwatchrule-source-ceiling.md)'s ceiling, and any rule already running under a resolved policy. | +| Where it applies | This PR's gate: a WatchRule asking for a namespace it has not been granted. | A WatchRule already running under a selector-resolved scope. PR 4 ships selector-backed wildcards and resolves them from this same source-namespace snapshot, so this contract governs them too. | | Effect of *cannot say* | The grant is not established, so the rule is **not compiled**. Nothing runs; nothing is swept. | The **last known-good scope is retained** and keeps running. No narrowing, no widening, **no sweep**. | | Retryable error (cache syncing, source unreachable) | `Unknown` / `CheckingSourceNamespacePolicy`, `Stalled=False`. Retried. | Same. | | Terminal error (source Namespace `list` is `Forbidden` for a selector policy) | `False` / `SourceNamespacePolicyUnavailable`, **`Stalled=True`**. | `Unknown` / `SourceNamespacePolicyUnavailable`, **`Stalled=False`**. | @@ -264,11 +288,10 @@ The asymmetry in the last row is the whole point, and it is deliberate: - When **establishing**, "fail closed" means *do not start the stream*. A permanent `Forbidden` means the rule will never run without an operator change — granting the RBAC or switching to exact names — so `Stalled=True` is an accurate, actionable claim about a rule that is doing nothing. -- When **maintaining**, "fail closed" would mean *narrow to nothing*, and a narrowed set is the input - to a sweep — so failing closed there **deletes a tenant's Git content** on a transient outage. The - rule is also still running its already-granted streams (and, for a ClusterWatchRule, its - cluster-scoped ones), so `Stalled=True` would be a false claim that nothing is progressing. - See [PR 5 § unknown is not empty](pr5-clusterwatchrule-source-ceiling.md#2b-unknown-is-not-empty). +- When **maintaining**, "fail closed" would mean *narrow to nothing*. Before PR 5 this could become a + destructive sweep; after PR 5 it still stops useful streams, so retaining the last known-good scope + remains the correct behavior. The rule is still running its already-granted streams, so + `Stalled=True` would be a false claim that nothing is progressing. An actual **denial** — the policy evaluated and does not admit the namespace — is terminal in both directions: `False` / `SourceNamespaceNotAllowed` / `Stalled=True`. That is a refusal, not an @@ -501,7 +524,8 @@ need a case — the first is the one that will regress unnoticed. - The legacy test above passes and no existing WatchRule changes behavior. - A denied override leaves no stream running. - `task lint`, `task test`, `task test-e2e` pass. -- PR 4 is queued — the field must not reach a release without its ClusterWatchRule half. +- This document's top-level field is not released. Its code is carried forward only through the PR-4 + rework, which is released together with PR 5. --- diff --git a/docs/design/watchrule-source-namespace/pr1-namespace-scoped-resync.md b/docs/design/watchrule-source-namespace/pr1-namespace-scoped-resync.md index 75adaa67..d0512756 100644 --- a/docs/design/watchrule-source-namespace/pr1-namespace-scoped-resync.md +++ b/docs/design/watchrule-source-namespace/pr1-namespace-scoped-resync.md @@ -68,8 +68,7 @@ Each of the remaining PRs breaks that invariant, independently — which is why | PR | New source of multi-namespace fan-out on one GVR | |---|---| | [PR 2](pr2-stream-scope-collapse.md) | Named and cluster-wide selections become distinct concurrent streams for the same GVR. | -| [PR 4](pr4-source-namespace-field.md) | Two WatchRules in the target's namespace can carry different `sourceNamespace` values. | -| [PR 5](pr5-clusterwatchrule-source-ceiling.md) | A declared ceiling expands one cluster-wide selection into N per-namespace selections. | +| [PR 4](pr4-cluster-scope-only.md) | Rule-item namespaces can expand one namespaced-resource selection into N concrete source namespaces. | So this was not a defect to fix opportunistically alongside the feature. It is the load-bearing floor under all three, and the failure mode is silent data loss in a tenant's repository — a replay for @@ -110,15 +109,14 @@ testable change. Revisit coalescing if per-namespace replay volume becomes the p ## Revocation leaves prior content — a decision, not an oversight Stopping a stream does not remove what it already wrote. When a namespace leaves a watch set — a -[PR 5](pr5-clusterwatchrule-source-ceiling.md) ceiling tightening, a WatchRule deletion, a revoked -label — its manifests remain in Git. +WatchRule policy change, a WatchRule deletion, or a revoked label — its manifests remain in Git unless +the target explicitly selects sweep pruning. **Recommended: retain, and make it visible.** Deleting a tenant's manifests as a side effect of a policy edit is destructive, hard to undo in the moment, and easy to trigger by accident (a typo in a selector). Retention is also the safe direction under the failure mode in -[PR 5](pr5-clusterwatchrule-source-ceiling.md#2b-unknown-is-not-empty): if an unavailable selector were -ever read as an empty allow-list, a sweep-on-revocation would erase the target's entire namespaced -content. +[PR 5](pr5-gittarget-deletion-safety.md): if an unavailable selector were ever read as an empty +allow-list, the default deletion policy must still suppress a resulting sweep. The cost is real and must be documented rather than glossed: after a revocation, Git holds manifests from a namespace the policy no longer admits, and no automatic process removes them. Removing them is @@ -154,6 +152,5 @@ Verified by reverting the namespace half of `ResyncScope.Matches`: - ✅ `task lint`, `task test`, `task test-e2e` pass. - ⏭ **Retention-on-revocation is documented above but not yet enforced by a test.** Nothing in this PR can revoke a namespace — no code path removes one from a watch set yet — so the test has no - subject until [PR 5](pr5-clusterwatchrule-source-ceiling.md) introduces ceiling tightening. It is - carried as `TestCeiling_UnknownScopeRetainsPreviousAndDoesNotSweep` and the revocation envtest in - PR 5's plan. Recording it here rather than silently dropping it. + subject until PR 4 introduces rule-item namespace expansion. It is carried as the retained-scope + and policy-revocation tests in that plan. Recording it here rather than silently dropping it. diff --git a/docs/design/watchrule-source-namespace/pr2-stream-scope-collapse.md b/docs/design/watchrule-source-namespace/pr2-stream-scope-collapse.md index 80fbff47..be23b758 100644 --- a/docs/design/watchrule-source-namespace/pr2-stream-scope-collapse.md +++ b/docs/design/watchrule-source-namespace/pr2-stream-scope-collapse.md @@ -3,7 +3,7 @@ > Phase 2 of [source-namespace addressing](README.md). **Depends on:** > [PR 1](pr1-namespace-scoped-resync.md) — this PR is the first thing that makes a GitTarget watch one > GVR in two namespaces at once, which is unsafe until the resync sweep is namespace-scoped. -> **Blocks:** PR 4 (the gate is only as good as the stream scoping underneath it) and PR 5. +> **Blocks:** PR 4's authorization and rule-item namespace expansion. > Bug fix — no API change, no CRD regeneration. > > **Status: landed.** `SnapshotNamespaces` is now `WatchedType.WatchScopes`, which returns every @@ -29,18 +29,16 @@ named rule co-resident with an `UPDATE` cluster-wide rule loses its filter too. ### Why it matters to this workstream -Under [PR 4](pr4-source-namespace-field.md) this is a gate bypass. A ClusterWatchRule may +Under the [historical top-level-field implementation](historical-top-level-source-namespace-baseline.md) +this is a gate bypass. A ClusterWatchRule may legitimately select every source namespace once its GitTarget passes provider admission — but a co-resident WatchRule must not silently inherit that cluster-wide stream. Otherwise a WatchRule authorized only for `repo-config` receives events from every namespace the credential can read, and its `allowedSourceNamespaces` check passed only *before* the data plane widened it. -[PR 5](pr5-clusterwatchrule-source-ceiling.md) removes the `""` key **for the namespaced selections** -of any target with a declared ceiling — `scope: Cluster` rules keep emitting `""`, because a -namespace allow-list cannot constrain cluster-scoped types. So the collapse cannot trigger for a -namespaced GVR under a ceiling, but a target that mirrors a GVR both cluster-scoped and namespaced is -not covered by that, and the far more common undeclared case is not covered at all. This PR is what -governs both. +[PR 4](pr4-cluster-scope-only.md) removes namespaced selections from ClusterWatchRule entirely and +emits concrete namespaces for WatchRule items. Cluster-scoped rules still emit `""`. This PR remains +the protection when a GitTarget legitimately follows both kinds of stream for the same GVR. ### This behavior is currently asserted as intended diff --git a/docs/design/watchrule-source-namespace/pr3-clusterwatchrule-target-admission.md b/docs/design/watchrule-source-namespace/pr3-clusterwatchrule-target-admission.md index 97b9e95c..114ea1e4 100644 --- a/docs/design/watchrule-source-namespace/pr3-clusterwatchrule-target-admission.md +++ b/docs/design/watchrule-source-namespace/pr3-clusterwatchrule-target-admission.md @@ -95,7 +95,7 @@ already exists ClusterProvider → ClusterWatchRules does not. This check is separate from, and does not replace, -[`GitTarget.allowedSourceNamespaces`](pr5-clusterwatchrule-source-ceiling.md). They answer different +[`GitTarget.allowedSourceNamespaces`](README.md#the-model). They answer different questions: provider admission asks *may this target use this credential at all*, the ceiling asks *which source namespaces may reach this target's destination*. diff --git a/docs/design/watchrule-source-namespace/pr4-cluster-scope-only.md b/docs/design/watchrule-source-namespace/pr4-cluster-scope-only.md new file mode 100644 index 00000000..36a9beb1 --- /dev/null +++ b/docs/design/watchrule-source-namespace/pr4-cluster-scope-only.md @@ -0,0 +1,697 @@ +# PR 4 — scope by kind: cluster-only ClusterWatchRule and per-item WatchRule source namespaces + +> Phase 4 of [source-namespace addressing](README.md). **Breaking change**, accepted for the +> preliminary v1alpha3 API. Depends on [PR 1](pr1-namespace-scoped-resync.md), +> [PR 2](pr2-stream-scope-collapse.md), and [PR 3](pr3-clusterwatchrule-target-admission.md). +> +> **Release gate.** [PR 5 deletion safety](pr5-gittarget-deletion-safety.md) is implemented in the PR +> immediately after this one. **No release may be cut between the two merges** — the first release +> containing this PR also contains PR 5. See [§9](#9-release-and-rollback). +> +> This PR replaces the unshipped top-level `sourceNamespace` interface described in the +> [historical implementation baseline](historical-top-level-source-namespace-baseline.md). Its +> authorization model, three-valued source-scope service, status contract, and single gated compile +> path are **reused as built**; only the field's location and cardinality change. +> +> Code references verified against the tree on 2026-07-21. + +## End state + +Scope is carried by the rule kind: + +- **WatchRule** selects namespaced source resources. Each `ResourceRule` has an optional + `sourceNamespace`. +- **ClusterWatchRule** selects cluster-scoped source resources. Its per-item scope choice is gone. + +~~~yaml +apiVersion: configbutler.ai/v1alpha3 +kind: GitTarget +metadata: { name: acme, namespace: tenant-acme } +spec: + # The destination's exhaustive source-namespace policy. Names and selector are ORed. + allowedSourceNamespaces: + names: [tenant-acme, repo-config] + selector: + matchLabels: + gitops.configbutler.ai/mirrorable: "true" +--- +apiVersion: configbutler.ai/v1alpha3 +kind: WatchRule +metadata: { name: repo-config, namespace: tenant-acme } +spec: + targetRef: { name: acme } + rules: + - resources: [configmaps] # omitted → this WatchRule's own namespace + - resources: [secrets] + sourceNamespace: repo-config # one admitted source namespace + - resources: [deployments] + sourceNamespace: "*" # every namespace the target admits, live +--- +apiVersion: configbutler.ai/v1alpha3 +kind: ClusterWatchRule +metadata: { name: acme-crds } +spec: + targetRef: { name: acme, namespace: tenant-acme } + rules: + - resources: [customresourcedefinitions] + apiGroups: [apiextensions.k8s.io] +~~~ + +The resulting audit rule is one question per kind: a WatchRule's source namespaces are exactly what +its GitTarget's policy admits; a ClusterWatchRule is intentionally cluster-global and is bounded only +by its source credential's RBAC. + +## Existing PR 4 work to keep + +The current implementation has the right authorization and safety boundaries. Rework it in place; do +not replace it with a second implementation. Only its single-namespace API and representation are +discarded. + +| Current implementation | PR 4 treatment | +|---|---| +| `ClusterProvider.spec.allowWatchRuleSourceNamespaceOverride` | **Keep the semantics; rename to `allowSourceNamespaceOverride`.** It stays the platform-admin delegation for cross-source-namespace WatchRules, defaults to `false`, and stays deliberately separate from connectivity. | +| `GitTarget.spec.allowedSourceNamespaces` and `NamespaceMatcher` | **Keep unchanged.** It remains the destination-owned, exhaustive policy. Under this design it bounds WatchRules only; ClusterWatchRule has no namespaced selections to bound. | +| `SourceNamespaceAuthorized` condition, kstatus result, and user-facing denial reasons | **Keep.** It becomes the aggregate verdict over all rule items — see the [aggregation order](#5-status-contract-for-per-item-scopes). | +| `internal/authz` three-part gate and exact-name / selector evaluation | **Keep and call per item.** An explicit name can still be admitted by a policy selector; exact names remain usable when source-namespace labels cannot be read. | +| Shared `CompileWatchRule` path and bootstrap enforcement | **Keep.** Both reconcile and bootstrap must continue to use one gated compile path, so a restart cannot open an unauthorized watch window. | +| Source-cluster Namespace snapshot, three-valued result, retention on an unavailable selector, and requeue wiring | **Keep, and now also drive wildcard sets from it.** Refactor the retained state from one namespace to a complete resolved scope for a WatchRule. | +| GitTarget, ClusterProvider, and source-Namespace change mappers | **Keep.** A policy or provider revocation must remove the compiled rule and stop streams promptly. | +| Unit, controller, bootstrap, planning, status, and end-to-end gate tests | **Keep as the regression suite**, changing their fixtures from one top-level namespace to rule-item scopes. | + +Replace only these single-scope pieces: + +- `WatchRule.spec.sourceNamespace`, `EffectiveSourceNamespace()`, and `OverridesSourceNamespace()` + become `ResourceRule.sourceNamespace` resolution. +- `CompiledRule.SourceNamespace string` and the `NamespacedName → string` retained-grant map become an + atomically replaced, whole-WatchRule resolved scope. +- ClusterWatchRule's public scope choice and all namespaced ClusterWatchRule runtime behavior go away. + +### Cross-source-namespace delegation stays on ClusterProvider, as `allowSourceNamespaceOverride` + +**Keep the delegation boolean, default `false`, and rename it to `allowSourceNamespaceOverride`.** +ClusterProvider is the right object: it is cluster-scoped and platform-admin-owned, so the +administrator who owns the source credential explicitly decides whether owners of admitted GitTargets +may choose source namespaces other than their WatchRule's namespace. + +The `WatchRule` prefix in the old name was carrying the disambiguation, and after [§1](#1-clusterwatchrule-becomes-cluster-only) +there is nothing left to disambiguate: ClusterWatchRule no longer has a source-namespace choice, so a +source-namespace override is a WatchRule concept by construction. `allowSourceNamespaceOverride` +reads against the vocabulary the rest of this design already uses — `sourceNamespace`, +`allowedSourceNamespaces`, `SourceNamespaceAuthorized` — and the load-bearing half of the name, the +fact that it delegates a **source namespace** and not a `targetRef`, is retained. + +Still do not widen it to `allowCrossNamespaceWatchRules`: that would read as permitting a +cross-namespace `targetRef`, which [decision 8](#closed-design-decisions) explicitly refuses. + +The field never reached a release, so this is a plain rename with no deprecation shim — unlike the two +superseded fields in [decision 10](#closed-design-decisions), no stored object can carry the old key, +because the CRD that defines it has not shipped. + +~~~yaml +apiVersion: configbutler.ai/v1alpha3 +kind: ClusterProvider +metadata: + name: workspaces +spec: + # False by default. A platform admin opts in deliberately. + allowSourceNamespaceOverride: true +--- +apiVersion: configbutler.ai/v1alpha3 +kind: GitTarget +metadata: + name: acme + namespace: tenant-acme +spec: + allowedSourceNamespaces: + names: [repo-config, team-payments] +~~~ + +The provider flag is necessary but never sufficient. A cross-source-namespace request also requires +that the ClusterProvider admits the GitTarget's namespace and that the GitTarget policy admits the +requested source namespace. The credential's Kubernetes RBAC remains the hard maximum. + +| WatchRule item request | Provider delegation | GitTarget policy | Outcome | +|---|---|---|---| +| omitted, or equal to the WatchRule namespace | not required | absent | allowed, legacy behavior | +| omitted, or equal to the WatchRule namespace | not required | declared and includes that namespace | allowed | +| omitted, or equal to the WatchRule namespace | not required | declared but does not include that namespace | refused; the policy is exhaustive | +| a different explicit name | required | declared and admits that name, by name **or by selector** | allowed | +| a different explicit name | absent or `false` | any | refused | +| a different explicit name | `true` | absent, or does not admit that name | refused | +| `"*"` | required | absent | refused — deny-by-default; `"*"` is not a backdoor | +| `"*"` | required | declared | expands to exactly the admitted set, by names and/or selector | + +`"*"` requires delegation even if the policy happens to list only the WatchRule's own namespace: it +expresses a request to follow the policy's set, and a later policy edit could otherwise widen the +watch without the platform-admin opt-in. + +## Closed design decisions + +These close the questions from the rejected alternatives. They are part of PR 4's contract, not +implementation choices to revisit while coding. + +1. **Use singular `rules[].sourceNamespace`.** Repeat a ResourceRule when two explicitly named + namespaces need the same resource selector. Do not add `sourceNamespaces: []string`: `"*"` already + expresses the useful many-namespace case, bounded by the target policy, without a second list + shape. +2. **Name the field `sourceNamespace`, not `namespace`.** On a namespaced CRD, `rules[].namespace` + reads ambiguously against `metadata.namespace`. `sourceNamespace` matches the condition + (`SourceNamespaceAuthorized`), the target field (`allowedSourceNamespaces`), and this folder's + vocabulary. +3. **Ship the selector-backed wildcard in this release.** A wildcard restricted to names-only policies + is merely an alias for the list; the dynamism is the whole point, and it is what replaces the + removed all-namespaces capability. The source-Namespace snapshot, three-valued result, and + retention path this needs are already built and shipped by the baseline PR — this reuses them + rather than adding them. +4. **`allowedSourceNamespaces: {selector: {}}` is the "every source namespace" declaration.** A + present-but-empty selector matches every namespace, and it stays self-updating. See + [§3](#3-which-namespaces-a-target-admits). +5. **Refuse the whole WatchRule when any explicit named item is denied.** Do not trim the denied item + and run a partial rule. A `"*"` item resolving to an empty admitted set is different: it is valid, + produces no selections for that item, and does not stall the WatchRule — but it must be visible. +6. **Keep per-resource-rule namespace granularity.** It is worth the atomic resolved-scope refactor: + one WatchRule can intentionally follow different resource types in its own namespace, a named + source namespace, or the target's admitted set. This is clearer than a top-level plural field and + makes the authorization visible beside the resource selector it applies to. +7. **Adopt the breaking two-object model.** ClusterWatchRule is cluster-scoped only; WatchRule is the + namespaced surface. The lower permanent runtime complexity is worth the one-time migration on this + preliminary API. +8. **Do not retain platform-authored namespaced watches from outside the target namespace.** A + platform administrator may manage the manifest, but it must create the WatchRule in the target + namespace. Do not add a namespaced `targetRef` or keep namespaced ClusterWatchRule as a workaround + without a new authorization design. +9. **No migration tool.** The conversion is mechanical and the API is preliminary. The breaking change + is carried by the release notes and [UPGRADING.md](../../UPGRADING.md) — see [§8](#8-migration). +10. **The one field that SHIPPED stays in the schema for one release as a loud rejection.** Deleting a + field makes a re-applied legacy manifest *silently pruned*; keeping it makes the apply fail. That + reasoning applies to `ClusterResourceRule.scope` and only to it — `WatchRule.spec.sourceNamespace` + never reached a release, so it is deleted outright. See + [§1](#1-clusterwatchrule-becomes-cluster-only) and + [§2](#2-watchrule-gains-rulessourcenamespace). +11. **Keep the ClusterProvider delegation boolean, renamed `allowSourceNamespaceOverride`.** `false` + remains the default and it is required for every cross-source-namespace request, including `"*"`. + GitTarget policy remains the independent, narrower approval. + +## 1. ClusterWatchRule becomes cluster-only + +`ClusterResourceRule.scope` +([clusterwatchrule_types.go:111-117](../../../api/v1alpha3/clusterwatchrule_types.go#L111-L117)) stops +being a scope selector. + +**Keep the field; narrow it.** Make it `+optional` with `+kubebuilder:default=Cluster` and +`+kubebuilder:validation:Enum=Cluster`, and say in its description that it is deprecated and names its +replacement. Deleting the field instead would be worse in two ways, and both are silent: + +- CRD pruning happens on **write**. Once the schema drops the field, re-applying a legacy + `scope: Namespaced` manifest is accepted with the value pruned away — no error anywhere — and the + rule quietly stops mirroring namespaced objects. +- A stored pre-release object keeps its value in etcd, but Go code that no longer has the field cannot + read it, so the controller has nothing to refuse. Inferring the refusal from "this selector resolved + to a namespaced type" instead is ambiguous for `resources: ["*"]`, which legitimately resolves + cluster-scoped records — see [the restart fixture](../../../test/e2e/templates/restart/watchrule-wildcard.tmpl). + +Retaining a narrowed enum gives an apply-time API-server rejection for the first case and a readable +Go value for the second. Remove the field entirely one release later, or at `v1beta1`. + +Three enforcement points, in this order: + +1. **Admission** — the narrowed enum rejects `scope: Namespaced` at apply time. +2. **Compile** — in the single gated path shared by bootstrap and reconcile + ([watchrule_compile.go:61](../../../internal/watch/watchrule_compile.go#L61) and its ClusterWatchRule + sibling), refuse any ClusterWatchRule holding a stored scope other than `Cluster`. The rule compiles + **no** stream and the terminal condition says: + + > ClusterWatchRule is cluster-scoped only; watch namespaced resources with a WatchRule and + > `rules[].sourceNamespace`. + +3. **Resolution** — `collectClusterWatchRuleSelections` + ([watched_type_resolver.go:312-329](../../../internal/watch/watched_type_resolver.go#L312-L329)) + always matches with `ResourceScopeCluster`, so even a pruned or absent value cannot widen a stream. + Its selections keep using namespace `""`, now only for genuinely cluster-scoped types. + +The `ResourceScope` Go type and both constants stay: `matchesScope` +([watched_type_resolver.go:397](../../../internal/watch/watched_type_resolver.go#L397)) still needs them +internally to resolve WatchRule selectors against namespaced records. What goes away is the *public* +choice. + +The critical test is `TestBootstrap_PreExistingNamespacedClusterRuleIsRefused`: it must compile no +stream before the first reconciliation can publish status. + +This also deletes the abandoned runtime-ceiling design for that kind: no ClusterWatchRule +source-namespace expansion, no `clusterWatchRuleFingerprint` scope component, no ClusterWatchRule +source-policy mapper, no selector-outage retention path for that kind. + +## 2. WatchRule gains `rules[].sourceNamespace` + +Add `sourceNamespace` to `ResourceRule` +([watchrule_types.go:85](../../../api/v1alpha3/watchrule_types.go#L85)). Validate it structurally as +either the literal `"*"` or a DNS-1123 label — lower-case alphanumerics and `-`, starting and ending +alphanumeric, `MaxLength=63` — so a malformed namespace is rejected at admission rather than resolving +to nothing at compile time. + +`ResourceRule` is not shared with `ClusterResourceRule`, so the field cannot leak into the cluster +kind. + +| `rules[].sourceNamespace` | Meaning | +|---|---| +| omitted | The WatchRule's own namespace. Legacy behavior, byte-for-byte | +| explicit name | One source namespace, admitted by the target policy and the provider delegation gate | +| `"*"` | Every source namespace `GitTarget.allowedSourceNamespaces` admits — never every namespace that exists | + +**Delete `WatchRule.spec.sourceNamespace` outright** — unlike `scope`, which is retained as a +rejection. The retention argument is about STORED objects: an unrecognised top-level field is pruned on +re-apply, and a stored pre-release value that Go can no longer read would make the rule silently watch +its own namespace instead of the one it asked for. Neither hazard exists here, because the field was +never on `main` and so cannot be in any cluster's etcd or in any manifest. Retaining a rejection for a +field nobody can have set would be ceremony, and it would leave a phantom in `kubectl explain` that +readers must be told to ignore. + +The Go field, the CEL guard, the `SourceNamespaceFieldRemoved` reason, and the compile-path refusal +all go with it. Pre-release manifests that still set the field — including this repo's own fixtures — +are updated in the same change, which is the whole affected population. + +**A denied explicit name denies the whole WatchRule.** The object reports +`SourceNamespaceAuthorized=False`, `Stalled=True`, starts no streams, and its message names the failing +item. Partial success — mirroring two of the three namespaces the rule asked for — is worse than a loud +failure. A wildcard never denies: it narrows to the admitted set, which may be empty. + +## 3. Which namespaces a target admits + +`GitTarget.spec.allowedSourceNamespaces` is the existing +[`NamespaceMatcher`](../../../api/v1alpha3/namespace_matcher.go) and does not change. What changes is +that a wildcard now resolves through it: + +| Policy on the GitTarget | `sourceNamespace: "*"` resolves to | +|---|---| +| undeclared | **denied** — deny-by-default. The message names the fix | +| `{}` (declared, empty) | nothing. An empty declared policy admits nothing, by the matcher's contract | +| `names: [a, b]` | exactly `a` and `b`, statically, with no source-cluster access | +| `selector: {matchLabels: …}` | every source namespace carrying those labels, live | +| `selector: {}` | **every source namespace** — the deliberate "all namespaces" declaration | + +The last row is the replacement for today's `ClusterWatchRule` + `scope: Namespaced`, and it is +strictly better: it is declared by the destination owner rather than by the rule author, it is legible +on the GitTarget, and it stays self-updating as namespaces come and go. `LabelSelectorAsSelector` +returns `labels.Everything()` for a present-but-empty selector and `labels.Nothing()` for a nil one, +which is exactly the declared-versus-absent distinction `NamespaceMatcher` is built around — add a test +pinning it, because it currently has none. + +Two invariants carry over from the historical baseline unchanged and must not be re-litigated: + +- **No self-namespace exception.** Once a policy is declared it is exhaustive, including for an omitted + item that resolves to the rule's own namespace ([README](README.md#no-self-namespace-exception)). +- **Names stay answerable without source-cluster Namespace access.** `MatchesName` is consulted before + the selector, so a cluster whose Namespace `list` is `Forbidden` still supports name-based policies — + including a `"*"` item against a names-only policy. This degradation path is the half most likely to + regress unnoticed. + +### Prior art: how Flux does this + +Flux has exactly one implemented namespace ACL: `AccessFrom` in +[fluxcd/pkg](https://github.com/fluxcd/pkg) (`apis/acl/acl_types.go`), evaluated by +`runtime/acl.Authorization.HasAccessToRef`. It is worth reading before implementing this section +because four of its choices are the same as ours, and three of the differences are load-bearing. + +**Same mechanism, independently arrived at:** + +- Namespace **labels** are the grant, and the selectors are a list evaluated with a logical OR — our + `names` ∪ `selector`, ORed. +- **An empty selector matches every namespace**, stated in the type's own godoc: *"An empty map of + MatchLabels matches all namespaces in a cluster."* That is [decision 4](#closed-design-decisions) + and [§3](#3-which-namespaces-a-target-admits)'s last row, already shipped upstream. +- **Absent ACL denies.** `HasAccessToRef` refuses when the referenced object declares none. +- **"Cannot say" is not "denied."** The Namespace read failure returns a plain error while a real + mismatch returns a distinctly typed `AccessDeniedError`, and callers branch on `IsAccessDenied`. + That is [§4.4](#44-unknown-is-not-empty)'s three-valued result in a different vocabulary — worth + citing when someone proposes collapsing it back to a boolean. +- The **literal `*`** is Flux's token for handing matching over to a selector too: + `CrossNamespaceObjectReference.name: "*"`, whose `matchLabels` field documents *"MatchLabels + requires the name to be set to `*`."* + +**Where we deliberately differ:** + +1. **Direction.** Flux's ACL sits on the object being *referenced* and admits *referrers* by their + namespace labels. `allowedSourceNamespaces` sits on the destination and admits the namespaces data + is *read from*. Same deny-unless-granted skeleton, different axis; Flux has no precedent for a + data-flow-direction policy, so nothing upstream validates the direction for us. +2. **The self-namespace exception.** `HasAccessToRef` returns "allowed" for a same-namespace reference + *before* it looks at the ACL. We deliberately have none + ([README](README.md#no-self-namespace-exception)): a declared policy is exhaustive. Flux can afford + the shortcut because a same-namespace reference grants nothing the referrer's own RBAC did not + already imply; ours would let a rule keep writing a namespace's content into Git after the + destination owner tightened the policy to exclude it. +3. **Flux never enumerates.** Every Flux check has one concrete candidate namespace, `Get`s that one + Namespace, and matches its labels. Nothing upstream turns a selector into a namespace *set*. + `sourceNamespace: "*"` does, which is why this design needs the source-cluster Namespace snapshot, + the refresh cadence, and — the part with no upstream analogue to copy — + [§4.3](#43-invalidation--the-silent-one)'s fingerprint over the resolved set. Treat that as the + unproven half. +4. **Selector shape.** Flux's `NamespaceSelector` carries `matchLabels` only — no `matchExpressions`, + no name list. We keep a full `LabelSelector` plus `names`, because names stay answerable when the + source cluster forbids Namespace `list` ([§3](#3-which-namespaces-a-target-admits)). Flux's shape + has no such degradation path; it simply fails the check. + +**And the caution.** This mechanism is the part of Flux's model that did *not* grow. In the manifests +flux-operator bundles, Flux 2.6.4 carried `accessFrom` on `Bucket`, `GitRepository`, `HelmChart`, +`HelmRepository`, and `ImageRepository`; by 2.9.0 only `HelmRepository` and `ImageRepository` still +have it, and the `HelmRepository` copy is annotated in-schema *"NOT implemented, provisional as of +[fluxcd/flux2#2092](https://github.com/fluxcd/flux2/pull/2092)"*. `OCIRepository` never got one. So +after roughly four years the selector ACL is implemented for exactly one reference — +`ImagePolicy` → `ImageRepository`. There is no design RFC for it either: +[RFC-0001](https://github.com/fluxcd/flux2/blob/main/rfcs/0001-authorization/README.md) is a +memorandum recording the model as of v0.24, and it files cross-namespace references under *security +considerations* with an admission-controller workaround rather than a field. + +What Flux shipped instead is a **cluster-wide, platform-admin boolean**: `--no-cross-namespace-refs` +(fluxcd/pkg `runtime/acl/flags.go`), which flux-operator's multitenant profile applies to every +controller alongside `--default-service-account`, and which +[RFC-0012](https://github.com/fluxcd/flux2/blob/main/rfcs/0012-external-artifact/README.md) +recommends third-party controllers expose. Tenancy is then enforced by RBAC and impersonation. + +Two things follow for this PR. First, it supports keeping the delegation boolean: an admin-owned +on/off switch is the part of this shape with a proven track record, and ours is strictly better placed +than Flux's — an API field on the cluster-scoped ClusterProvider is per-source-credential and +per-cluster, where a process flag is per-deployment. Second, it argues for keeping the selector half +**narrow**: one policy field, on GitTarget, with no per-kind variants and no second ACL surface +elsewhere in the API. The source credential's RBAC remains the hard ceiling, exactly as it is for Flux. + +## 4. Compile, expansion, and invalidation + +### 4.1 The resolved scope + +`CompiledRule.SourceNamespace string` ([store.go:20-49](../../../internal/rulestore/store.go#L20)) is a +single-namespace representation and cannot be reused. Replace it with a concrete namespace set **per +compiled resource rule**: `CompiledResourceRule.SourceNamespaces []string`, always expanded to names by +compile time. Neither a wildcard nor a policy reference survives into the data plane. + +The resolved scope is a **pure function of (rule spec, target policy, source Namespace snapshot), +recomputed on every compile and replaced atomically**. Nothing per-item is persisted across a spec +change, which disposes of the rule-item identity problem: items need no stable API identity because no +state outlives the spec that produced them. + +The one exception is the retained grant the establishing/maintaining contract requires +(`sourceNamespaceScope.grants`, +[source_namespace_scope.go](../../../internal/watch/source_namespace_scope.go)). Widen it from +`map[NamespacedName]string` to the whole resolved scope and **key it by the rule's spec hash**: +retention applies only while the spec is unchanged, and a spec edit discards it and re-establishes from +scratch. Keying by item index would let a reorder inherit another item's grant. + +### 4.2 Expansion, not filtering + +`collectWatchRuleSelections` +([watched_type_resolver.go:284-307](../../../internal/watch/watched_type_resolver.go#L284-L307)) appends +one selection per **(matched record × resolved namespace)**. + +Expand here rather than filtering events at the read site. An expanded selection carries the scope +through the plan hash, the informers, **and** the resync path for free; a read-site filter has to be +repeated at each of them and is silently wrong if one is missed — and it would also mean an unfiltered +LIST/WATCH over every namespace, so the data crosses into the process before being dropped. + +Neither an omitted item nor a `"*"` item ever emits a raw `""` key: both resolve to concrete names. +Only ClusterWatchRule emits `""` now, so [PR 2](pr2-stream-scope-collapse.md)'s collapse rules are +unaffected. + +### 4.3 Invalidation — the silent one + +`rulesFingerprint` +([watched_type_resolver.go:469](../../../internal/watch/watched_type_resolver.go#L469)) is computed +**only from compiled rules** and is what gates the table rebuild. A wildcard's inputs — the GitTarget +policy and source-cluster Namespace labels — are **not rule state**, so a mapper that merely requeues +the WatchRule is not sufficient: reconciliation runs, the fingerprint is unchanged, the rebuild is +skipped, and the resident table keeps the old namespace set. Streams carry on at their old width and +every diff looks correct, because the rule object genuinely did not change. + +So `watchRuleFingerprint` +([watched_type_resolver.go:490](../../../internal/watch/watched_type_resolver.go#L490)) must hash **each +item's resolved namespace set**, replacing its single `src=` component. Since compilation is what +resolves the set, the fingerprint sees it for free — provided compilation always precedes the rebuild. + +### 4.4 Unknown is not empty + +An unevaluatable selector must **never** be read as a valid empty allow-list. An empty resolved set +means "watch nothing", and combined with a resync it means Git content for those namespaces leaves the +desired snapshot — a transient outage becomes the input to a sweep. The three-valued result already +built into `authz.SourceScopeResult` +([source_namespace.go](../../../internal/authz/source_namespace.go)) exists for exactly this, and the +[establishing versus maintaining contract](historical-top-level-source-namespace-baseline.md#establishing-versus-maintaining-a-scope) +applies verbatim, now per item: + +- **Establishing** (no retained scope for this spec): the item does not compile. A retryable error is + `Unknown`/`CheckingSourceNamespacePolicy`; a permanent one is + `False`/`SourceNamespacePolicyUnavailable`/`Stalled=True`. +- **Maintaining** (a retained scope exists for this spec): retain it, keep running, no narrowing, no + widening, **no sweep**. `Unknown`/`SourceNamespacePolicyUnavailable`, `Stalled=False`. +- **Denial** — the policy evaluated and does not admit — is terminal in both directions and must not + share a code path with "cannot say". + +On a denial or revocation, remove the compiled rule and replan the watch manager **before** publishing +terminal status. On "cannot say", do neither. + +### 4.5 The gate becomes per item + +`authz.WatchRuleSourceNamespaceAdmitted` +([source_namespace.go:135](../../../internal/authz/source_namespace.go#L135)) currently derives its +candidate from `rule.EffectiveSourceNamespace()`. Split that: the ordering contract (own namespace free +while no policy is declared; a different namespace needs provider admission plus the delegation flag; a +declared policy is exhaustive) is per **candidate namespace** and stays exactly as built. The caller +supplies the candidate, and for `"*"` asks the resolver to enumerate the admitted set instead of testing +one name. `EffectiveSourceNamespace()` and `OverridesSourceNamespace()` move down to the item. + +## 5. Status contract for per-item scopes + +`SourceNamespaceAuthorized` stays one condition per object, per the +[status contract](historical-top-level-source-namespace-baseline.md#status-contract-kstatus-compatible). +With mixed items it needs a stated aggregation, or two implementations will disagree. + +**Reason precedence** (first match wins): + +1. any item denied → `False` / `SourceNamespaceNotAllowed` / `Stalled=True` +2. any item permanently unevaluatable while establishing → `False` / + `SourceNamespacePolicyUnavailable` / `Stalled=True` +3. any item retaining a scope it can no longer re-evaluate → `Unknown` / + `SourceNamespacePolicyUnavailable` / `Stalled=False` +4. any item still resolving → `Unknown` / `CheckingSourceNamespacePolicy` +5. every item admitted, at least one naming a namespace other than the rule's own → `True` / + `SourceNamespaceAllowed` +6. every item omitted → `True` / `LegacySourceNamespace` + +**Messages name the item**, by index *and* by its resources and requested namespace — an index alone +goes stale the moment somebody reorders the list while reading the message. + +**An admitted-but-empty wildcard must be visible.** A rule whose items all resolved to zero namespaces +is not stalled, but it must not read as healthy either: report `True` with reason +`NoAdmittedSourceNamespaces` and let the existing `StreamsRunning` / `ResourcesResolved` surfaces show +the zero. A rule that mirrors nothing while reporting `Ready=True` with no explanation is a silent +no-op. + +**`StreamSummaryForWatchRule` +([stream_readiness.go:133](../../../internal/watch/stream_readiness.go#L133)) can no longer be computed +from the spec.** It takes a `configv1alpha3.WatchRule` and rebuilds its keys from +`EffectiveSourceNamespace()`; with a wildcard the resolved set exists only in the compiled rule, so the +roll-up must read the compiled rule (or the resolved scope) instead. Missing this makes a wildcard rule +permanently not-ready while its streams run — the same class of bug the singular field already hit once. + +The `SourceAuthorized` printer column stays as shipped. + +## 6. Reactivity + +| Input changes | Required reaction | Status after the baseline PR | +|---|---|---| +| WatchRule spec | Recompile, re-resolve, re-project | Wired | +| GitTarget `allowedSourceNamespaces` | Recompile its WatchRules **and re-project the table** | Mapper wired; the fingerprint half is [§4.3](#43-invalidation--the-silent-one) | +| ClusterProvider `allowedNamespaces` or the delegation flag | Reconcile affected GitTargets **and their WatchRules** | Wired — the ClusterProvider → WatchRules mapper shipped with the baseline | +| Source-cluster Namespace labels | Re-resolve selector-backed items; grant and revoke | Wired — the per-source-cluster snapshot and its enqueue channel shipped with the baseline; it must now also drive wildcard sets | + +The snapshot remains a refresh-cadence re-list, not an informer, and remains armed lazily by a selector +policy actually asking. An install with no selector policies never lists a namespace. + +## 7. Wildcard fan-out is an accepted cost + +`targetWatchSpecs` opens one stream per `(GVR, namespace)` scope +([target_watch.go:230-243](../../../internal/watch/target_watch.go#L230-L243)) and `ResyncScope` is +single-namespace ([git/types.go:334](../../../internal/git/types.go#L334)). So a `"*"` item over **N** +admitted namespaces and **M** matched types opens **N×M** informers and produces **N×M** resync scopes, +where today's cluster-wide ClusterWatchRule uses M. + +This is the price of the safe direction — the alternative is one wide stream filtered at the read site, +which loses the per-namespace resync scope [PR 1](pr1-namespace-scoped-resync.md) landed to create. +Accept it here, state the shape in the field documentation, and follow up: a cluster-wide stream whose +gather carries a namespace **set** would collapse the fan-out without widening the sweep. Tracked in +[docs/TODO.md](../../TODO.md). + +The `PendingSample` cap of five entries +([watchrule_types.go:182](../../../api/v1alpha3/watchrule_types.go#L182)) also stops being +representative once totals are N×M. Leave the cap; do not grow the status object. + +## 8. Migration + +Two capabilities are removed on purpose: + +- **Platform-authored namespaced mirroring from outside the tenant namespace.** ClusterWatchRule's + cross-namespace `targetRef` let a platform team mirror a tenant's namespaced resources with no object + in that tenant's namespace. A platform administrator may still own the manifest, but it must now live + in the tenant namespace. If that is unacceptable for a deployment, stop this PR or design a separate + namespaced-target WatchRule with its own authorization review. +- **Rule-author-declared all-namespace watching.** `scope: Namespaced` let the rule author reach every + namespace. The replacement is destination-declared: `allowedSourceNamespaces: {selector: {}}` plus + `sourceNamespace: "*"` — same reach, declared by the GitTarget owner instead of the rule author. + +There is **no migration tool**. A conversion webhook cannot perform a cross-kind move, and a dry-run +generator is more surface than this change deserves on a preliminary API with a small install base. +Instead: + +- A `feat(api)!:` commit with a `BREAKING CHANGE:` footer, so the removal is unmissable in the generated + release notes. +- A section in [UPGRADING.md](../../UPGRADING.md), adjacent to the existing v1alpha2 → v1alpha3 rewrite + instructions, containing: + 1. the two removed capabilities above; + 2. the conversion — a namespaced `ClusterWatchRule` becomes a `WatchRule` **in the tenant namespace**; + its namespaced items become `sourceNamespace: "*"` (or explicit names); any cluster-scoped items + stay behind in a revised ClusterWatchRule; + 3. the warning that a target with no policy admits **only** a WatchRule whose every item watches its + own namespace — every wildcard or cross-namespace item a conversion produces is denied — so + converting without also declaring `allowedSourceNamespaces` narrows production data; and that + under PR 5's `onEvent` default the narrowing leaves stale documents in Git rather than deleting + them; + 4. a `kubectl get clusterwatchrules -o json | jq …` one-liner that lists every affected object and its + target. + +## 9. Release and rollback + +PR 5 is implemented in the PR immediately after this one; both merge before any release is cut. + +1. Merge this PR. **`main` is now in a do-not-release window.** +2. Merge [PR 5](pr5-gittarget-deletion-safety.md), which adds `prune.mode` and makes the resync sweep + opt-in. The window closes. +3. Release both together, with the breaking change in the notes. + +**Rolling back the controller past that release is unsupported while migrated manifests exist**, and the +reason belongs in the release notes: the previous controller neither understands +`rules[].sourceNamespace` (so a rule resolves to its own namespace — a *narrower* desired set) nor has +`prune.mode` (so a resync sweeps). Together that deletes the mirrored namespaces' documents. If a +rollback is unavoidable, remove or narrow the affected WatchRules **first**. + +The same skew exists inside a rolling upgrade: CRDs are cluster-wide, so an old leader can observe a new +WatchRule, ignore `rules[].sourceNamespace`, and mirror the wrong namespace's content into Git. Complete +the controller rollout before applying migrated manifests. + +## Rework sequence + +1. **API and generated contract.** Move `sourceNamespace` from `WatchRuleSpec` onto `ResourceRule`, + retain the two superseded fields as rejections per decision 10, rename + `allowWatchRuleSourceNamespaceOverride` to `allowSourceNamespaceOverride`, keep + `allowedSourceNamespaces`, then regenerate the CRDs. + Rewrite the retained fields' Go/CRD documentation so it no longer says the policy bounds every rule + kind or that it narrows ClusterWatchRule. +2. **Authorization.** Generalize the existing `internal/authz` one-namespace decision into a per-item + candidate check plus a wildcard enumeration. Keep its exact-name fast path, selector result states, + reasons, and provider-delegation check; aggregate per [§5](#5-status-contract-for-per-item-scopes). +3. **Compilation and state.** Refactor `CompileWatchRule`, `CompiledRule`, the RuleStore, the + source-scope service, and the WatchRule fingerprint/selection collection to operate on the whole + resolved scope atomically. Keep the current bootstrap path and dependency mappers wired through that + compiler. +4. **ClusterWatchRule.** Narrow the public scope selector, select only discovery-confirmed + cluster-scoped types, and add the stored-object refusal to that kind's shared bootstrap/reconcile + compile path. +5. **Status and roll-up.** Implement the aggregation order and move `StreamSummaryForWatchRule` onto the + compiled rule. +6. **Documentation and tests.** Replace the top-level-field material listed in + [§10](#10-docs-that-become-false), and carry the existing authorization cases forward before adding + rule-item, wildcard, and migration cases. + +## 10. Docs that become false + +Must change in the same PR: + +- [configuration.md](../../configuration.md) line 12 — "`ClusterWatchRule` does the same for + cluster-scoped or cross-namespace watching"; +- [configuration.md](../../configuration.md) lines 784 and 821-835 and 1002 — the `ClusterWatchRule` + section, its `scope` example, and the multi-namespace use case; +- the `ClusterWatchRule` godoc + ([clusterwatchrule_types.go:150-163](../../../api/v1alpha3/clusterwatchrule_types.go#L150-L163)) and + the `WatchRule` godoc + ([watchrule_types.go:202-215](../../../api/v1alpha3/watchrule_types.go#L202-L215)), both of which + describe the superseded model, plus the generated CRDs; +- the WatchRule section of [status-conditions-guide.md](../../spec/status-conditions-guide.md) — the new + reason `NoAdmittedSourceNamespaces` and the aggregation order; +- [architecture.md](../../architecture.md), [security-model.md](../../security-model.md), and + [rbac.md](../../rbac.md) where they describe cross-namespace ClusterWatchRule watching; +- [UPGRADING.md](../../UPGRADING.md) per [§8](#8-migration), and the + [INDEX.md](../../INDEX.md) entry for this folder, which still describes the abandoned ClusterWatchRule + ceiling. + +Fixture conversions worth calling out, because they change what the test proves: + +- [test/e2e/templates/restart/watchrule-wildcard.tmpl](../../../test/e2e/templates/restart/watchrule-wildcard.tmpl) + exists to prove the **startup snapshot** honours `apiVersions: ["*"]`, "otherwise a controller restart + snapshots an empty cluster and deletes the whole tracked tree". Its replacement must still cover that + regression, not merely compile. +- [unsupported_folder_e2e_test.go:177](../../../test/e2e/unsupported_folder_e2e_test.go#L177), plus the + ~11 unit-test files referencing the scope enum. + +## Tests + +**Refusal and admission** + +- `TestBootstrap_PreExistingNamespacedClusterRuleIsRefused` — a stored ClusterWatchRule with + `scope: Namespaced` compiles no stream before the first reconcile can publish status. +- A ClusterWatchRule with `resources: ["*"]` still resolves its cluster-scoped types: the refusal keys + on the stored scope, not on what the selector happens to resolve. +- Applying `scope: Namespaced` is rejected by the API server (envtest against the generated CRDs) — the + guard that the field was narrowed rather than deleted. + +**Per-item resolution** + +- Omitted, explicit, and wildcard items each generate the expected selections; a mixed rule resolves each + item independently. +- A denied explicit item stops the entire WatchRule and the message names it; an empty wildcard set does + not stall the rule and reports `NoAdmittedSourceNamespaces`. +- `"*"` with no declared policy is denied, not "all namespaces"; against `{}` it admits nothing; against + `names` it expands exactly; against `selector: {}` it admits every namespace in the snapshot. +- With source Namespace `list` forbidden, a `"*"` item against a **names-only** policy still resolves, + while a selector item follows the establishing/maintaining table. Both halves need a case. +- The retained gate suite still proves provider-delegation denial and revocation, target-policy denial + and revocation, selector uncertainty, and bootstrap refusal before any stream starts. + +**Silent-failure guards** + +- `TestWatchRuleFingerprint_ChangesWithResolvedSourceScope` — two byte-identical WatchRules whose + GitTargets declare different policies fingerprint differently, and tightening a policy changes the + fingerprint of an untouched rule object. +- `TestWatchedTypeTable_RebuildsWhenOnlyThePolicyChanged` — the invalidation twin one level up: the + resident table re-projects, not merely the reconcile re-runs. +- `TestResolvedScope_UnknownRetainsPreviousAndDoesNotSweep` — assert the **absence of the sweep**, not + only the condition. +- The stream roll-up reports ready for a wildcard rule whose streams are running — the + [§5](#5-status-contract-for-per-item-scopes) `StreamSummaryForWatchRule` hazard. + +**Reactivity (envtest)** + +- Adding the matching label to a source-cluster Namespace grants it to a `"*"` item within a bounded + time; removing it revokes that namespace's stream rather than only re-rendering status. +- Editing `allowedSourceNamespaces` and flipping the delegation flag each re-reconcile the affected + WatchRules. + +**End-to-end** + +- A WatchRule in `tenant-acme` with `sourceNamespace: repo-config` writes under `repo-config/…`, not + `tenant-acme/…` — the + [Appendix A](historical-top-level-source-namespace-baseline.md#appendix-a-the-source-objects-namespace-already-names-the-git-folder) + proof, kept. +- A target whose policy admits `repo-config` never receives an object from `tenant-zen`, asserted + against a real commit. +- Narrowing a policy under PR 5's `onEvent` default leaves the prior documents in Git rather than + sweeping them. + +## Done when + +- The public `scope` choice is unreachable: rejected at admission, refused at compile, ignored at + resolution. The top-level `sourceNamespace` is gone from the API entirely. +- `rules[].sourceNamespace` resolves omitted, explicit, and wildcard items, with the wildcard backed by + both halves of `allowedSourceNamespaces`. +- A policy or source-Namespace-label change re-projects the watched-type table with the rule object + untouched. +- The release notes and [UPGRADING.md](../../UPGRADING.md) carry the breaking change per + [§8](#8-migration). +- `task lint`, `task test`, `task test-e2e` pass — and no release is cut until + [PR 5](pr5-gittarget-deletion-safety.md) has merged. diff --git a/docs/design/watchrule-source-namespace/pr4-review-followups.md b/docs/design/watchrule-source-namespace/pr4-review-followups.md new file mode 100644 index 00000000..cd4ee0fb --- /dev/null +++ b/docs/design/watchrule-source-namespace/pr4-review-followups.md @@ -0,0 +1,253 @@ +# PR 4 review follow-ups + +> Work list from two reviews of PR 4 ([#259](https://github.com/ConfigButler/gitops-reverser/pull/259)): +> an automated pass and an independent review agent. **Every claim below was re-verified against the +> code before being accepted**, and one of the two reported blockers did not survive that check — its +> premise is empirically false, though its recommendation still stands for a different reason. This +> document is the work intake, not a second design: nothing here changes the +> [scope-by-kind decision](pr4-cluster-scope-only.md). + +At the time of writing all CI is green — lint, unit, and all six e2e legs — so nothing here was caught +by a gate. That is the point of writing it down: these are the failures the suite does not have a test +for yet, and each item below names the test that would have caught it. + +## Summary + +| # | Item | Verdict | Status | +|---|---|---|---| +| B1 | Source scope is resolved against the **config plane** for a GitTarget that has not declared yet | Confirmed | **Fixed** | +| B2 | `allowedSourceNamespaces.names: ["*"]` is accepted and silently mirrors nothing | Confirmed, **severity corrected** | **Fixed** | +| F1 | `refreshSourceNamespaceScopes` is serial and unbounded — one hung source cluster stalls every tenant's reconcile | Confirmed | **Fixed** | +| F2 | A retained source-scope grant survives WatchRule deletion | Confirmed | **Fixed** | +| D1 | Migration guidance overstates deny-by-default | Confirmed | **Fixed** | +| D2 | The historical baseline says PR 4 defers selector wildcards; PR 4 ships them | Confirmed | **Fixed** | +| D3 | A facts doc still describes the old ClusterWatchRule model | Confirmed | **Fixed** | +| — | Split `SourceScopeService`; deep-copy the remaining `CompiledResourceRule` slices; move tests | Declined / deferred | Not taken | + +All seven landed on this branch. Each section below keeps the analysis that justified the fix; the +**Landed** note at its end records what was actually done and which test holds it. + +## B1 — an undeclared GitTarget resolves its selector against the wrong cluster + +**Blocker.** [`ResolveSourceNamespace`](../../../internal/watch/source_namespace_scope.go) and +`EnumerateSourceNamespaces` both key the Namespace snapshot on +[`clusterIDForGitTarget`](../../../internal/watch/cluster_context.go), which *deliberately* hides the +not-yet-declared case behind the config-plane default: + +~~~go +if id := m.gitTargetClusters[gitDest.Key()]; id != "" { + return id +} +return configPlaneClusterID +~~~ + +That default is correct for the read paths it was written for — a status read racing the first +`Declare` — and wrong for this one. Authorization is not a status read: for a **remote** GitTarget the +selector is then evaluated against **config-plane** Namespace labels, so a namespace admitted here can +be a namespace the source cluster never labelled, and vice versa. The window is real because the +WatchRule reconcile does not wait on the GitTarget's `Declare`: it resolves the GitTarget and +GitProvider and gates immediately ([`watchrule_controller.go`](../../../internal/controller/watchrule_controller.go)), +while `DeclareForGitTarget` is called by the *GitTarget* controller +([`gittarget_controller.go`](../../../internal/controller/gittarget_controller.go)). After a restart the +two run concurrently. + +Startup bootstrap is **not** the exposed path, contrary to how the finding was first reported. +`BootstrapRules` runs before any refresh, so every snapshot is missing, every selector question is +`Unknown`, and the rule is simply left uncompiled — fail-closed. The exposure is the ordinary +reconcile, once the config-plane snapshot has synced. + +**Fix.** Key the snapshot on `target.SourceCluster()` — the GitTarget the resolver already holds — +rather than on the Declare-time cache. There is no id-mapping subtlety to get wrong: the controller +passes `target.SourceCluster()` to `DeclareForGitTarget` **verbatim**, so the two agree by +construction. (Note the fallback is not even locally equivalent: an undeclared target resolves to +`configPlaneClusterID` — the empty string — while a declared local one is stored under `"default"`. +Those are two different `clusterContext`s with two different snapshots.) + +**Test.** A bootstrap/reconcile test with **divergent** config-plane and remote Namespace labels: a +namespace that the config plane admits and the remote does not must not be admitted. Without divergent +labels the test passes against the bug. + +**Landed.** Both resolver entry points key on `sourceScopeClusterID(target)` — +`target.SourceCluster()` — in [`source_namespace_scope.go`](../../../internal/watch/source_namespace_scope.go), +held by `TestResolveSourceNamespace_ReadsTheGitTargetsOwnCluster`. Two things worth recording: + +- The **existing** tests had to change, and that is the strongest evidence the defect was real. Seven + of them seeded the config-plane snapshot for a GitTarget naming ClusterProvider `workspaces`, and + passed only because the resolver read the wrong cluster. They now seed the target's own cluster. +- It also fixes a latent enqueue miss nobody had noticed. `enqueueSourceNamespaceChange` matches the + armed cluster id against `gitTargetClusters`, whose values are `SourceCluster()` — so an + undeclared target that armed `""` could never match itself, and its grants and revocations waited + for the periodic requeue instead of arriving on the edge. + +## B2 — `names: ["*"]` is accepted, and it is a silent no-op + +**Blocker, for release-timing reasons rather than severity.** [`NamespaceMatcher.Names`](../../../api/v1alpha3/namespace_matcher.go) +carries no per-item validation, and +[`expandWildcard`](../../../internal/authz/source_namespace.go) copies policy names verbatim into the +resolved scope: + +~~~go +admitted := append([]string(nil), policy.Names...) +~~~ + +So `names: ["*"]` plus a `sourceNamespace: "*"` item resolves to a scope containing the literal string +`*`. + +**The reported severity was "authorizes all namespaces". That is not what happens.** The claim rests on +Kubernetes interpreting `*` as an all-namespaces list/watch. It does not — it treats it as a literal +namespace name. Measured against the live e2e cluster, which holds 23 ConfigMaps across namespaces: + +~~~console +$ kubectl get --raw '/api/v1/namespaces/*/configmaps?limit=3' +{"kind":"ConfigMapList","apiVersion":"v1","metadata":{"resourceVersion":"22812"},"items":[]} + +$ kubectl get --raw '/api/v1/namespaces/*/configmaps?watch=true&timeoutSeconds=4' + # no events, clean exit +$ kubectl get --raw '/api/v1/configmaps?watch=true&sendInitialEvents=true&…' +{"type":"ADDED","object":{"kind":"ConfigMap",…,"namespace":"cert-manager",… # control: streams +~~~ + +The percent-encoded form behaves identically. The event-matching half agrees: `matchesSourceNamespace` +in [`store.go`](../../../internal/rulestore/store.go) is exact string equality, with no glob. + +So the real defect is the *opposite* of an escalation: the rule reports `SourceNamespaceAllowed` and +`Ready`, plans one stream against a namespace that cannot exist (namespace names are DNS labels), and +mirrors **nothing** — while the operator believes they granted everything. A silent no-op wearing a +green condition is the failure mode this design already refuses elsewhere: it is exactly why +`NoAdmittedSourceNamespaces` exists as a distinct reason. + +**Fix.** Reject `*` in `NamespaceMatcher.names` at the CRD level (DNS-label validation on the items), +and defensively at runtime so an object stored before the validation lands fails loudly rather than +silently narrowing. `*` stays valid **only** in `WatchRule.spec.rules[].sourceNamespace`. The +"admit every namespace" declaration is, and remains, `selector: {}` — which is the form that carries +the snapshot and audit guarantees. + +This is a blocker because of *when*, not *how bad*: adding an API rejection after release is itself a +breaking change, so it belongs in the same breaking release as the rest of PR 4. Both `NamespaceMatcher` +users are affected — `GitTarget.spec.allowedSourceNamespaces` and +`ClusterProvider.spec.allowedNamespaces`. + +**Landed.** `Names` carries DNS-1123 item validation +([`namespace_matcher.go`](../../../api/v1alpha3/namespace_matcher.go)), which both CRDs inherit from +the shared type, plus `ValidateNames()` for the stored-object case. The gate treats a failure as +`SourceScopeUnavailable` — a policy that cannot be evaluated, not a smaller one — so the +establishing/maintaining contract handles it correctly in both directions. Held by +`TestNamespaceMatcher_ValidateNamesRejectsPatterns` and +`TestResolveWatchRuleSourceScope_PatternInPolicyNamesCannotBeEvaluated`, including that one bad entry +condemns the whole policy: admitting the well-formed remainder is the silent narrowing this design +refuses everywhere else. `ClusterProvider.spec.allowedNamespaces` gets the schema rejection; it needs +no runtime half, because there a literal `*` already fails closed (it admits no namespace) rather +than narrowing one. + +## F1 — one hung source cluster stalls every tenant's reconcile + +[`refreshSourceNamespaceScopes`](../../../internal/watch/source_namespace_scope.go) walks wanted +clusters **sequentially**, and the Namespace `List` runs on the shared source-cluster config — which +[deliberately carries no `rest.Config.Timeout`](../../../internal/watch/source_cluster_resolver.go) so +that long-lived watches are never cut off. Only the *dial* is bounded (15s). A cluster that accepts the +connection and then hangs on the response blocks indefinitely, and `ReconcileForRuleChange` never +reaches `refreshWatchedTypeTables` / `refreshRunningTargetWatches` +([`manager.go`](../../../internal/watch/manager.go)). + +This is the same failure the catalog refresh already solved, one file over — its own comment states the +rule: + +> Serial refresh made total latency grow as remoteCount × the discovery timeout — one unreachable +> remote could burn the full timeout before the next even started, delaying every other tenant. +> — [`manager_catalog.go`](../../../internal/watch/manager_catalog.go) + +**Fix.** Follow that precedent exactly: bound the finite List with a request timeout stamped on a +**copy** of the config (as `clusterDiscovery` does in +[`cluster_context.go`](../../../internal/watch/cluster_context.go), so the shared config's watches are +never deadlined), and refresh remotes with the same bounded concurrency as +`refreshRemoteCatalogsConcurrently`. + +**Test.** A stalled fake source cluster must not prevent the tables from refreshing. +`source_namespace_scope.go` is the PR's least-covered file (**61.3%** patch coverage, 68 lines missed — +its refresh failure paths are almost entirely untested), so this fix carries the coverage debt with it. + +**Landed.** Each cluster is listed on its own goroutine under `sourceNamespaceListTimeout`, bounded by +`maxConcurrentSourceNamespaceRefreshes`. A `context.WithTimeout` is the right tool here, unlike the +discovery path that needs a `rest.Config` timeout — `List` takes a context, `ServerGroupsAndResources` +does not. Two tests: `…_BoundsEveryClustersList` asserts every list runs under a deadline no longer +than the bound, and `…_OneWedgedClusterCannotStarveTheOthers` uses a barrier that only falls through +once every cluster is inside its list at the same moment, which a serial loop can never reach. + +## F2 — a retained grant outlives its WatchRule + +The delete path in [`watchrule_controller.go`](../../../internal/controller/watchrule_controller.go) +removes the rule from the store and triggers a manager reconcile, but never calls +`ForgetSourceScopeGrant`. Grants are keyed by `NamespacedName` **and** spec hash, so a delete followed by +recreating the same name with the same spec inherits the old grant. + +That matters because the grant is precisely what distinguishes *establishing* a scope from +*maintaining* one, and the two branches are deliberately opposite. A recreated rule is establishing: +an unevaluatable policy must produce the terminal, actionable refusal (`False` / +`SourceNamespacePolicyUnavailable` / `Stalled=True`). Inheriting the grant makes it read as +maintaining instead, so it reports `Unknown` and `Reconciling` indefinitely — nothing runs, and +nothing says why. Note the precise consequence: it does **not** resurrect a stream, because the +maintaining branch compiles nothing and the deleted rule was already out of the store. The damage is +a rule stuck silently in-progress under a name a different tenant may now own. The +`ForgetSourceScopeGrant` docstring already says it is called "on a REFUSAL or a deletion"; the +deletion half was never wired. + +**Test.** Delete and recreate under the same key with the resolver unavailable — the recreated rule must +refuse rather than report progress. + +**Landed.** The delete branch of `Reconcile` now calls `ForgetSourceScopeGrant`, held by +`TestReconcile_DeletedWatchRuleForgetsItsRetainedScope`, which recreates a byte-identical rule — +the case the spec hash cannot catch, so only forgetting the grant can. Verified to fail against the +unfixed controller (it reports `Stalled=False`, i.e. the silent in-progress state) and pass with it. + +## Docs + +- **D1 — deny-by-default is overstated.** [`README.md`](README.md) and + [`pr4-cluster-scope-only.md`](pr4-cluster-scope-only.md) both tell operators that a target with no + declared policy "admits nothing". The code disagrees: `ReasonLegacySourceNamespace` in + [`source_namespace.go`](../../../internal/authz/source_namespace.go) admits a rule whose every item + watches its own namespace against a target that declares no policy. As written, UPGRADING.md would + tell every existing operator their rules break. Qualify it: the warning applies to **converted + wildcard or cross-namespace items**. +- **D2 — the historical baseline contradicts what shipped.** + [`historical-top-level-source-namespace-baseline.md`](historical-top-level-source-namespace-baseline.md) + says "PR 4 defers selector-backed wildcards". PR 4 ships them, and resolves them from the + source-namespace snapshot. Correct the row or mark it superseded. +- **D3 — a facts doc still carries the old model.** + [`kubernetes-api-discovery.md`](../../facts/kubernetes-api-discovery.md) says ClusterWatchRule "can + watch cluster-scoped and namespaced resources (by rule scope)". Cluster-scoped only, as of PR 4. + +**Landed.** All three corrected. D1 is stated as the two halves that pull in opposite directions — +own-namespace items keep working untouched, wildcard and cross-namespace items are denied — because +the unqualified denial is the more expensive error: it tells every existing operator their rules +break on upgrade, which is false. + +## Declined and deferred + +- **Split `SourceScopeService` into resolver + retained-grant cache.** Reasonable, and the observation + that callers use `RetainedSourceScope`'s slice as a boolean is fair — but it is a refactor of an + interface this PR introduces, and it does not change behavior. Better as its own change once B1/F2 + have settled where the cluster key comes from, since both touch the same seam. +- **Deep-copy the remaining `CompiledResourceRule` slices** (`Operations`, `APIGroups`, `APIVersions`, + `Resources`) in [`store.go`](../../../internal/rulestore/store.go). No consumer mutates them, and + `deepCopyCompiledClusterRule` is shallow in the same way — so this is pre-existing, uniform, and not + PR 4's to change. If it lands, it lands on its own. +- **Assert on `kubectl create namespace` in the e2e suite.** Actively wrong here: every suite under + [`test/e2e/`](../../../test/e2e/) uses `_, _ =` with an explicit *"idempotent; ignore AlreadyExists"* + comment, so asserting breaks reruns against a warm cluster. +- **Rename `TestWatchRuleSourceNamespaceDecision_…`.** Already done — it is + `TestSourceNamespaceDecision_TerminalClassification`. The comment was stale. +- **Move the type tests out of `namespace_matcher_test.go`.** Those tests cover one contract across the + three types that share the shape; splitting them by type scatters it. + +## Sequencing + +B1, B2, F2 and the docs are all in-scope for PR 4 itself — B2 because an API rejection added after +release is a second breaking change, and B1/F2 because they are authorization defects in the feature +this PR exists to add. F1 is a stability fix in the same package and rides along. + +PR 4 remains blocked from release by [PR 5](pr5-gittarget-deletion-safety.md) either way: the +do-not-release window between the two merges is unchanged by anything here. + +Validation is the standard sequence — `task lint`, `task test`, `task test-e2e` — with the e2e legs run +sequentially. The new tests raised unit coverage from 77.5% to 77.8%, so the auto-bumped +`.coverage-baseline` is committed with them. diff --git a/docs/design/watchrule-source-namespace/pr5-clusterwatchrule-source-ceiling.md b/docs/design/watchrule-source-namespace/pr5-clusterwatchrule-source-ceiling.md deleted file mode 100644 index fae1c60f..00000000 --- a/docs/design/watchrule-source-namespace/pr5-clusterwatchrule-source-ceiling.md +++ /dev/null @@ -1,250 +0,0 @@ -# PR 5 — a declared allowedSourceNamespaces bounds ClusterWatchRule too - -> Phase 5 of [source-namespace addressing](README.md). **Depends on:** -> [PR 4](pr4-source-namespace-field.md) (the field and the source-scope service), -> [PR 1](pr1-namespace-scoped-resync.md) (per-namespace expansion is unsafe until the sweep is -> namespace-scoped), and [PR 2](pr2-stream-scope-collapse.md). **Must ship with PR 4** — see the -> [release gate](README.md#implementation-phases). No new API fields; this changes what an existing -> field governs. - -## Why this is launch-set, not follow-up - -A multi-tenant deployment needs a ClusterWatchRule per tenant GitTarget **from day one**, because a -ClusterWatchRule is the only way to select cluster-scoped types and every tenant needs their CRDs -mirrored. So the rule kind that can bypass a WatchRule-only allow-list is not a rare edge — it is in -every tenant's baseline configuration. - -Without this PR, `allowedSourceNamespaces` is enforced on the rule kind that cannot bypass it and -unenforced on the one that can. The operator's answer to *"will this stream objects from namespaces -outside my allow-list?"* becomes "hand-audit every ClusterWatchRule and hope", and the audit has to -be repeated on every change by anyone with cluster-admin. With this PR the answer is a field you can -read off the GitTarget. - -The workaround — carefully writing ClusterWatchRules that contain only `scope: Cluster` rules — does -work today. It just cannot be *verified* from the GitTarget, which is where a tenant boundary should -be legible. - -## The invariant - -`ClusterWatchRule.targetRef` is a `NamespacedTargetReference` with a **required** namespace -([clusterwatchrule_types.go:22-44](../../../api/v1alpha3/clusterwatchrule_types.go#L22-L44)), so it -reaches across namespaces by design, and `collectClusterWatchRuleSelections` hardcodes -`namespace: ""` for every rule -([watched_type_resolver.go:317-318](../../../internal/watch/watched_type_resolver.go#L317-L318)). -Left alone, a ClusterWatchRule delivers every source namespace into its target's Git destination -regardless of what that target declared. - -So the allow-list is stated over the **destination**, and holds for both rule kinds: - -> When a GitTarget declares `allowedSourceNamespaces`, the source namespaces mirrored into it are -> **exactly** those the policy admits, for every rule of every kind. When it declares none, each rule -> kind keeps its legacy scope. - -There is **no implicit own-namespace exception** — a declared policy is exhaustive, including for a -WatchRule watching the namespace it lives in. The reasoning, and the authoring footgun it accepts, -are in [no self-namespace exception](README.md#no-self-namespace-exception); do not reintroduce the -carve-out here. Because a ClusterWatchRule has no own namespace, the kinds therefore differ only in -the legacy behavior when the field is **undeclared**: - -| `GitTarget.allowedSourceNamespaces` | WatchRule | ClusterWatchRule (`scope: Namespaced` rules) | -|---|---|---| -| Undeclared | Own namespace only (legacy) | All source namespaces (legacy) | -| Declared | Exactly what the policy admits | Exactly what the policy admits | - -One meaning for the field across both kinds — declared means ceiling, absent means no new grant — so -nobody has to remember which rule kind inverts it, and no existing ClusterWatchRule changes behavior -until a target owner declares a policy. - -### This is a restriction, so no delegation flag - -`allowWatchRuleSourceNamespaceOverride` gates *granting* a WatchRule a foreign namespace. The ceiling -only ever narrows, so it applies whether the flag is true or false. Gating a restriction behind a -delegation flag would mean an admin must grant extra authority in order to reduce scope. - -### Three precisions the implementation must not get wrong - -- **Cluster-scoped rules are exempt.** A namespace allow-list cannot constrain Nodes, CRDs, or - ClusterRoles. The ceiling applies per `ClusterResourceRule`, only where `scope: Namespaced`. A - ClusterWatchRule mixing both scopes keeps its cluster-scoped streams intact — and since the CRD use - case above *is* the cluster-scoped half, an over-broad implementation that refuses or narrows the - whole rule breaks the exact configuration this PR exists to serve. -- **Narrowing is not a refusal.** A declared ceiling admitting nothing leaves a namespaced-scope - ClusterWatchRule selecting no namespaces. That is a correct outcome, not a terminal failure: it - must not set `Stalled=True`. Report it through the existing `ResourcesResolved` surface and the - reason below, so a dead rule is still visible. -- **The ceiling does not partition cluster-scoped objects at all.** Stated plainly in the - [overview](README.md#what-the-ceiling-does-not-do): every tenant selecting CRDs gets every CRD the - credential can read. If tenants must not see each other's cluster-scoped objects, the answer is one - ClusterProvider and credential per tenant, not this field. - -### Status - -The narrowing is a scope change, not an authorization refusal, so it reuses the domain condition -[PR 4](pr4-source-namespace-field.md#status-contract-kstatus-compatible) defines rather than adding a -second one. On ClusterWatchRule, `SourceNamespaceAuthorized=True` with -reason `AllSourceNamespaces` when no ceiling applies, and `SourceNamespacesNarrowed` when one does. -The `SourceAuthorized` printer column is added to ClusterWatchRule as well, so the two rule kinds -read the same way in `kubectl get -o wide`. - -## Implementation - -### 1. Apply the ceiling in selection - -`collectClusterWatchRuleSelections` -([watched_type_resolver.go:306-323](../../../internal/watch/watched_type_resolver.go#L306-L323)) -hardcodes `namespace: ""`. When the referenced GitTarget declares `allowedSourceNamespaces`, expand -each `scope: Namespaced` rule into **one selection per admitted namespace** instead of a single `""` -selection. Leave `scope: Cluster` rules emitting `""`. - -**Prefer expansion over filtering events at the read site.** An expanded selection carries the scope -through the plan hash, the informers, **and** the resync path (`snapshotGVRsFromTable`, -[scope_resolve.go:180](../../../internal/watch/scope_resolve.go#L180)) for free. A read-site filter -must be repeated at each of those and is silently wrong if one is missed — and it would also mean an -unfiltered LIST/WATCH over all namespaces, so the data crosses into the process before being dropped. - -Two consequences worth knowing: - -- A name-based policy expands statically; a **selector**-based one makes the namespace set depend on - the source-cluster Namespace informer [PR 4](pr4-source-namespace-field.md) introduces, and can - produce many streams on a large cluster. That is the cost of the safe direction. -- A declared policy emits no `""` key **for its namespaced selections**, so the - [PR 2](pr2-stream-scope-collapse.md) collapse cannot trigger between two namespaced streams on that - target. It does **not** follow that the target emits no `""` key at all: a co-resident - `scope: Cluster` rule still emits one, so a GVR selected both cluster-scoped and namespaced can - still collapse. PR 2 governs that case, and the undeclared case, which remains the common one. - -### 2. Put the resolved scope into table invalidation - -`rulesFingerprint` is computed **only from compiled rules** — it iterates -`SnapshotWatchRules()` and `SnapshotClusterWatchRules()` and hashes their spec fields -([watched_type_resolver.go:463-500](../../../internal/watch/watched_type_resolver.go#L463-L500)) — -and it is what gates the table rebuild -([watched_type_resolver.go:88-96](../../../internal/watch/watched_type_resolver.go#L88-L96)). -`clusterWatchRuleFingerprint` has no `src=` component at all, on the assumption that a -ClusterWatchRule is always all-source-namespaces. Step 1 falsifies that assumption. - -The consequence is bigger than a missing hash component, and it is the failure mode to design -against: the new ceiling's inputs — GitTarget policy and source-cluster Namespace labels — are **not -rule state**, so nothing about them reaches the fingerprint. A mapper that requeues the -ClusterWatchRule is therefore not sufficient on its own: reconciliation runs, the fingerprint is -unchanged, the rebuild is skipped, and the resident table keeps the old namespace set. The streams -carry on at their previous width and every diff looks correct, because the rule object genuinely did -not change. - -**So carry a resolved-scope version into invalidation.** Either hash the resolved namespace set into -each rule's fingerprint, or add a separate source-scope generation to the rebuild trigger alongside -the rules fingerprint and the catalog generation. Hashing the resolved set is the smaller change and -composes with the existing gate; whichever is chosen, the test in the plan below asserts that an -unchanged rule object with a changed policy re-projects the table. - -This is why [PR 4](pr4-source-namespace-field.md)'s source-scope service must expose resolution to -the resolver, not only to the reconciler: the fingerprint is computed in `internal/watch` and needs -the same answer the gate got. - -### 2b. Unknown is not empty - -An unresolvable selector must **never** be treated as a valid empty allow-list. The distinction is -load-bearing because narrowing has a data-plane consequence: an empty resolved set means "watch -nothing in any namespace", and combined with a resync it means Git content for those namespaces is -no longer in `desired`. A transient source-cluster outage read as "the policy admits nothing" is -therefore not merely a stopped stream — it is the input to a sweep. This is the sharpest reason -[PR 1](pr1-namespace-scoped-resync.md) lands first and why its recommended retain-on-revocation -semantics matter. - -Required behavior when the resolved set is unknown — cache not synced, source cluster unreachable, -Namespace access denied for a selector policy: - -- **Retain the current resolved scope.** Do not narrow, do not widen, and do not sweep. The last - known-good scope keeps running. -- **Never synthesize an empty set.** "I could not evaluate" and "it admits nothing" must be different - values all the way through the resolver, which is what the three-valued result in PR 4's - source-scope service exists to provide. -- **Report it as non-terminal:** `SourceNamespaceAuthorized=Unknown` with reason - `CheckingSourceNamespacePolicy` while a retryable error is being retried, and - `SourceNamespacePolicyUnavailable` (still `Unknown`, still retained, **not** `Stalled`) when source - Namespace access is denied outright for a selector policy. Exact-name entries in the same policy - remain resolvable and keep working. - -A permanently unavailable selector is a legitimate long-lived `Unknown` **here**. Turning it into -`Stalled` would be a false claim that the operator knows the rule is wrong — the rule is still -running its cluster-scoped streams and its last-resolved namespaced ones — and turning it into an -empty set would be destructive. - -> **This is not in conflict with [PR 4](pr4-source-namespace-field.md), which makes the same -> permanent `Forbidden` terminal.** The ceiling is *maintaining* a scope, where failing closed would -> mean narrowing to nothing and sweeping; PR 4's gate is *establishing* one, where failing closed -> means never starting a stream and nothing is at risk. The single contract, with the table that -> settles which value applies where, is -> [establishing versus maintaining a scope](pr4-source-namespace-field.md#establishing-versus-maintaining-a-scope). -> A ClusterWatchRule that has *never* resolved its ceiling is in the establishing column too. - -### 3. Reactivity - -| Input changes | Required reaction | Status | -|---|---|---| -| GitTarget `allowedSourceNamespaces` | Re-resolve the ceiling and replan the ClusterWatchRule's streams. | Not wired — the ClusterWatchRule controller performs no GitTarget-driven re-resolution. Needs a GitTarget → ClusterWatchRules mapper. | -| Source-cluster Namespace labels | Re-resolve selector-based ceilings. | Extends the per-source-cluster informer [PR 4](pr4-source-namespace-field.md#reactivity-and-source-cluster-rbac) adds, to map to ClusterWatchRules as well. | -| ClusterProvider `allowedNamespaces` | Already handled by [PR 3](pr3-clusterwatchrule-target-admission.md)'s mapper. | — | - -## Test plan - -These prove the invariant. Without them the allow-list is enforced only where it cannot be bypassed. - -- **`TestCollectClusterWatchRuleSelections_DeclaredCeilingNarrowsClusterWideStream`** — a - ClusterWatchRule with a `scope: Namespaced` rule against a GitTarget declaring - `allowedSourceNamespaces: {names: [repo-config]}` emits selections for `repo-config` **only**, and - emits **no** `""` selection. Assert the absence of the `""` key directly: `""` present alongside - `repo-config` reads as "we did narrowing" in a diff while behaving as all-namespaces at runtime, - because `SnapshotNamespaces()` short-circuits on it - ([watched_type_table.go:78-92](../../../internal/watch/watched_type_table.go#L78-L92)). -- **`TestCollectClusterWatchRuleSelections_UndeclaredCeilingStaysClusterWide`** — the upgrade-safety - twin. No policy on the GitTarget means today's single `""` selection, unchanged. -- **`TestCollectClusterWatchRuleSelections_CeilingSparesClusterScopedRules`** — a ClusterWatchRule - mixing `scope: Cluster` (CRDs) and `scope: Namespaced` rules under a declared ceiling keeps the - cluster-scoped stream at `""` while the namespaced one narrows. This is the day-one multi-tenant - shape; it guards the over-correction of narrowing or refusing the whole rule. -- **`TestCollectClusterWatchRuleSelections_CeilingAdmittingNothingIsNotStalled`** — a declared policy - matching no namespace yields no namespaced selections, `SourceNamespaceAuthorized=True` with reason - `SourceNamespacesNarrowed`, and **not** the Failed trio. -- **`TestClusterWatchRuleFingerprint_ChangesWithResolvedSourceScope`** — two otherwise-identical - ClusterWatchRules whose GitTargets declare different `allowedSourceNamespaces` fingerprint - differently, and tightening a policy changes the fingerprint of an unchanged rule object. The rule - spec is byte-identical across both cases, so nothing else in the suite can catch a missing `src=` - component. -- **`TestWatchedTypeTable_RebuildsWhenOnlyThePolicyChanged`** — the invalidation twin of the above, - one level up: with the rule object untouched, editing `GitTarget.allowedSourceNamespaces` must - actually re-project the resident table, not merely re-run reconciliation. This is the test that - catches "the mapper fired but the fingerprint was unchanged, so the rebuild was skipped". -- **`TestCeiling_UnknownScopeRetainsPreviousAndDoesNotSweep`** — with the source-scope service - reporting *cannot say* (cache unsynced or source unreachable), the resolved namespace set is - retained, no narrowing occurs, and **no resync/sweep is enqueued**. Assert the absence of the - sweep, not only the condition — this is the path where a wrong answer deletes Git content. -- **`TestCeiling_ForbiddenSelectorIsUnknownNotStalled`** — a ClusterWatchRule that **has already - resolved** a ceiling, whose source Namespace access is then denied, reports - `SourceNamespaceAuthorized=Unknown` with `SourceNamespacePolicyUnavailable`, keeps running at its - last known scope, and does **not** set `Stalled=True`. Exact-name entries in the same policy keep - resolving. -- **`TestCeiling_ForbiddenSelectorBeforeFirstResolutionIsStalled`** — the establishing twin, and the - test that keeps the two halves of the contract from drifting: a ClusterWatchRule whose selector - ceiling has **never** resolved, under the same denial, is `False` / - `SourceNamespacePolicyUnavailable` / `Stalled=True` with no namespaced streams. Without this pair - an implementation can satisfy either page alone while contradicting the other. -- **Revocation, envtest** — a running ClusterWatchRule under `allowedSourceNamespaces: - [repo-config, team-a]`, narrowed to `[repo-config]`, stops the `team-a` stream within a bounded - time. Not merely re-renders status. -- **Selector reactivity, envtest** — removing the matching label from a source-cluster Namespace - revokes that namespace's stream for a ClusterWatchRule under a selector ceiling. -- **End-to-end, the one that proves the boundary** — the day-one multi-tenant shape: a - ClusterWatchRule selecting CRDs (`scope: Cluster`) **and** ConfigMaps (`scope: Namespaced`), - targeting acme's GitTarget which declares `allowedSourceNamespaces: [repo-config]`. Create a - ConfigMap in `repo-config` and one in `tenant-zen`, and a CRD. Assert all three: `repo-config/…` - appears, the CRD appears, and `tenant-zen/…` is **never** written. Assert the negative against a - real commit — every unit-level assertion above is on the plan, not on what reached Git. - -## Done when - -- A declared ceiling narrows namespaced ClusterWatchRule streams and leaves cluster-scoped ones - intact. -- Tightening a ceiling stops the removed namespaces' streams without touching the rule object. -- The e2e above passes, including its negative assertion. -- `task lint`, `task test`, `task test-e2e` pass. diff --git a/docs/design/watchrule-source-namespace/pr5-gittarget-deletion-safety.md b/docs/design/watchrule-source-namespace/pr5-gittarget-deletion-safety.md new file mode 100644 index 00000000..c17fefdf --- /dev/null +++ b/docs/design/watchrule-source-namespace/pr5-gittarget-deletion-safety.md @@ -0,0 +1,116 @@ +# PR 5 — GitTarget deletion safety + +> Phase 5 of [source-namespace addressing](README.md). It deliberately contains no source-namespace +> API change. It is implemented in the PR immediately **after** +> [PR 4](pr4-cluster-scope-only.md), and **no release may be cut between the two merges** — the first +> release containing PR 4's breaking API changes also contains this one. +> +> **Status: proposal, not started.** Depends on [PR 1](pr1-namespace-scoped-resync.md) and +> [PR 2](pr2-stream-scope-collapse.md), which identify the resync sweep boundary this PR controls. + +## Purpose + +GitOps Reverser has two distinct deletion paths: + +- An explicit source DELETE event deletes one resolved managed document. +- A resync compares a desired snapshot with Git and drops documents that are absent from that + snapshot (mark-and-sweep). + +The first is source-cluster evidence. The second is an inference, and is unsafe when the snapshot +is narrowed by a bad scope, a temporary outage, or a controller that does not understand a newer +scope field. This PR lets each GitTarget decide which paths may remove Git documents. + +## API + +Add an extensible policy object rather than a bare enum, so a later volume guard can be added +without changing a scalar field into an object: + +~~~yaml +apiVersion: configbutler.ai/v1alpha3 +kind: GitTarget +metadata: + name: acme + namespace: tenant-acme +spec: + providerRef: { name: acme-git } + branch: main + path: tenants/acme + prune: + mode: onEvent +~~~ + +`GitTarget.spec.prune.mode` is an enum with an effective default of `onEvent`: + +| Mode | Explicit source DELETE event | Resync mark-and-sweep | Intended use | +|---|---:|---:|---| +| `never` | suppressed | suppressed | archive/tombstone mirror | +| `onEvent` | applied | suppressed | safe default; mirror observed deletes but never infer them | +| `always` | applied | applied | full convergence, including cleanup of stale Git documents | + +`onEvent` means a DELETE event, not every watch event. `always` enables both deletion paths. + +The CRD default is useful for newly written objects, but it is not the compatibility mechanism. +The controller must use `EffectivePruneMode()` and treat an omitted stored value as `onEvent`; old +GitTargets must become safe without first being edited. + +## Why `onEvent` is the default + +A scope collapse must stop updates outside the resulting scope, but it must not erase their existing +Git documents. With `onEvent`, a resync emits no managed-drop actions, so the documents remain until +an explicit source DELETE is observed or the target owner deliberately selects `always`. + +This is intentionally a behavior change from today's implicit sweep behavior. A target that needs +full desired-state convergence opts in with `prune.mode: always`. + +This PR does **not** attempt to limit a large cascade of genuine DELETE events. A future PR may add +for example `maxDeletesPerCommit` to this object if experience shows that control is needed. An +absolute count is not a complete whole-folder safeguard for a small target, so it is better omitted +than presented as one in this first safety release. + +## Implementation + +1. **API and effective default.** Add `PrunePolicy` and `PruneMode` (`never`, `onEvent`, `always`) to + `GitTargetSpec`, with schema enum/default and an `EffectivePruneMode()` helper. Regenerate + deepcopy code and CRDs. +2. **Suppress sweep actions at the planner.** Thread the effective mode into the resync planning + policy. `BuildScopedPlan` must not emit `PlanDropOrphan` when the mode is `never` or `onEvent`. + Do not filter the action at apply time: a suppressed drop must not enter the plan, plan hash, or + commit path. +3. **Gate explicit deletes.** The steady-state delete writer applies delete-document actions only + for `onEvent` and `always`; `never` leaves the managed document unchanged. +4. **Surface configured retention.** A sweep suppressed by policy is healthy, not a failed + reconciliation. Emit a rate-limited informational log and make the mode visible in API + documentation; do not add a failure condition merely because a stale document remains. + +The zero value of the internal planner policy must be explicit in every caller. Production resync +code passes the target's effective mode; dry-run and unit-test callers choose deliberately whether +they want to model `always` or `onEvent`. + +## Tests + +- A legacy GitTarget that omits `prune` has effective mode `onEvent`. +- `onEvent` retains a document when a resync desired set narrows to empty; the generated plan has no + managed-drop action. +- `onEvent` still mirrors one explicit DELETE event. +- `never` suppresses both paths. +- `always` reproduces today's mark-and-sweep behavior byte-for-byte. +- Full and namespace-scoped resyncs both honor the mode; no alternate sweep path bypasses it. + +## Release and rollback + +This PR merges immediately after [PR 4](pr4-cluster-scope-only.md) and the two are released together; +`main` is in a do-not-release window between the merges. There is therefore **no released version +containing PR 4 but not this PR**, and no PR-5 rollback floor to fall back to. + +Do not claim safety for a rollback past that release once PR-4 manifests have been applied: an older +controller neither understands `prune` nor the rule-item source-namespace field, so it resolves a +narrower desired set *and* sweeps it. Remove or narrow the affected WatchRules first. + +Inside the shipping release, `onEvent` is still what makes a scope mistake non-destructive — it is +just no longer a separately released prerequisite. + +## Done when + +- `prune.mode` defaults effectively to `onEvent` for both new and existing GitTargets. +- Explicit deletes and inferred sweep drops are independently controlled exactly as in the table. +- `task lint`, `task test`, and `task test-e2e` pass, closing the do-not-release window PR 4 opened. diff --git a/docs/facts/kubernetes-api-discovery.md b/docs/facts/kubernetes-api-discovery.md index 22786566..a5ca4237 100644 --- a/docs/facts/kubernetes-api-discovery.md +++ b/docs/facts/kubernetes-api-discovery.md @@ -162,7 +162,8 @@ silently partial one.** - RBAC still applies: the operator must be allowed to `get/list/watch` the target resources. - Scope still applies: - `WatchRule` is namespace-scoped - - `ClusterWatchRule` can watch cluster-scoped and namespaced resources (by rule scope) + - `ClusterWatchRule` watches cluster-scoped resources only; a namespaced resource is + a `WatchRule`'s to select, addressed through `rules[].sourceNamespace` ## Quick Verification Commands diff --git a/docs/rbac.md b/docs/rbac.md index e3902f3d..fab8e0b8 100644 --- a/docs/rbac.md +++ b/docs/rbac.md @@ -63,6 +63,20 @@ Do **not** restate `namespaces`, `customresourcedefinitions` or `apiservices`: t role already carries them. A `WatchRule` naming a type you did not grant will fail to watch it, so the list must cover every type your rules name. +### `namespaces` on a REMOTE source cluster + +The manager role's `namespaces` `get`/`list`/`watch` covers the operator's **own** cluster. A remote +`ClusterProvider` whose `GitTarget`s declare a **selector-based** `allowedSourceNamespaces` — or whose +`WatchRule`s use `sourceNamespace: "*"` against one — needs the same for the identity in that +provider's kubeconfig, because the selector matches labels on `Namespace`s in the *source* cluster. + +Exact `names` entries stay usable without it: a name-based policy, including a `"*"` item resolved +against a names-only policy, is answered from the API objects and never reads a `Namespace`. That is +a deliberate degradation path, not an oversight. When the source credential is forbidden from listing +Namespaces, a selector policy reports `SourceNamespaceAuthorized=False` with reason +`SourceNamespacePolicyUnavailable` (or `Unknown` while retaining an already-resolved scope) rather +than silently narrowing to nothing. + ## The operator does not read Secrets wholesale It holds **no Secret informer**: Secrets are excluded from the manager's cache diff --git a/docs/security-model.md b/docs/security-model.md index 9bc03386..d166989d 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -42,6 +42,28 @@ To mirror live state into Git the controller must: The controller never writes to a watched type. Its only write target is Git, plus the Secrets it generates itself (the signing key, and the age key under `generateWhenMissing`). +### Which namespaces a rule may read from + +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 both `ClusterProvider.spec.allowSourceNamespaceOverride` (a platform-admin delegation, + false by default) and an explicit `GitTarget.spec.allowedSourceNamespaces` entry admitting it. A + `"*"` expands to exactly that policy's set, never to every namespace that exists. + + On an **in-cluster** provider that delegation deliberately bypasses live namespace RBAC: the owner + of an admitted `GitTarget` can then mirror another namespace's objects — read through the + operator's own cluster-wide credential — into a Git destination they control. That is legitimate to + grant on purpose, and it is why the flag exists and defaults to false. +- A **`ClusterWatchRule`** selects cluster-scoped resources only. Cluster-scoped objects have no + namespace, so `allowedSourceNamespaces` is neither consulted nor a bound for it: it is + intentionally cluster-global. Isolating cluster-scoped objects between tenants therefore takes a + separate `ClusterProvider` and credential per tenant, so that credential's RBAC is the boundary. + +The audit question is one per kind: read a `WatchRule`'s items and its target's policy, or recognise +a `ClusterWatchRule` as cluster-global. + ## The controller does not hold Secret values There is **no Secret informer**. The manager is built with `Client.Cache.DisableFor: diff --git a/docs/spec/status-conditions-guide.md b/docs/spec/status-conditions-guide.md index 6586e787..48dc4d0e 100644 --- a/docs/spec/status-conditions-guide.md +++ b/docs/spec/status-conditions-guide.md @@ -58,6 +58,9 @@ const ( TypeStreamsRunning = "StreamsRunning" TypeGitPathAccepted = "GitPathAccepted" TypeGitTargetReady = "GitTargetReady" + + // WatchRule only: whether every rule item's RESOLVED source-namespace scope is authorized. + TypeSourceNamespaceAuthorized = "SourceNamespaceAuthorized" ) ``` @@ -69,6 +72,50 @@ Canonical reads: `Stalled=True` * Git path refusal details live on `GitPathAccepted=False` and `Stalled=True`, reason `UnsupportedContent` * 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. + + Its three values are not interchangeable. `False` is a **refusal** — terminal, `Stalled=True`, + stream stopped — with reason `SourceNamespaceNotAllowed`, or `SourceNamespacePolicyUnavailable` + when a selector policy is permanently unevaluatable *and* no scope was ever resolved for the rule. + `Unknown` is "cannot say yet": either the answer is still being established + (`CheckingSourceNamespacePolicy`), or a rule that already holds a resolved scope has lost the + ability to re-evaluate its policy and is **retaining** that scope + (`SourceNamespacePolicyUnavailable`, `Stalled=False`, still mirroring). + + That asymmetry is deliberate. While *establishing* a grant, failing closed means "do not start the + stream", which is accurate and actionable. While *maintaining* one, failing closed would mean + "narrow to nothing" — and a narrowed scope is the input to a resync sweep, so it would delete a + tenant's Git content over a transient outage. An unevaluatable policy therefore never produces a + resolved namespace set: not the empty one, and not the full one. + + **It is one condition per object, aggregated over every `spec.rules[]` item.** The precedence is + stated rather than derived, because two implementations of "worst wins" would otherwise disagree + about a mixed rule. First match wins: + + 1. any item **denied** → `False` / `SourceNamespaceNotAllowed` / `Stalled=True` + 2. any item **permanently unevaluatable** while establishing → `False` / + `SourceNamespacePolicyUnavailable` / `Stalled=True` + 3. any item retaining a scope it can no longer re-evaluate → `Unknown` / + `SourceNamespacePolicyUnavailable` / `Stalled=False` + 4. any item **still resolving** → `Unknown` / `CheckingSourceNamespacePolicy` + 5. every item admitted, at least one naming a namespace other than the rule's own → `True` / + `SourceNamespaceAllowed` + 6. every item omitted → `True` / `LegacySourceNamespace` + + 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. + Messages therefore name the deciding item by index *and* by its resources and requested namespace — + an index alone goes stale the moment somebody reorders the list while reading the message. + + One more `True` reason exists so a no-op cannot look healthy: `NoAdmittedSourceNamespaces`, when + every item is authorized but the resolved scope is **empty** (a `sourceNamespace: "*"` against a + policy that currently admits nothing). The rule is not stalled — nothing is wrong with it — but it + mirrors nothing, and `Ready=True` with no explanation would hide that. The existing + `StreamsRunning` and `ResourcesResolved` surfaces show the zero. ### CommitRequest (one-shot) diff --git a/internal/authz/clusterprovider_admission_test.go b/internal/authz/clusterprovider_admission_test.go index ccf9d7fe..2b81a635 100644 --- a/internal/authz/clusterprovider_admission_test.go +++ b/internal/authz/clusterprovider_admission_test.go @@ -41,7 +41,7 @@ func targetIn(providerName string) *configv1alpha3.GitTarget { return t } -func providerNamed(name string, policy *configv1alpha3.AllowedNamespaces) *configv1alpha3.ClusterProvider { +func providerNamed(name string, policy *configv1alpha3.NamespaceMatcher) *configv1alpha3.ClusterProvider { return &configv1alpha3.ClusterProvider{ ObjectMeta: metav1.ObjectMeta{Name: name}, Spec: configv1alpha3.ClusterProviderSpec{AllowedNamespaces: policy}, @@ -83,7 +83,7 @@ func TestGitTargetAdmitted_Policy(t *testing.T) { name: "omitted clusterProviderRef is admitted by an admitting default provider", objects: []client.Object{ providerNamed(configv1alpha3.DefaultClusterProviderName, - &configv1alpha3.AllowedNamespaces{Names: []string{"team-a"}}), + &configv1alpha3.NamespaceMatcher{Names: []string{"team-a"}}), namespaceLabeled(nil), }, target: targetIn(""), @@ -103,7 +103,7 @@ func TestGitTargetAdmitted_Policy(t *testing.T) { { name: "empty policy struct denies by default", objects: []client.Object{ - providerNamed("prod-eu-1", &configv1alpha3.AllowedNamespaces{}), + providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{}), namespaceLabeled(nil), }, target: targetIn("prod-eu-1"), @@ -114,7 +114,7 @@ func TestGitTargetAdmitted_Policy(t *testing.T) { name: "names entry admits", objects: []client.Object{ providerNamed("prod-eu-1", - &configv1alpha3.AllowedNamespaces{Names: []string{"team-b", "team-a"}}), + &configv1alpha3.NamespaceMatcher{Names: []string{"team-b", "team-a"}}), namespaceLabeled(nil), }, target: targetIn("prod-eu-1"), @@ -123,7 +123,7 @@ func TestGitTargetAdmitted_Policy(t *testing.T) { { name: "names list not containing the namespace denies", objects: []client.Object{ - providerNamed("prod-eu-1", &configv1alpha3.AllowedNamespaces{Names: []string{"team-b"}}), + providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{Names: []string{"team-b"}}), namespaceLabeled(nil), }, target: targetIn("prod-eu-1"), @@ -136,7 +136,7 @@ func TestGitTargetAdmitted_Policy(t *testing.T) { name: "empty selector admits every namespace", objects: []client.Object{ providerNamed("prod-eu-1", - &configv1alpha3.AllowedNamespaces{Selector: &metav1.LabelSelector{}}), + &configv1alpha3.NamespaceMatcher{Selector: &metav1.LabelSelector{}}), namespaceLabeled(nil), }, target: targetIn("prod-eu-1"), @@ -145,7 +145,7 @@ func TestGitTargetAdmitted_Policy(t *testing.T) { { name: "selector matching namespace labels admits", objects: []client.Object{ - providerNamed("prod-eu-1", &configv1alpha3.AllowedNamespaces{ + providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "prod"}}, }), namespaceLabeled(map[string]string{"tier": "prod"}), @@ -156,7 +156,7 @@ func TestGitTargetAdmitted_Policy(t *testing.T) { { name: "selector not matching namespace labels denies", objects: []client.Object{ - providerNamed("prod-eu-1", &configv1alpha3.AllowedNamespaces{ + providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "prod"}}, }), namespaceLabeled(map[string]string{"tier": "dev"}), @@ -170,7 +170,7 @@ func TestGitTargetAdmitted_Policy(t *testing.T) { // 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.AllowedNamespaces{ + providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{ Names: []string{"team-a"}, Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "prod"}}, }), @@ -182,7 +182,7 @@ func TestGitTargetAdmitted_Policy(t *testing.T) { { name: "invalid selector denies with a legible message rather than admitting", objects: []client.Object{ - providerNamed("prod-eu-1", &configv1alpha3.AllowedNamespaces{ + providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{ Selector: &metav1.LabelSelector{MatchExpressions: []metav1.LabelSelectorRequirement{{ Key: "tier", Operator: "NotARealOperator", }}}, @@ -199,7 +199,7 @@ func TestGitTargetAdmitted_Policy(t *testing.T) { // 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.AllowedNamespaces{Names: []string{"team-a"}}), + providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{Names: []string{"team-a"}}), }, target: targetIn("prod-eu-1"), wantAllowed: true, @@ -208,7 +208,7 @@ func TestGitTargetAdmitted_Policy(t *testing.T) { // ...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.AllowedNamespaces{ + providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "prod"}}, }), }, @@ -273,7 +273,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.AllowedNamespaces{Names: []string{"team-a"}}), + providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{Names: []string{"team-a"}}), namespaceLabeled(nil), ). WithInterceptorFuncs(interceptor.Funcs{ diff --git a/internal/authz/source_namespace.go b/internal/authz/source_namespace.go new file mode 100644 index 00000000..388235a4 --- /dev/null +++ b/internal/authz/source_namespace.go @@ -0,0 +1,674 @@ +// SPDX-License-Identifier: Apache-2.0 + +package authz + +import ( + "context" + "fmt" + "sort" + "strings" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" +) + +// Reasons for the WatchRule SourceNamespaceAuthorized condition. They are the rule-side names, so +// a reader never has to know which of the three gate inputs produced the verdict — the Message +// carries that. +const ( + // ReasonLegacySourceNamespace is the True reason when EVERY item watches the rule's OWN + // namespace and the GitTarget declares no allowedSourceNamespaces policy. No authorization was + // needed. + ReasonLegacySourceNamespace = "LegacySourceNamespace" + + // ReasonSourceNamespaceAllowed is the True reason when every item passed the policy and at + // least one names a namespace other than the rule's own — including an own-namespace item that + // a DECLARED policy explicitly admits. + ReasonSourceNamespaceAllowed = "SourceNamespaceAllowed" + + // ReasonNoAdmittedSourceNamespaces is the True reason when every item was admitted but the + // resolved scope is EMPTY — a "*" item against a policy that currently admits nothing. The rule + // is not stalled (nothing is wrong with it) but it mirrors nothing, and a rule that mirrors + // nothing while reporting Ready=True with no explanation is a silent no-op. + ReasonNoAdmittedSourceNamespaces = "NoAdmittedSourceNamespaces" + + // ReasonSourceNamespaceNotAllowed is the TERMINAL False reason for a refusal: the delegation + // flag is off, the GitTarget declares no policy for an override, or a declared policy + // evaluated and does not admit the namespace. The policy was READ; this is a decision, not an + // inability to decide, and it must never share a code path with the unevaluatable case. + ReasonSourceNamespaceNotAllowed = "SourceNamespaceNotAllowed" + + // ReasonSourceNamespacePolicyUnavailable is the reason when a SELECTOR policy cannot be + // evaluated at all — an invalid selector, or Namespace reads permanently Forbidden on the + // source cluster. Its STATUS depends on whether a scope was ever established for the rule: + // False/Stalled=True while establishing (nothing runs, and only an operator change will fix + // it), Unknown/Stalled=False while maintaining an already-resolved scope (which is retained). + ReasonSourceNamespacePolicyUnavailable = "SourceNamespacePolicyUnavailable" + + // ReasonCheckingSourceNamespacePolicy is the Unknown reason while the answer is still being + // established: the source-cluster Namespace cache has not synced, or a retryable read error is + // being retried. It is NOT a denial — encoding "cannot say yet" as "denied" is exactly how a + // transient outage becomes a terminal Stalled=True and a stopped stream. + ReasonCheckingSourceNamespacePolicy = "CheckingSourceNamespacePolicy" +) + +// SourceScopeVerdict is the THREE-valued answer a source-namespace policy evaluation produces. +// Three-valued is the whole point: a two-valued interface forces "cannot say" to be encoded as +// "denied", which turns a transient source-cluster outage into a terminal failure and a stopped +// stream — and makes the Unknown row of the status contract unimplementable. +type SourceScopeVerdict int + +const ( + // SourceScopeUnknown means the policy could not be evaluated YET and the cause is retryable + // (cache still syncing, source cluster momentarily unreachable). Retry; do not deny. + SourceScopeUnknown SourceScopeVerdict = iota + // SourceScopeAdmitted means the policy was evaluated and admits the namespace. + SourceScopeAdmitted + // SourceScopeDenied means the policy was evaluated and does NOT admit the namespace. + SourceScopeDenied + // SourceScopeUnavailable means the policy can never be evaluated as written without an + // operator change — an invalid selector, or Namespace reads Forbidden for a selector policy. + SourceScopeUnavailable +) + +// SourceScopeResult is a policy evaluation's outcome plus an operator-legible explanation. +type SourceScopeResult struct { + Verdict SourceScopeVerdict + Message string +} + +// SourceNamespaceResolver evaluates a GitTarget's allowedSourceNamespaces against namespaces in +// that target's SOURCE cluster. It is an interface here — and implemented by the watch manager — +// because the labels a selector needs live in the source cluster, whose connection and cache the +// watch manager already owns. A reconciler that dialled the source cluster itself on every pass +// would duplicate both. +// +// Implementations MUST answer an exact-NAME policy without consulting the label cache, so a source +// cluster whose Namespace access is denied still supports name-based policies. That degradation +// path is deliberate, and it is the half most likely to regress unnoticed. +type SourceNamespaceResolver interface { + // ResolveSourceNamespace answers whether ONE candidate namespace is admitted. + ResolveSourceNamespace( + ctx context.Context, + target *configv1alpha3.GitTarget, + namespace string, + ) SourceScopeResult + + // EnumerateSourceNamespaces expands a target's SELECTOR half into the concrete set of source + // namespaces it currently admits. It answers the "*" case, which has no single candidate to + // test. + // + // The returned slice is meaningful only when the result is SourceScopeAdmitted; an empty slice + // with that verdict is a real answer ("the selector currently admits nothing"), which is + // exactly why the verdict must not be inferred from the length. Unknown and Unavailable mean + // the set could not be computed and MUST NOT be read as the empty set — an empty resolved scope + // is the input to a resync sweep. + EnumerateSourceNamespaces( + ctx context.Context, + target *configv1alpha3.GitTarget, + ) ([]string, SourceScopeResult) +} + +// SourceNamespaceDecision is one rule ITEM's source-namespace verdict, plus the concrete namespace +// set it resolved to. +type SourceNamespaceDecision struct { + // Index is the item's position in spec.rules. + Index int + // Requested is what the item asked for, verbatim: "" (omitted), a name, or "*". + Requested string + // Namespaces is the RESOLVED, concrete namespace set for this item. It is meaningful only when + // the verdict is admitted, and it is deliberately allowed to be empty for a wildcard whose + // policy currently admits nothing. + Namespaces []string + // Verdict is admitted / denied / cannot-say-yet / permanently-unevaluatable. + Verdict SourceScopeVerdict + // Reason is the SourceNamespaceAuthorized condition reason this item would produce. + Reason string + // Message explains the verdict to an operator. + Message string +} + +// Admitted reports whether this item may contribute selections. +func (d SourceNamespaceDecision) Admitted() bool { return d.Verdict == SourceScopeAdmitted } + +// Terminal reports whether the verdict is a REFUSAL the controller should publish as Stalled=True +// while establishing a grant — as opposed to a retryable "cannot say yet". A permanently +// unevaluatable policy is terminal here only because this gate ESTABLISHES grants; a caller +// maintaining an already-resolved scope must retain it instead (see the establishing/maintaining +// contract in the PR 4 design), which is why that decision is the caller's and not encoded here. +func (d SourceNamespaceDecision) Terminal() bool { + return d.Verdict == SourceScopeDenied || d.Verdict == SourceScopeUnavailable +} + +// 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, target policy, source Namespace snapshot), recomputed on +// every compile and replaced atomically. 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 + // Verdict is the aggregate over Items, per the status contract's reason precedence. + Verdict SourceScopeVerdict + // Reason is the aggregate SourceNamespaceAuthorized reason. + Reason string + // Message explains the aggregate, naming the deciding item when one item decided it. + Message string +} + +// Admitted reports whether the whole rule may compile. +func (s ResolvedSourceScope) Admitted() bool { return s.Verdict == SourceScopeAdmitted } + +// Terminal reports whether the aggregate is a refusal rather than a retryable "cannot say yet". +func (s ResolvedSourceScope) Terminal() bool { + return s.Verdict == SourceScopeDenied || s.Verdict == SourceScopeUnavailable +} + +// NamespacesFor returns the resolved namespace set for one item index. +func (s ResolvedSourceScope) NamespacesFor(index int) []string { + if index < 0 || index >= len(s.Items) { + return nil + } + return s.Items[index].Namespaces +} + +// Fingerprint renders the resolved scope as a stable string, per item, for the watched-type +// re-projection gate. +// +// This is the SILENT hazard the design calls out: a wildcard's inputs — the GitTarget policy and +// the source cluster's Namespace labels — are not rule state, so a mapper that merely requeues the +// WatchRule is not enough. If the fingerprint hashed the rule spec instead of the RESOLVED set, +// reconciliation would run, the fingerprint would be unchanged, the table rebuild would be skipped, +// and every stream would carry on at its old width with no visible failure anywhere. +func (s ResolvedSourceScope) Fingerprint() string { + parts := make([]string, 0, len(s.Items)) + for _, item := range s.Items { + parts = append(parts, fmt.Sprintf("%d=%s", item.Index, strings.Join(item.Namespaces, ","))) + } + return strings.Join(parts, ";") +} + +// ResolveWatchRuleSourceScope is the WatchRule source-namespace gate: which source-cluster +// namespaces may each of this rule's items watch, in its GitTarget's source cluster? +// +// It is CROSS-OBJECT authorization — WatchRule → GitTarget → ClusterProvider — and the selector +// half needs remote state, so it is not expressible in CEL and is deliberately a reconciler check +// rather than a webhook (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 per-candidate ordering is the contract, unchanged from the single-namespace gate it +// generalizes: +// +// 1. Own namespace + NO declared GitTarget policy → allowed, with no delegation flag and no +// policy. This is the legacy case and it must stay free: gating it would break every existing +// WatchRule on upgrade. +// 2. A DIFFERENT namespace — including "*" — additionally requires the GitTarget's namespace to be +// admitted by its ClusterProvider, and that provider to set allowSourceNamespaceOverride. +// 3. Whenever a policy is declared it is EXHAUSTIVE — evaluated even for an own-namespace item, +// with no self-namespace carve-out — and an override against a target with NO policy is +// denied by default. +// +// A non-NotFound ClusterProvider 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, + resolver SourceNamespaceResolver, +) (ResolvedSourceScope, error) { + gate := &itemGate{reader: reader, rule: rule, target: target, resolver: resolver} + 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) + } + return aggregateSourceScope(items), nil +} + +// itemGate carries the per-rule inputs so each item's decision reads as one step rather than a +// six-argument call. The ClusterProvider 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. +type itemGate struct { + reader client.Reader + rule *configv1alpha3.WatchRule + target *configv1alpha3.GitTarget + resolver SourceNamespaceResolver + + delegation *SourceNamespaceDecision + delegationAsked bool +} + +// decide resolves ONE rule item: its requested namespace (or wildcard) through the three-part gate. +func (g *itemGate) decide( + ctx context.Context, + index int, + item *configv1alpha3.ResourceRule, +) (SourceNamespaceDecision, error) { + base := SourceNamespaceDecision{Index: index, Requested: item.SourceNamespace} + overrides := item.OverridesSourceNamespace(g.rule.Namespace) + + // (1) The legacy case: own namespace, no policy. Free, and it must stay free. + if !overrides && !g.target.DeclaresSourceNamespacePolicy() { + own := item.EffectiveSourceNamespace(g.rule.Namespace) + base.Namespaces = []string{own} + base.Verdict = SourceScopeAdmitted + base.Reason = ReasonLegacySourceNamespace + base.Message = fmt.Sprintf( + "%s watches this WatchRule's own namespace %q; the GitTarget declares no "+ + "allowedSourceNamespaces policy, so no authorization is required", + g.describeItem(index, item), own) + return base, nil + } + + // (2) Anything other than the rule's own namespace needs provider admission of the target AND + // the explicit delegation. + if overrides { + refusal, refused, err := g.overrideDelegated(ctx, index, item) + if err != nil { + return SourceNamespaceDecision{}, err + } + if refused { + return refusal, nil + } + } + + // (3) A declared policy is exhaustive; an override against no policy is denied by default. + if !g.target.DeclaresSourceNamespacePolicy() { + base.Verdict = SourceScopeDenied + base.Reason = ReasonSourceNamespaceNotAllowed + base.Message = fmt.Sprintf( + "%s: GitTarget %s/%s declares no spec.allowedSourceNamespaces, so no source namespace "+ + "other than this WatchRule's own may be mirrored into it; declare that policy and "+ + "add %s to it", + g.describeItem(index, item), g.target.Namespace, g.target.Name, + item.DescribeSourceNamespace(g.rule.Namespace)) + return base, nil + } + + // (4) A declared policy whose names could never BE namespace names is unevaluatable, not + // narrower. The schema rejects them at admission, but a policy stored before that validation + // shipped is still in etcd — and the two ways to carry on are both worse than refusing: + // honouring the valid subset silently mirrors less than the operator asked for, and + // resolving a wildcard THROUGH such a name produces a scope pointing at a namespace that + // cannot exist. Unavailable is the accurate verdict: only an operator edit fixes it, and the + // establishing/maintaining contract already handles it correctly in both directions. + if err := g.target.Spec.AllowedSourceNamespaces.ValidateNames(); err != nil { + base.Verdict = SourceScopeUnavailable + base.Reason = ReasonSourceNamespacePolicyUnavailable + base.Message = fmt.Sprintf( + "%s: GitTarget %s/%s spec.allowedSourceNamespaces cannot be evaluated: %v; a policy "+ + "admits namespaces by NAME or by selector, never by pattern — use `selector: {}` to "+ + "admit every source namespace", + g.describeItem(index, item), g.target.Namespace, g.target.Name, err) + return base, nil + } + + if item.IsSourceNamespaceWildcard() { + return g.expandWildcard(ctx, index, item, base), nil + } + return g.evaluateCandidate(ctx, index, item, base), nil +} + +// 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 caller should carry on to the GitTarget policy. 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 + } + 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.SourceCluster() + + var provider configv1alpha3.ClusterProvider + if err := g.reader.Get(ctx, k8stypes.NamespacedName{Name: providerName}, &provider); err != nil { + if apierrors.IsNotFound(err) { + return &SourceNamespaceDecision{ + Verdict: SourceScopeDenied, + Reason: ReasonSourceNamespaceNotAllowed, + Message: fmt.Sprintf( + "referenced ClusterProvider %q 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.allowSourceNamespaceOverride", + providerName), + }, nil + } + // Transient: requeue rather than tear down a running stream. + return nil, fmt.Errorf("read ClusterProvider %q: %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{ + Verdict: SourceScopeDenied, + Reason: ReasonSourceNamespaceNotAllowed, + Message: fmt.Sprintf( + "GitTarget %s/%s may not mirror through ClusterProvider %q at all: %s", + g.target.Namespace, g.target.Name, providerName, admitted.Message), + }, nil + } + + if !provider.AllowsSourceNamespaceOverride() { + return &SourceNamespaceDecision{ + Verdict: SourceScopeDenied, + Reason: ReasonSourceNamespaceNotAllowed, + Message: fmt.Sprintf( + "ClusterProvider %q does not set spec.allowSourceNamespaceOverride; 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. +} + +// evaluateCandidate runs ONE named candidate through the GitTarget's declared policy and maps the +// three-valued answer onto the condition's reasons. +func (g *itemGate) evaluateCandidate( + ctx context.Context, + index int, + item *configv1alpha3.ResourceRule, + base SourceNamespaceDecision, +) SourceNamespaceDecision { + candidate := item.EffectiveSourceNamespace(g.rule.Namespace) + result := resolveWith(ctx, g.resolver, g.target, candidate) + label := g.describeItem(index, item) + + switch result.Verdict { + case SourceScopeAdmitted: + base.Namespaces = []string{candidate} + base.Verdict = SourceScopeAdmitted + base.Reason = ReasonSourceNamespaceAllowed + base.Message = fmt.Sprintf( + "%s: source namespace %q is admitted by GitTarget %s/%s spec.allowedSourceNamespaces", + label, candidate, g.target.Namespace, g.target.Name) + case SourceScopeDenied: + base.Verdict = SourceScopeDenied + base.Reason = ReasonSourceNamespaceNotAllowed + base.Message = label + ": " + g.deniedMessage(item, candidate, result.Message) + case SourceScopeUnavailable: + base.Verdict = SourceScopeUnavailable + base.Reason = ReasonSourceNamespacePolicyUnavailable + base.Message = fmt.Sprintf( + "%s: GitTarget %s/%s spec.allowedSourceNamespaces cannot be evaluated for %q: %s", + label, g.target.Namespace, g.target.Name, candidate, result.Message) + case SourceScopeUnknown: + fallthrough + default: + base.Verdict = SourceScopeUnknown + base.Reason = ReasonCheckingSourceNamespacePolicy + base.Message = fmt.Sprintf( + "%s: still establishing whether source namespace %q is admitted by GitTarget %s/%s: %s", + label, candidate, g.target.Namespace, g.target.Name, result.Message) + } + return base +} + +// expandWildcard resolves a "*" item to exactly the set the GitTarget's policy admits — never to +// every namespace that exists. +// +// The names half is answered here, with no source-cluster access at all, so a "*" against a +// names-only policy keeps resolving on a cluster whose Namespace list is Forbidden. That +// degradation path is the half most likely to regress unnoticed. The selector half needs the +// snapshot, and a selector that cannot be evaluated yields Unknown/Unavailable for the whole item +// rather than the names it did manage to read: a partial set would silently narrow the watch. +func (g *itemGate) expandWildcard( + ctx context.Context, + index int, + item *configv1alpha3.ResourceRule, + base SourceNamespaceDecision, +) SourceNamespaceDecision { + label := g.describeItem(index, item) + policy := g.target.Spec.AllowedSourceNamespaces + admitted := append([]string(nil), policy.Names...) + + if policy.HasSelector() { + selected, result := enumerateWith(ctx, g.resolver, g.target) + switch result.Verdict { + case SourceScopeAdmitted: + admitted = append(admitted, selected...) + case SourceScopeUnavailable: + base.Verdict = SourceScopeUnavailable + base.Reason = ReasonSourceNamespacePolicyUnavailable + base.Message = fmt.Sprintf( + "%s: GitTarget %s/%s spec.allowedSourceNamespaces cannot be enumerated for %q: %s", + label, g.target.Namespace, g.target.Name, configv1alpha3.SourceNamespaceWildcard, + result.Message) + return base + case SourceScopeDenied, SourceScopeUnknown: + fallthrough + default: + base.Verdict = SourceScopeUnknown + base.Reason = ReasonCheckingSourceNamespacePolicy + base.Message = fmt.Sprintf( + "%s: still enumerating which source namespaces GitTarget %s/%s admits: %s", + label, g.target.Namespace, g.target.Name, result.Message) + return base + } + } + + base.Namespaces = sortedUnique(admitted) + base.Verdict = SourceScopeAdmitted + base.Reason = ReasonSourceNamespaceAllowed + if len(base.Namespaces) == 0 { + base.Reason = ReasonNoAdmittedSourceNamespaces + base.Message = fmt.Sprintf( + "%s: GitTarget %s/%s spec.allowedSourceNamespaces currently admits no source namespace, "+ + "so this item watches nothing", + label, g.target.Namespace, g.target.Name) + return base + } + base.Message = fmt.Sprintf( + "%s: expands to the %d source namespace(s) GitTarget %s/%s admits (%s)", + label, len(base.Namespaces), g.target.Namespace, g.target.Name, + strings.Join(base.Namespaces, ", ")) + return base +} + +// deniedMessage names the SPECIFIC fix, which matters most in the case the design calls a genuine +// authoring footgun: declaring a policy for one item silently denies a co-resident LEGACY item +// unless its own namespace is listed. A denial you are told about, in the terms of the fix, is the +// price of a field that means what it says — so that case gets its own wording. +func (g *itemGate) deniedMessage(item *configv1alpha3.ResourceRule, candidate, detail string) string { + if !item.OverridesSourceNamespace(g.rule.Namespace) { + return fmt.Sprintf( + "namespace %s is not in the GitTarget's allowedSourceNamespaces; add it to keep "+ + "watching this rule's own namespace (GitTarget %s/%s declares a policy, and a "+ + "declared policy is exhaustive — there is no self-namespace exception)", + candidate, g.target.Namespace, g.target.Name) + } + msg := fmt.Sprintf( + "source namespace %q is not admitted by GitTarget %s/%s spec.allowedSourceNamespaces; "+ + "add it to that policy", + candidate, g.target.Namespace, g.target.Name) + if detail != "" { + msg += ": " + detail + } + return msg +} + +// describeItem names an item by index AND by what it selects. The index alone goes stale the moment +// somebody reorders the list while reading the message, so both are always present. +func (g *itemGate) describeItem(index int, item *configv1alpha3.ResourceRule) string { + return fmt.Sprintf("spec.rules[%d] (resources %s, sourceNamespace %s)", + index, strings.Join(item.Resources, ","), item.DescribeSourceNamespace(g.rule.Namespace)) +} + +// 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. any item permanently unevaluatable → False / SourceNamespacePolicyUnavailable / Stalled=True +// (the caller downgrades this to Unknown when it is MAINTAINING a retained scope) +// 3. any item still resolving → Unknown / CheckingSourceNamespacePolicy +// 4. every item admitted, at least one naming a namespace other than the rule's own → True / +// SourceNamespaceAllowed — or NoAdmittedSourceNamespaces when the whole resolved scope is empty +// 5. every item omitted → True / LegacySourceNamespace +func aggregateSourceScope(items []SourceNamespaceDecision) ResolvedSourceScope { + out := ResolvedSourceScope{Items: items} + if len(items) == 0 { + out.Verdict = SourceScopeAdmitted + out.Reason = ReasonLegacySourceNamespace + out.Message = "no rule items to authorize" + return out + } + + for _, verdict := range []SourceScopeVerdict{SourceScopeDenied, SourceScopeUnavailable, SourceScopeUnknown} { + if worst, ok := firstWithVerdict(items, verdict); ok { + out.Verdict = worst.Verdict + out.Reason = worst.Reason + out.Message = worst.Message + return out + } + } + + out.Verdict = SourceScopeAdmitted + out.Reason, out.Message = admittedAggregate(items) + return out +} + +func firstWithVerdict(items []SourceNamespaceDecision, verdict SourceScopeVerdict) (SourceNamespaceDecision, bool) { + for _, item := range items { + if item.Verdict == verdict { + return item, true + } + } + return SourceNamespaceDecision{}, false +} + +// admittedAggregate picks the True reason once every item was admitted. An empty resolved scope +// gets its own reason: the rule is not stalled, but a rule that mirrors nothing while reporting +// Ready=True with no explanation is a silent no-op. +func admittedAggregate(items []SourceNamespaceDecision) (string, string) { + total := 0 + legacy := true + for _, item := range items { + total += len(item.Namespaces) + if item.Reason != ReasonLegacySourceNamespace { + legacy = false + } + } + switch { + case total == 0: + return ReasonNoAdmittedSourceNamespaces, + "every rule item is authorized, but the resolved source-namespace scope is empty, so " + + "this WatchRule currently mirrors nothing" + case legacy: + return ReasonLegacySourceNamespace, items[0].Message + default: + return ReasonSourceNamespaceAllowed, summariseAdmitted(items) + } +} + +func summariseAdmitted(items []SourceNamespaceDecision) string { + all := make([]string, 0, len(items)) + for _, item := range items { + all = append(all, item.Namespaces...) + } + all = sortedUnique(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 +} + +// resolveWith calls the resolver, treating a MISSING resolver as "cannot say yet" rather than as a +// denial. A nil resolver means the source-scope service is not wired (a zero-value manager in +// tests, or a controller running before the data plane is up); answering "denied" there would stop +// streams for an entirely unrelated reason. Exact-NAME policies are answered inline first so they +// remain usable with no resolver and no source-cluster access at all. +func resolveWith( + ctx context.Context, + resolver SourceNamespaceResolver, + target *configv1alpha3.GitTarget, + namespace string, +) SourceScopeResult { + if target.Spec.AllowedSourceNamespaces.MatchesName(namespace) { + return SourceScopeResult{ + Verdict: SourceScopeAdmitted, + Message: "admitted by an exact name entry", + } + } + if !target.Spec.AllowedSourceNamespaces.HasSelector() { + // A declared policy with no selector and no matching name is a complete answer: nothing + // remote is needed to know it denies. + return SourceScopeResult{ + Verdict: SourceScopeDenied, + Message: "the policy lists no matching name and declares no selector", + } + } + if resolver == nil { + return SourceScopeResult{ + Verdict: SourceScopeUnknown, + Message: "no source-scope service is wired yet to evaluate the selector", + } + } + return resolver.ResolveSourceNamespace(ctx, target, namespace) +} + +// enumerateWith is the wildcard twin of resolveWith: it asks the resolver to expand the selector +// half, and treats a missing resolver as "cannot say yet". +func enumerateWith( + ctx context.Context, + resolver SourceNamespaceResolver, + target *configv1alpha3.GitTarget, +) ([]string, SourceScopeResult) { + if resolver == nil { + return nil, SourceScopeResult{ + Verdict: SourceScopeUnknown, + Message: "no source-scope service is wired yet to enumerate the selector", + } + } + return resolver.EnumerateSourceNamespaces(ctx, target) +} diff --git a/internal/authz/source_namespace_test.go b/internal/authz/source_namespace_test.go new file mode 100644 index 00000000..9db254a0 --- /dev/null +++ b/internal/authz/source_namespace_test.go @@ -0,0 +1,677 @@ +// SPDX-License-Identifier: Apache-2.0 + +package authz_test + +import ( + "context" + "errors" + "testing" + + "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" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "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" + "github.com/ConfigButler/gitops-reverser/internal/authz" +) + +const ( + snTenantNS = "tenant-acme" + snSourceNS = "repo-config" + snTargetName = "acme" + snRuleName = "repo-config-rule" + snProvider = "workspaces" + snWildcard = configv1alpha3.SourceNamespaceWildcard +) + +func snScheme(t *testing.T) *runtime.Scheme { + t.Helper() + s := runtime.NewScheme() + require.NoError(t, clientgoscheme.AddToScheme(s)) + require.NoError(t, configv1alpha3.AddToScheme(s)) + return s +} + +func snTarget(policy *configv1alpha3.NamespaceMatcher) *configv1alpha3.GitTarget { + return &configv1alpha3.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: snTargetName, Namespace: snTenantNS}, + Spec: configv1alpha3.GitTargetSpec{ + ProviderRef: configv1alpha3.GitProviderReference{Name: "acme-git"}, + ClusterProviderRef: &configv1alpha3.ClusterProviderReference{Name: snProvider}, + Branch: "main", + Path: "tenants/acme", + AllowedSourceNamespaces: policy, + }, + } +} + +// snRule builds a WatchRule with one item per given sourceNamespace ("" = omitted). +func snRule(sourceNamespaces ...string) *configv1alpha3.WatchRule { + items := make([]configv1alpha3.ResourceRule, 0, len(sourceNamespaces)) + for _, ns := range sourceNamespaces { + items = append(items, configv1alpha3.ResourceRule{ + Resources: []string{"configmaps"}, SourceNamespace: ns, + }) + } + return &configv1alpha3.WatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: snRuleName, Namespace: snTenantNS}, + Spec: configv1alpha3.WatchRuleSpec{ + TargetRef: configv1alpha3.LocalTargetReference{Name: snTargetName}, + Rules: items, + }, + } +} + +// snClusterProvider 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 snClusterProvider(delegate bool) *configv1alpha3.ClusterProvider { + return &configv1alpha3.ClusterProvider{ + ObjectMeta: metav1.ObjectMeta{Name: snProvider}, + Spec: configv1alpha3.ClusterProviderSpec{ + AllowedNamespaces: &configv1alpha3.NamespaceMatcher{Names: []string{snTenantNS}}, + AllowSourceNamespaceOverride: delegate, + }, + } +} + +// stubResolver is a source-scope service stand-in. The gate only ever asks it SELECTOR questions — +// authz answers the exact-name half itself — so a test that expects it to be consulted is also +// asserting that the name fast-path did not swallow the question. +type stubResolver struct { + result authz.SourceScopeResult + enumerated []string + enumeration authz.SourceScopeResult + calls int + enumCalls int +} + +func (s *stubResolver) ResolveSourceNamespace( + context.Context, *configv1alpha3.GitTarget, string, +) authz.SourceScopeResult { + s.calls++ + return s.result +} + +func (s *stubResolver) EnumerateSourceNamespaces( + context.Context, *configv1alpha3.GitTarget, +) ([]string, authz.SourceScopeResult) { + s.enumCalls++ + return s.enumerated, s.enumeration +} + +func admitting() *stubResolver { + return &stubResolver{result: authz.SourceScopeResult{Verdict: authz.SourceScopeAdmitted}} +} + +func denying() *stubResolver { + return &stubResolver{result: authz.SourceScopeResult{Verdict: authz.SourceScopeDenied}} +} + +func enumerating(names ...string) *stubResolver { + return &stubResolver{ + enumerated: names, + enumeration: authz.SourceScopeResult{Verdict: authz.SourceScopeAdmitted}, + } +} + +// resolve is the one-item shorthand every truth-table row uses. +func resolveOne( + t *testing.T, + sourceNamespace string, + policy *configv1alpha3.NamespaceMatcher, + delegate bool, + resolver authz.SourceNamespaceResolver, +) authz.ResolvedSourceScope { + t.Helper() + target := snTarget(policy) + cl := fake.NewClientBuilder(). + WithScheme(snScheme(t)). + WithObjects( + target, + snClusterProvider(delegate), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snTenantNS}}, + ). + Build() + + resolved, err := authz.ResolveWatchRuleSourceScope( + context.Background(), cl, snRule(sourceNamespace), target, resolver) + require.NoError(t, err) + return resolved +} + +// TestResolveWatchRuleSourceScope is the gate's truth table, per rule ITEM, modelled on +// TestCheckSourceAuthorization. The first two rows are the LEGACY guarantee: if they ever fail, +// deny-by-default has broken every existing WatchRule on upgrade. +func TestResolveWatchRuleSourceScope(t *testing.T) { + labelled := map[string]string{"gitops.configbutler.ai/mirrorable": "true"} + selectorPolicy := &configv1alpha3.NamespaceMatcher{ + Selector: &metav1.LabelSelector{MatchLabels: labelled}, + } + + tests := []struct { + name string + // sourceNamespace is spec.rules[0].sourceNamespace ("" = omitted). + sourceNamespace string + policy *configv1alpha3.NamespaceMatcher + delegate bool + resolver *stubResolver + wantVerdict authz.SourceScopeVerdict + wantReason string + wantNamespaces []string + }{{ + // THE test. An item that omits sourceNamespace must pass with no policy and no flag. + name: "omitted, no policy, flag false: allowed (legacy, and must stay free)", + sourceNamespace: "", + policy: nil, + delegate: false, + wantVerdict: authz.SourceScopeAdmitted, + wantReason: authz.ReasonLegacySourceNamespace, + wantNamespaces: []string{snTenantNS}, + }, { + name: "explicitly equals its own namespace, no policy, flag false: allowed", + sourceNamespace: snTenantNS, + policy: nil, + delegate: false, + wantVerdict: authz.SourceScopeAdmitted, + wantReason: authz.ReasonLegacySourceNamespace, + wantNamespaces: []string{snTenantNS}, + }, { + // The no-self-namespace-exception rule: the case most likely to be implemented as an + // accidental carve-out. A DECLARED policy is exhaustive, own namespace included. + name: "omitted, policy declared but omits the rule's own namespace: denied", + sourceNamespace: "", + policy: &configv1alpha3.NamespaceMatcher{Names: []string{snSourceNS}}, + delegate: false, + wantVerdict: authz.SourceScopeDenied, + wantReason: authz.ReasonSourceNamespaceNotAllowed, + }, { + name: "omitted, policy declared and lists the rule's own namespace: allowed", + sourceNamespace: "", + policy: &configv1alpha3.NamespaceMatcher{Names: []string{snTenantNS}}, + delegate: false, + wantVerdict: authz.SourceScopeAdmitted, + wantReason: authz.ReasonSourceNamespaceAllowed, + wantNamespaces: []string{snTenantNS}, + }, { + name: "differs, flag false: denied even though the target names it", + sourceNamespace: snSourceNS, + policy: &configv1alpha3.NamespaceMatcher{Names: []string{snSourceNS}}, + delegate: false, + wantVerdict: authz.SourceScopeDenied, + wantReason: authz.ReasonSourceNamespaceNotAllowed, + }, { + name: "differs, flag true, target policy absent: denied (deny-by-default)", + sourceNamespace: snSourceNS, + policy: nil, + delegate: true, + wantVerdict: authz.SourceScopeDenied, + wantReason: authz.ReasonSourceNamespaceNotAllowed, + }, { + name: "differs, flag true, target policy empty: denied (empty is not unrestricted)", + sourceNamespace: snSourceNS, + policy: &configv1alpha3.NamespaceMatcher{}, + delegate: true, + wantVerdict: authz.SourceScopeDenied, + wantReason: authz.ReasonSourceNamespaceNotAllowed, + }, { + name: "differs, flag true, target names it: allowed", + sourceNamespace: snSourceNS, + policy: &configv1alpha3.NamespaceMatcher{Names: []string{snSourceNS}}, + delegate: true, + wantVerdict: authz.SourceScopeAdmitted, + wantReason: authz.ReasonSourceNamespaceAllowed, + wantNamespaces: []string{snSourceNS}, + }, { + name: "differs, flag true, target selector matches: allowed", + sourceNamespace: snSourceNS, + policy: selectorPolicy, + delegate: true, + resolver: admitting(), + wantVerdict: authz.SourceScopeAdmitted, + wantReason: authz.ReasonSourceNamespaceAllowed, + wantNamespaces: []string{snSourceNS}, + }, { + name: "differs, flag true, target names a DIFFERENT namespace: denied", + sourceNamespace: snSourceNS, + policy: &configv1alpha3.NamespaceMatcher{Names: []string{"someone-elses-namespace"}}, + delegate: true, + wantVerdict: authz.SourceScopeDenied, + wantReason: authz.ReasonSourceNamespaceNotAllowed, + }, { + name: "differs, flag true, selector does not match: denied", + sourceNamespace: snSourceNS, + policy: selectorPolicy, + delegate: true, + resolver: denying(), + wantVerdict: authz.SourceScopeDenied, + wantReason: authz.ReasonSourceNamespaceNotAllowed, + }, { + // An unevaluatable policy is NOT a refusal and must not share its code path. + name: "differs, flag true, selector permanently unevaluatable: policy unavailable", + sourceNamespace: snSourceNS, + policy: selectorPolicy, + delegate: true, + resolver: &stubResolver{result: authz.SourceScopeResult{ + Verdict: authz.SourceScopeUnavailable, Message: "namespaces list is forbidden", + }}, + wantVerdict: authz.SourceScopeUnavailable, + wantReason: authz.ReasonSourceNamespacePolicyUnavailable, + }, { + name: "differs, flag true, selector answer not ready yet: retryable, not denied", + sourceNamespace: snSourceNS, + policy: selectorPolicy, + delegate: true, + resolver: &stubResolver{result: authz.SourceScopeResult{ + Verdict: authz.SourceScopeUnknown, Message: "cache still syncing", + }}, + wantVerdict: authz.SourceScopeUnknown, + wantReason: authz.ReasonCheckingSourceNamespacePolicy, + }, { + // "*" is deny-by-default too: it follows the policy's set, so with no policy there is no set. + name: "wildcard, flag true, no policy: denied — a wildcard is not a backdoor", + sourceNamespace: snWildcard, + policy: nil, + delegate: true, + wantVerdict: authz.SourceScopeDenied, + wantReason: authz.ReasonSourceNamespaceNotAllowed, + }, { + name: "wildcard, flag false: denied even with a policy that would admit", + sourceNamespace: snWildcard, + policy: &configv1alpha3.NamespaceMatcher{Names: []string{snSourceNS}}, + delegate: false, + wantVerdict: authz.SourceScopeDenied, + wantReason: authz.ReasonSourceNamespaceNotAllowed, + }, { + name: "wildcard against a names policy: expands to exactly those names", + sourceNamespace: snWildcard, + policy: &configv1alpha3.NamespaceMatcher{Names: []string{"team-payments", snSourceNS}}, + delegate: true, + wantVerdict: authz.SourceScopeAdmitted, + wantReason: authz.ReasonSourceNamespaceAllowed, + wantNamespaces: []string{snSourceNS, "team-payments"}, + }, { + name: "wildcard against a declared-but-empty policy: admits nothing, but is not a refusal", + sourceNamespace: snWildcard, + policy: &configv1alpha3.NamespaceMatcher{}, + delegate: true, + wantVerdict: authz.SourceScopeAdmitted, + wantReason: authz.ReasonNoAdmittedSourceNamespaces, + wantNamespaces: []string{}, + }, { + name: "wildcard against a selector policy: expands to the enumerated set", + sourceNamespace: snWildcard, + policy: selectorPolicy, + delegate: true, + resolver: enumerating("beta", "alpha"), + wantVerdict: authz.SourceScopeAdmitted, + wantReason: authz.ReasonSourceNamespaceAllowed, + wantNamespaces: []string{"alpha", "beta"}, + }, { + name: "wildcard, selector enumeration unavailable: never read as the empty set", + sourceNamespace: snWildcard, + policy: selectorPolicy, + delegate: true, + resolver: &stubResolver{enumeration: authz.SourceScopeResult{ + Verdict: authz.SourceScopeUnavailable, Message: "namespaces list is forbidden", + }}, + wantVerdict: authz.SourceScopeUnavailable, + wantReason: authz.ReasonSourceNamespacePolicyUnavailable, + }, { + name: "wildcard, selector enumeration not ready: retryable, not denied", + sourceNamespace: snWildcard, + policy: selectorPolicy, + delegate: true, + resolver: &stubResolver{enumeration: authz.SourceScopeResult{ + Verdict: authz.SourceScopeUnknown, Message: "cache still syncing", + }}, + wantVerdict: authz.SourceScopeUnknown, + wantReason: authz.ReasonCheckingSourceNamespacePolicy, + }} + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var resolver authz.SourceNamespaceResolver + if tc.resolver != nil { + resolver = tc.resolver + } + + resolved := resolveOne(t, tc.sourceNamespace, tc.policy, tc.delegate, resolver) + + assert.Equal(t, tc.wantVerdict, resolved.Verdict, "verdict (message: %s)", resolved.Message) + assert.Equal(t, tc.wantReason, resolved.Reason) + assert.NotEmpty(t, resolved.Message, "every verdict must carry an operator-legible message") + require.Len(t, resolved.Items, 1) + if tc.wantNamespaces != nil { + assert.Equal(t, tc.wantNamespaces, resolved.NamespacesFor(0)) + } + }) + } +} + +// TestResolveWatchRuleSourceScope_MixedItemsResolveIndependently is the point of moving the field +// onto the items: one rule can follow configmaps in its own namespace, secrets in a named one, and +// deployments everywhere the target admits. +func TestResolveWatchRuleSourceScope_MixedItemsResolveIndependently(t *testing.T) { + target := snTarget(&configv1alpha3.NamespaceMatcher{ + Names: []string{snTenantNS, snSourceNS, "team-payments"}, + }) + cl := fake.NewClientBuilder().WithScheme(snScheme(t)). + WithObjects(target, snClusterProvider(true), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snTenantNS}}).Build() + + resolved, err := authz.ResolveWatchRuleSourceScope( + context.Background(), cl, snRule("", snSourceNS, snWildcard), target, nil) + + require.NoError(t, err) + require.True(t, resolved.Admitted(), "message: %s", resolved.Message) + assert.Equal(t, authz.ReasonSourceNamespaceAllowed, resolved.Reason) + assert.Equal(t, []string{snTenantNS}, resolved.NamespacesFor(0)) + assert.Equal(t, []string{snSourceNS}, resolved.NamespacesFor(1)) + assert.Equal(t, []string{snSourceNS, "team-payments", snTenantNS}, resolved.NamespacesFor(2)) +} + +// TestResolveWatchRuleSourceScope_DeniedItemRefusesTheWholeRule is decision 5: a denied explicit +// name is never trimmed away and run as a partial rule. Mirroring two of the three namespaces a rule +// asked for is worse than a loud failure — and the message must name the offending item. +func TestResolveWatchRuleSourceScope_DeniedItemRefusesTheWholeRule(t *testing.T) { + target := snTarget(&configv1alpha3.NamespaceMatcher{Names: []string{snTenantNS, snSourceNS}}) + cl := fake.NewClientBuilder().WithScheme(snScheme(t)). + WithObjects(target, snClusterProvider(true), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snTenantNS}}).Build() + + resolved, err := authz.ResolveWatchRuleSourceScope( + context.Background(), cl, snRule("", snSourceNS, "tenant-zen"), target, nil) + + require.NoError(t, err) + assert.Equal(t, authz.SourceScopeDenied, resolved.Verdict) + assert.Equal(t, authz.ReasonSourceNamespaceNotAllowed, resolved.Reason) + assert.Contains(t, resolved.Message, "spec.rules[2]", + "the aggregate message must name the failing item by index...") + assert.Contains(t, resolved.Message, "tenant-zen", + "...and by what it asked for, because an index alone goes stale on a reorder") +} + +// TestResolveWatchRuleSourceScope_EmptyWildcardIsVisibleNotStalled is the other half of decision 5: +// a "*" that currently admits nothing is valid and does not stall the rule, but it must not read as +// healthy either — a rule that mirrors nothing while reporting Ready=True is a silent no-op. +func TestResolveWatchRuleSourceScope_EmptyWildcardIsVisibleNotStalled(t *testing.T) { + target := snTarget(&configv1alpha3.NamespaceMatcher{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirrorable": "true"}}, + }) + cl := fake.NewClientBuilder().WithScheme(snScheme(t)). + WithObjects(target, snClusterProvider(true), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snTenantNS}}).Build() + + resolved, err := authz.ResolveWatchRuleSourceScope( + context.Background(), cl, snRule(snWildcard), target, enumerating()) + + require.NoError(t, err) + assert.True(t, resolved.Admitted(), "an empty admitted set is not a refusal") + assert.False(t, resolved.Terminal()) + assert.Equal(t, authz.ReasonNoAdmittedSourceNamespaces, resolved.Reason) + assert.Empty(t, resolved.NamespacesFor(0)) +} + +// TestResolveWatchRuleSourceScope_WildcardOverNamesNeedsNoResolver is the degradation path applied +// to the wildcard: a names-only policy is enumerable with no source-cluster access at all, so "*" +// keeps resolving on a cluster whose Namespace list is Forbidden. +func TestResolveWatchRuleSourceScope_WildcardOverNamesNeedsNoResolver(t *testing.T) { + target := snTarget(&configv1alpha3.NamespaceMatcher{Names: []string{snSourceNS, "team-payments"}}) + cl := fake.NewClientBuilder().WithScheme(snScheme(t)). + WithObjects(target, snClusterProvider(true), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snTenantNS}}).Build() + + resolver := denying() + resolved, err := authz.ResolveWatchRuleSourceScope( + context.Background(), cl, snRule(snWildcard), target, resolver) + + require.NoError(t, err) + assert.True(t, resolved.Admitted()) + assert.Equal(t, []string{snSourceNS, "team-payments"}, resolved.NamespacesFor(0)) + assert.Zero(t, resolver.enumCalls, + "a names-only policy must never reach the source-scope service") +} + +// TestResolveWatchRuleSourceScope_TargetIsolation is the multi-tenant invariant: a GitTarget's +// policy bounds ONLY that target. zen's policy admitting acme's namespace must not let a rule +// writing to ACME's target reach it. +func TestResolveWatchRuleSourceScope_TargetIsolation(t *testing.T) { + // acme's target admits only its own workspace; a sibling tenant's target admits "shared". + acme := snTarget(&configv1alpha3.NamespaceMatcher{Names: []string{"acme-workspace"}}) + + cl := fake.NewClientBuilder(). + WithScheme(snScheme(t)). + WithObjects(acme, snClusterProvider(true), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snTenantNS}}). + Build() + + resolved, err := authz.ResolveWatchRuleSourceScope( + context.Background(), cl, snRule("shared"), acme, nil) + + require.NoError(t, err) + assert.Equal(t, authz.SourceScopeDenied, resolved.Verdict, + "another target's policy must never widen this one") + assert.Equal(t, authz.ReasonSourceNamespaceNotAllowed, resolved.Reason) +} + +// TestResolveWatchRuleSourceScope_UnadmittedGitTargetCannotDelegate closes the first leg of the +// three-part gate: a provider that does not admit the GitTarget's own namespace delegates nothing to +// it, even with the flag on. +func TestResolveWatchRuleSourceScope_UnadmittedGitTargetCannotDelegate(t *testing.T) { + target := snTarget(&configv1alpha3.NamespaceMatcher{Names: []string{snSourceNS}}) + provider := snClusterProvider(true) + provider.Spec.AllowedNamespaces = &configv1alpha3.NamespaceMatcher{Names: []string{"some-other-tenant"}} + + cl := fake.NewClientBuilder(). + WithScheme(snScheme(t)). + WithObjects(target, provider, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snTenantNS}}). + Build() + + resolved, err := authz.ResolveWatchRuleSourceScope( + context.Background(), cl, snRule(snSourceNS), target, nil) + + require.NoError(t, err) + assert.Equal(t, authz.SourceScopeDenied, resolved.Verdict) + assert.Contains(t, resolved.Message, "may not mirror through ClusterProvider") +} + +// TestResolveWatchRuleSourceScope_MissingClusterProviderDeniesOverride: an absent provider delegates +// nothing. It must not be an implicit allow. +func TestResolveWatchRuleSourceScope_MissingClusterProviderDeniesOverride(t *testing.T) { + target := snTarget(&configv1alpha3.NamespaceMatcher{Names: []string{snSourceNS}}) + + cl := fake.NewClientBuilder().WithScheme(snScheme(t)).WithObjects(target).Build() + + resolved, err := authz.ResolveWatchRuleSourceScope( + context.Background(), cl, snRule(snSourceNS), target, nil) + + require.NoError(t, err) + assert.Equal(t, authz.SourceScopeDenied, resolved.Verdict) + assert.Equal(t, authz.ReasonSourceNamespaceNotAllowed, resolved.Reason) +} + +// TestResolveWatchRuleSourceScope_ProviderReadErrorIsRequeued: a transient apiserver failure must +// surface as an ERROR the caller requeues on, never as a silent denial that would tear down a +// running stream. +func TestResolveWatchRuleSourceScope_ProviderReadErrorIsRequeued(t *testing.T) { + target := snTarget(&configv1alpha3.NamespaceMatcher{Names: []string{snSourceNS}}) + boom := errors.New("etcdserver: request timed out") + + cl := fake.NewClientBuilder(). + WithScheme(snScheme(t)). + WithObjects(target, snClusterProvider(true)). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func( + ctx context.Context, c client.WithWatch, key client.ObjectKey, + obj client.Object, opts ...client.GetOption, + ) error { + if _, ok := obj.(*configv1alpha3.ClusterProvider); ok { + return boom + } + return c.Get(ctx, key, obj, opts...) + }, + }). + Build() + + _, err := authz.ResolveWatchRuleSourceScope( + context.Background(), cl, snRule(snSourceNS), target, nil) + + require.Error(t, err, "a non-NotFound read error must requeue, not deny") + assert.ErrorIs(t, err, boom) +} + +// TestResolveWatchRuleSourceScope_ExactNamesNeedNoResolver is the DEGRADATION PATH, and the half +// most likely to regress unnoticed: with no source-scope service wired at all (standing in for a +// source cluster whose Namespace access is denied), an exact-NAME entry still admits while a +// SELECTOR entry fails safe as "cannot say yet" rather than as a denial. +func TestResolveWatchRuleSourceScope_ExactNamesNeedNoResolver(t *testing.T) { + t.Run("exact name still admits", func(t *testing.T) { + resolved := resolveOne(t, snSourceNS, + &configv1alpha3.NamespaceMatcher{Names: []string{snSourceNS}}, true, nil) + + assert.Equal(t, authz.SourceScopeAdmitted, resolved.Verdict, + "a name-based policy must not depend on source-cluster Namespace access") + }) + + t.Run("selector without a resolver is retryable, never denied", func(t *testing.T) { + resolved := resolveOne(t, snSourceNS, &configv1alpha3.NamespaceMatcher{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"x": "y"}}, + }, true, nil) + + assert.Equal(t, authz.SourceScopeUnknown, resolved.Verdict) + assert.Equal(t, authz.ReasonCheckingSourceNamespacePolicy, resolved.Reason) + }) + + t.Run("wildcard over a selector without a resolver is retryable, never empty", func(t *testing.T) { + resolved := resolveOne(t, snWildcard, &configv1alpha3.NamespaceMatcher{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"x": "y"}}, + }, true, nil) + + assert.Equal(t, authz.SourceScopeUnknown, resolved.Verdict) + assert.Empty(t, resolved.NamespacesFor(0), + "and it resolves NO namespaces, so nothing can be compiled from it") + }) +} + +// TestResolveWatchRuleSourceScope_PatternInPolicyNamesCannotBeEvaluated covers the policy the +// schema now rejects but etcd may still hold. +// +// `names: ["*"]` reads like "every namespace" and is nothing of the sort: `*` is a literal name +// Kubernetes matches against nothing, so honouring it would resolve a wildcard item to a namespace +// that cannot exist — an authorized-looking rule mirroring zero objects. The verdict must therefore +// be UNAVAILABLE (an operator edit is required) rather than a smaller admitted scope, and it must +// condemn the whole policy: admitting the entries that happen to be well-formed is silent +// narrowing, which is the failure this design refuses everywhere else. +func TestResolveWatchRuleSourceScope_PatternInPolicyNamesCannotBeEvaluated(t *testing.T) { + t.Run("a wildcard item cannot resolve through it", func(t *testing.T) { + resolved := resolveOne(t, snWildcard, + &configv1alpha3.NamespaceMatcher{Names: []string{"*"}}, true, admitting()) + + assert.Equal(t, authz.SourceScopeUnavailable, resolved.Verdict) + assert.Equal(t, authz.ReasonSourceNamespacePolicyUnavailable, resolved.Reason) + assert.Empty(t, resolved.NamespacesFor(0), + "no scope may be resolved from a policy that cannot be evaluated") + assert.Contains(t, resolved.Message, "selector: {}", + "the message must name the form that actually admits every namespace") + }) + + t.Run("a valid name alongside it does not rescue the policy", func(t *testing.T) { + resolved := resolveOne(t, snSourceNS, + &configv1alpha3.NamespaceMatcher{Names: []string{snSourceNS, "*"}}, true, admitting()) + + assert.Equal(t, authz.SourceScopeUnavailable, resolved.Verdict, + "a partially valid policy is not a smaller policy") + }) + + t.Run("a legacy own-namespace rule against no policy is untouched", func(t *testing.T) { + resolved := resolveOne(t, "", nil, false, nil) + + assert.Equal(t, authz.SourceScopeAdmitted, resolved.Verdict, + "validation applies to a DECLARED policy; it must not disturb the legacy path") + assert.Equal(t, authz.ReasonLegacySourceNamespace, resolved.Reason) + }) +} + +// TestResolveWatchRuleSourceScope_NameFastPathSkipsTheResolver pins the degradation path's +// mechanism, not just its outcome: a name match must be answered WITHOUT consulting the +// source-scope service at all, or "exact names keep working without Namespace access" is only +// accidentally true. +func TestResolveWatchRuleSourceScope_NameFastPathSkipsTheResolver(t *testing.T) { + resolver := denying() + resolved := resolveOne(t, snSourceNS, &configv1alpha3.NamespaceMatcher{ + Names: []string{snSourceNS}, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"never": "consulted"}}, + }, true, resolver) + + assert.Equal(t, authz.SourceScopeAdmitted, resolved.Verdict) + assert.Zero(t, resolver.calls, "an exact-name match must never reach the source-scope service") +} + +// TestResolvedSourceScope_Fingerprint pins the §4.3 hazard at its source: the fingerprint must move +// when the RESOLVED set changes, even though the rule spec that produced it is byte-identical. +func TestResolvedSourceScope_Fingerprint(t *testing.T) { + narrow := authz.ResolvedSourceScope{Items: []authz.SourceNamespaceDecision{ + {Index: 0, Namespaces: []string{"a"}}, + }} + wide := authz.ResolvedSourceScope{Items: []authz.SourceNamespaceDecision{ + {Index: 0, Namespaces: []string{"a", "b"}}, + }} + + assert.NotEqual(t, narrow.Fingerprint(), wide.Fingerprint(), + "a policy edit that widens a wildcard MUST change the fingerprint, or the watched-type "+ + "table is never re-projected and the streams silently keep their old width") + assert.Equal(t, narrow.Fingerprint(), authz.ResolvedSourceScope{ + Items: []authz.SourceNamespaceDecision{{Index: 0, Namespaces: []string{"a"}}}, + }.Fingerprint(), "and an unchanged set must be stable, or every reconcile rebuilds the table") +} + +// TestSourceNamespaceDecision_TerminalClassification pins which verdicts stop a rule while +// ESTABLISHING a grant. Denied and Unavailable are terminal; Unknown must never be, or a transient +// outage becomes a permanent Stalled=True. +func TestSourceNamespaceDecision_TerminalClassification(t *testing.T) { + for verdict, wantTerminal := range map[authz.SourceScopeVerdict]bool{ + authz.SourceScopeAdmitted: false, + authz.SourceScopeUnknown: false, + authz.SourceScopeDenied: true, + authz.SourceScopeUnavailable: true, + } { + decision := authz.SourceNamespaceDecision{Verdict: verdict} + assert.Equal(t, wantTerminal, decision.Terminal(), "verdict %d", verdict) + assert.Equal(t, wantTerminal, authz.ResolvedSourceScope{Verdict: verdict}.Terminal()) + } +} + +// TestAggregateSourceScope_ReasonPrecedence pins the §5 order. Without a stated precedence two +// implementations disagree about mixed rules, and "worst wins" is ambiguous between a denial and an +// unevaluatable policy. +func TestAggregateSourceScope_ReasonPrecedence(t *testing.T) { + selectorPolicy := &configv1alpha3.NamespaceMatcher{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirrorable": "true"}}, + } + target := snTarget(selectorPolicy) + cl := fake.NewClientBuilder().WithScheme(snScheme(t)). + WithObjects(target, snClusterProvider(true), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snTenantNS}}).Build() + + // One item the selector denies, one it cannot evaluate: DENIAL wins, because it is a decision + // while "cannot say" is the absence of one. + mixed := &stubResolver{result: authz.SourceScopeResult{Verdict: authz.SourceScopeDenied}} + resolved, err := authz.ResolveWatchRuleSourceScope( + context.Background(), cl, snRule("a-namespace", "b-namespace"), target, mixed) + require.NoError(t, err) + assert.Equal(t, authz.ReasonSourceNamespaceNotAllowed, resolved.Reason) + + // Unavailable outranks Unknown for the same reason, one level down. + unavailable := &stubResolver{result: authz.SourceScopeResult{Verdict: authz.SourceScopeUnavailable}} + resolved, err = authz.ResolveWatchRuleSourceScope( + context.Background(), cl, snRule("a-namespace"), target, unavailable) + require.NoError(t, err) + assert.Equal(t, authz.ReasonSourceNamespacePolicyUnavailable, resolved.Reason) +} diff --git a/internal/controller/clusterprovider_controller_test.go b/internal/controller/clusterprovider_controller_test.go index 5553714b..6cd57f8d 100644 --- a/internal/controller/clusterprovider_controller_test.go +++ b/internal/controller/clusterprovider_controller_test.go @@ -54,7 +54,7 @@ var _ = Describe("ClusterProvider Controller", func() { provider := &configbutleraiv1alpha3.ClusterProvider{ ObjectMeta: metav1.ObjectMeta{Name: "local-extra"}, Spec: configbutleraiv1alpha3.ClusterProviderSpec{ - AllowedNamespaces: &configbutleraiv1alpha3.AllowedNamespaces{Names: []string{"default"}}, + AllowedNamespaces: &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"default"}}, }, } Expect(k8sClient.Create(context.Background(), provider)).To(Succeed()) diff --git a/internal/controller/clusterwatchrule_admission_test.go b/internal/controller/clusterwatchrule_admission_test.go index 7784ca9b..15f3b3be 100644 --- a/internal/controller/clusterwatchrule_admission_test.go +++ b/internal/controller/clusterwatchrule_admission_test.go @@ -17,6 +17,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + "sigs.k8s.io/controller-runtime/pkg/event" configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" "github.com/ConfigButler/gitops-reverser/internal/authz" @@ -32,6 +33,10 @@ type cwaWatchManager struct { replans int replanErr error onReconcile func() + + // scope is the source-scope service this double hands back. It stays nil unless a test needs + // grants to be observable, so every existing test keeps the "no data plane is wired" path. + scope watch.SourceScopeService } func (m *cwaWatchManager) ReconcileForRuleChange(context.Context) error { @@ -70,6 +75,14 @@ func (m *cwaWatchManager) StreamSummaryForClusterWatchRule( return cwaRunningSummary() } +// SourceScope returns the injected service, or nil when a test wired none — in which case +// selector-based allowedSourceNamespaces degrades to "cannot say yet" while exact names stay fully +// answerable. +func (m *cwaWatchManager) SourceScope() watch.SourceScopeService { return m.scope } + +// SourceNamespaceEvents returns nil, so no source-cluster Namespace channel is wired. +func (m *cwaWatchManager) SourceNamespaceEvents() <-chan event.GenericEvent { return nil } + func cwaRunningSummary() watch.StreamSummary { return watch.StreamSummary{ Total: 1, Ready: 1, Reason: "Streaming", Message: "1/1 streams running", @@ -103,7 +116,7 @@ func cwaGitProvider() *configbutleraiv1alpha3.GitProvider { } } -func cwaClusterProvider(policy *configbutleraiv1alpha3.AllowedNamespaces) *configbutleraiv1alpha3.ClusterProvider { +func cwaClusterProvider(policy *configbutleraiv1alpha3.NamespaceMatcher) *configbutleraiv1alpha3.ClusterProvider { return &configbutleraiv1alpha3.ClusterProvider{ ObjectMeta: metav1.ObjectMeta{Name: cwaProviderName}, Spec: configbutleraiv1alpha3.ClusterProviderSpec{AllowedNamespaces: policy}, @@ -118,20 +131,20 @@ func cwaClusterWatchRule() *configbutleraiv1alpha3.ClusterWatchRule { Kind: "GitTarget", Name: cwaTargetName, Namespace: cwaTargetNS, }, Rules: []configbutleraiv1alpha3.ClusterResourceRule{{ - Scope: configbutleraiv1alpha3.ResourceScopeNamespaced, - Resources: []string{"configmaps"}, + Resources: []string{"customresourcedefinitions"}, + APIGroups: []string{"apiextensions.k8s.io"}, }}, }, } } // cwaAdmitting is the policy that admits cwaTargetNS; cwaDenying admits some other namespace. -func cwaAdmitting() *configbutleraiv1alpha3.AllowedNamespaces { - return &configbutleraiv1alpha3.AllowedNamespaces{Names: []string{cwaTargetNS}} +func cwaAdmitting() *configbutleraiv1alpha3.NamespaceMatcher { + return &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{cwaTargetNS}} } -func cwaDenying() *configbutleraiv1alpha3.AllowedNamespaces { - return &configbutleraiv1alpha3.AllowedNamespaces{Names: []string{"some-other-namespace"}} +func cwaDenying() *configbutleraiv1alpha3.NamespaceMatcher { + return &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"some-other-namespace"}} } type cwaFixture struct { @@ -337,7 +350,7 @@ func TestReconcile_AdmittedBySelectorOnNamespaceLabels(t *testing.T) { ctx := context.Background() f := newCWAFixture(t, []client.Object{ cwaClusterWatchRule(), cwaGitTarget(), cwaGitProvider(), - cwaClusterProvider(&configbutleraiv1alpha3.AllowedNamespaces{ + cwaClusterProvider(&configbutleraiv1alpha3.NamespaceMatcher{ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirror": "yes"}}, }), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ diff --git a/internal/controller/clusterwatchrule_controller.go b/internal/controller/clusterwatchrule_controller.go index 7f298b33..ff878340 100644 --- a/internal/controller/clusterwatchrule_controller.go +++ b/internal/controller/clusterwatchrule_controller.go @@ -22,7 +22,6 @@ import ( ctrlreconcile "sigs.k8s.io/controller-runtime/pkg/reconcile" configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" - "github.com/ConfigButler/gitops-reverser/internal/authz" "github.com/ConfigButler/gitops-reverser/internal/rulestore" "github.com/ConfigButler/gitops-reverser/internal/watch" ) @@ -40,13 +39,14 @@ const ( ClusterWatchRuleReasonUnresolvedResources = "UnresolvedResources" // ClusterWatchRuleReasonGitTargetNamespaceNotAuthorized is the terminal reason when the - // referenced GitTarget's namespace is not admitted by that target's ClusterProvider — either - // because spec.allowedNamespaces excludes 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 - // target. The Message carries which of the two it was. - ClusterWatchRuleReasonGitTargetNamespaceNotAuthorized = "GitTargetNamespaceNotAuthorized" + // referenced GitTarget's namespace is not admitted by that target's ClusterProvider. It is + // re-exported from internal/watch, where the shared compile path both bootstrap and this + // reconciler call decides it, so the two can never drift. + ClusterWatchRuleReasonGitTargetNamespaceNotAuthorized = watch.ClusterWatchRuleReasonGitTargetNamespaceNotAuthorized + + // ClusterWatchRuleReasonScopeNotSupported is the terminal reason for a STORED ClusterWatchRule + // that still selects namespaced resources through the removed scope choice. + ClusterWatchRuleReasonScopeNotSupported = watch.ClusterWatchRuleReasonScopeNotSupported ) // ClusterWatchRuleReconciler reconciles a ClusterWatchRule object. @@ -182,11 +182,6 @@ func (r *ClusterWatchRuleReconciler) reconcileClusterWatchRuleViaTarget( } r.setGitTargetReadyCondition(clusterRule, target) - // ClusterProvider namespace admission — see gateGitTargetAdmission. - if handled, result, err := r.gateGitTargetAdmission(ctx, clusterRule, target, log); handled { - return result, err - } - // Resolve GitProvider from target providerName := target.Spec.ProviderRef.Name providerNS := target.Namespace // Same as GitTarget @@ -217,14 +212,13 @@ func (r *ClusterWatchRuleReconciler) reconcileClusterWatchRuleViaTarget( // Ready check // TODO: Check GitProvider readiness - // Add rule to store with GitTarget reference and resolved values - r.RuleStore.AddOrUpdateClusterWatchRule( - *clusterRule, - target.Name, targetNS, // GitTarget reference - provider.Name, providerNS, // GitProvider reference - target.Spec.Branch, - target.Spec.Path, - ) + // Admission AND compilation, in that order and in one call — see gateClusterWatchRule. There is + // deliberately no AddOrUpdateClusterWatchRule here: routing every compilation through + // watch.CompileClusterWatchRule is what stops the startup bootstrap from being a second, + // ungated path into the store. + if handled, result, err := r.gateClusterWatchRule(ctx, clusterRule, target, provider, log); handled { + return result, err + } // Trigger WatchManager reconciliation for new/updated rule if r.WatchManager != nil { @@ -242,66 +236,72 @@ func (r *ClusterWatchRuleReconciler) reconcileClusterWatchRuleViaTarget( return r.setReadyAndUpdateStatusWithTarget(ctx, clusterRule) } -// gateGitTargetAdmission applies the ClusterProvider namespace admission to the GitTarget a -// ClusterWatchRule references. A ClusterWatchRule is cluster-scoped and its targetRef 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 allowedNamespaces never admitted that -// target — so the same decision the GitTarget reconciler makes is made here too, at the second -// call site that compiles rules. See internal/authz. +// gateClusterWatchRule is the ClusterWatchRule gate and the ONE place this controller compiles a +// cluster rule. It runs the shared compile path, which applies two refusals in order: // -// It returns handled=false when the rule may proceed; handled=true means the reconcile is over and -// the caller must return the accompanying result and error unchanged. -func (r *ClusterWatchRuleReconciler) gateGitTargetAdmission( +// 1. the ClusterProvider namespace admission of the referenced GitTarget. A ClusterWatchRule is +// cluster-scoped and its targetRef 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 allowedNamespaces never admitted that target. +// 2. the cluster-scope-only narrowing: a STORED rule that still says `scope: Namespaced` compiles +// no stream. Admission rejects the value on write, but a pre-release object keeps it in etcd. +// +// Both live in internal/watch rather than here because the startup bootstrap must apply exactly the +// same refusals BEFORE the first reconcile — otherwise every restart reopens the window they close. +// +// It returns handled=false when the rule compiled and the reconcile should continue; handled=true +// means the reconcile is over and the caller must return the accompanying result and error +// unchanged. +func (r *ClusterWatchRuleReconciler) gateClusterWatchRule( ctx context.Context, clusterRule *configbutleraiv1alpha3.ClusterWatchRule, target configbutleraiv1alpha3.GitTarget, + provider configbutleraiv1alpha3.GitProvider, log logr.Logger, ) (bool, ctrl.Result, error) { - admitted, err := authz.GitTargetAdmitted(ctx, r.Client, &target) + decision, err := watch.CompileClusterWatchRule( + ctx, r.Client, r.RuleStore, *clusterRule, target, provider) if err != nil { // A transient apiserver failure must NOT tear down a running stream: leave the compiled // rule in place and requeue with the error so the gate re-runs on real data. - log.Error(err, "Failed to evaluate ClusterProvider admission for referenced GitTarget", + log.Error(err, "Failed to evaluate admission for ClusterWatchRule", "gitTargetName", target.Name, "gitTargetNamespace", target.Namespace) return true, ctrl.Result{}, err } - if admitted.Allowed { + if decision.Admitted { return false, ctrl.Result{}, nil } - result, refuseErr := r.refuseUnauthorizedGitTarget(ctx, clusterRule, target, admitted, log) + result, refuseErr := r.refuseClusterWatchRule(ctx, clusterRule, decision, log) return true, result, refuseErr } -// refuseUnauthorizedGitTarget is the denial half of the ClusterProvider admission gate. +// refuseClusterWatchRule is the denial half of the ClusterWatchRule gate. // -// Order is the contract, not an implementation detail: the compiled rule is removed and the watch -// manager is replanned BEFORE any status is written. A gate that only writes a condition is not a -// gate — it leaves the stream running while announcing that it is not. Any test that asserts the -// terminal condition must therefore also be able to assert the rule is already gone. +// Order is the contract, not an implementation detail: CompileClusterWatchRule has ALREADY removed +// the compiled rule, this replans the watch manager, and only then is the terminal status +// published. A gate that only writes a condition is not a gate — it leaves the stream running while +// announcing that it is not. Any test that asserts the terminal condition must therefore 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 ClusterProvider policy -// change or a Namespace label change — through the mappers registered in SetupWithManager. -func (r *ClusterWatchRuleReconciler) refuseUnauthorizedGitTarget( +// change or a Namespace label change — through the mappers registered in SetupWithManager, or as an +// edit converting the rule to the cluster-only model. +func (r *ClusterWatchRuleReconciler) refuseClusterWatchRule( ctx context.Context, clusterRule *configbutleraiv1alpha3.ClusterWatchRule, - target configbutleraiv1alpha3.GitTarget, - decision authz.Decision, + decision watch.ClusterWatchRuleDecision, log logr.Logger, ) (ctrl.Result, error) { - log.Info("Refusing ClusterWatchRule: referenced GitTarget's namespace is not admitted by its ClusterProvider", + log.Info("Refusing ClusterWatchRule", "name", clusterRule.Name, - "gitTargetName", target.Name, - "gitTargetNamespace", target.Namespace, - "clusterProvider", target.SourceCluster(), - "reason", decision.Reason) + "reason", decision.Reason, + "message", decision.Message) - // 1. Stop the data plane: drop any rule compiled by an earlier, admitted reconcile... - r.RuleStore.DeleteClusterWatchRule(types.NamespacedName{Name: clusterRule.Name}) - - // ...and replan so the watch manager tears down a stream this rule was keeping alive. + // The compiled rule is already out of the store; replan so the watch manager tears down a + // stream this rule was keeping alive. if r.WatchManager != nil { if err := r.WatchManager.ReconcileForRuleChange(ctx); err != nil { log.Error(err, "Failed to reconcile watch manager after refusing cluster rule", @@ -311,25 +311,21 @@ func (r *ClusterWatchRuleReconciler) refuseUnauthorizedGitTarget( } } - // 2. Only now publish the terminal status. - msg := fmt.Sprintf( - "ClusterWatchRule may not compile against GitTarget '%s/%s': %s", - target.Namespace, target.Name, decision.Message) r.setTypedCondition( clusterRule, ConditionTypeGitTargetReady, metav1.ConditionFalse, - ClusterWatchRuleReasonGitTargetNamespaceNotAuthorized, - msg, + decision.Reason, + decision.Message, ) r.setTypedCondition( clusterRule, ConditionTypeStreamsRunning, metav1.ConditionFalse, - ClusterWatchRuleReasonGitTargetNamespaceNotAuthorized, - "No streams: the referenced GitTarget is not authorized to use its ClusterProvider", + decision.Reason, + "No streams: the ClusterWatchRule was refused", ) - r.setRuleStalled(clusterRule, ClusterWatchRuleReasonGitTargetNamespaceNotAuthorized, msg) + r.setRuleStalled(clusterRule, decision.Reason, decision.Message) return r.updateStatusAndRequeue(ctx, clusterRule) } @@ -449,7 +445,6 @@ func (r *ClusterWatchRuleReconciler) setRuleKstatus( applyRuleKstatus( clusterRule.Status.Conditions, readyMessage, - ClusterWatchRuleReasonReady, "ClusterWatchRule is not stalled", func(conditionType string, status metav1.ConditionStatus, reason, message string) { r.setTypedCondition(clusterRule, conditionType, status, reason, message) diff --git a/internal/controller/constants.go b/internal/controller/constants.go index a36b92fe..48d29d4a 100644 --- a/internal/controller/constants.go +++ b/internal/controller/constants.go @@ -7,6 +7,8 @@ import ( "context" "time" + "sigs.k8s.io/controller-runtime/pkg/event" + configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" "github.com/ConfigButler/gitops-reverser/internal/types" "github.com/ConfigButler/gitops-reverser/internal/watch" @@ -21,6 +23,21 @@ type WatchManagerInterface interface { StreamSummaryForGitTarget(gitDest types.ResourceReference) watch.StreamSummary StreamSummaryForWatchRule(rule configv1alpha3.WatchRule) watch.StreamSummary StreamSummaryForClusterWatchRule(rule configv1alpha3.ClusterWatchRule) watch.StreamSummary + + // SourceScope exposes the source-scope service — the manager-owned evaluation of a GitTarget's + // allowedSourceNamespaces against its SOURCE cluster, plus the per-rule resolved scopes. + // + // The gate runs in this package but the labels a selector needs live in a source cluster whose + // connection and cache the watch manager already owns, so the reconciler asks the manager + // instead of dialling that cluster itself on every pass. It may return nil (the data plane is + // not wired), which degrades to exact-name policy evaluation — never to a denial. + SourceScope() watch.SourceScopeService + + // SourceNamespaceEvents is the channel the WatchRule controller wires via source.Channel so a + // SOURCE-cluster Namespace label change re-reconciles the rules it grants or revokes. Those + // labels live in a cluster the controller has no client for, so the watch manager observes + // them and pushes the affected GitTargets here. + SourceNamespaceEvents() <-chan event.GenericEvent } const ( @@ -40,6 +57,18 @@ const ( ConditionTypeRenderMatchesLive = "RenderMatchesLive" // 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. + // + // 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 + // interchangeable: False is a refusal (terminal, Stalled=True), while Unknown covers both "the + // answer is still being established" and "a rule with an already-resolved scope has lost the + // ability to re-evaluate its policy and is retaining that scope" — neither of which may be + // rendered as a permanent failure. + ConditionTypeSourceNamespaceAuthorized = "SourceNamespaceAuthorized" // ConditionTypeStreamsReady is a source-compatibility alias for StreamsRunning. ConditionTypeStreamsReady = ConditionTypeStreamsRunning // ConditionTypeAuthorAttributed indicates whether a CommitRequest's commit author diff --git a/internal/controller/gittarget_source_cluster_test.go b/internal/controller/gittarget_source_cluster_test.go index a04ecc19..512d29ae 100644 --- a/internal/controller/gittarget_source_cluster_test.go +++ b/internal/controller/gittarget_source_cluster_test.go @@ -115,7 +115,7 @@ func scScheme(t *testing.T) *runtime.Scheme { } func TestCheckSourceAuthorization(t *testing.T) { - provider := func(policy *configbutleraiv1alpha3.AllowedNamespaces) *configbutleraiv1alpha3.ClusterProvider { + provider := func(policy *configbutleraiv1alpha3.NamespaceMatcher) *configbutleraiv1alpha3.ClusterProvider { return &configbutleraiv1alpha3.ClusterProvider{ ObjectMeta: metav1.ObjectMeta{Name: "prod-eu-1"}, Spec: configbutleraiv1alpha3.ClusterProviderSpec{AllowedNamespaces: policy}, @@ -148,21 +148,21 @@ func TestCheckSourceAuthorization(t *testing.T) { { name: "provider allows the namespace by name", objects: []client.Object{ - provider(&configbutleraiv1alpha3.AllowedNamespaces{Names: []string{"team-a"}}), ns, + provider(&configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-a"}}), ns, }, providerRef: "prod-eu-1", wantAuthorized: true, }, { name: "provider does not allow the namespace -> refused", objects: []client.Object{ - provider(&configbutleraiv1alpha3.AllowedNamespaces{Names: []string{"team-b"}}), ns, + provider(&configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-b"}}), ns, }, providerRef: "prod-eu-1", wantAuthorized: false, wantReason: GitTargetReasonNamespaceNotAuthorized, }, { name: "provider allows the namespace by label selector", objects: []client.Object{ - provider(&configbutleraiv1alpha3.AllowedNamespaces{ + provider(&configbutleraiv1alpha3.NamespaceMatcher{ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "trusted"}}, }), ns, }, @@ -173,7 +173,7 @@ func TestCheckSourceAuthorization(t *testing.T) { // unevaluatable policy as "allow" would hand a namespace access it was never granted. name: "invalid allowedNamespaces selector -> refused, not allowed", objects: []client.Object{ - provider(&configbutleraiv1alpha3.AllowedNamespaces{ + provider(&configbutleraiv1alpha3.NamespaceMatcher{ Selector: &metav1.LabelSelector{ MatchExpressions: []metav1.LabelSelectorRequirement{ {Key: "tier", Operator: "BogusOperator", Values: []string{"trusted"}}, @@ -188,14 +188,14 @@ 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.AllowedNamespaces{Names: []string{"team-a"}}), + provider(&configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"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.AllowedNamespaces{ + provider(&configbutleraiv1alpha3.NamespaceMatcher{ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "trusted"}}, }), }, @@ -231,7 +231,7 @@ func TestCheckSourceAuthorization_ReadErrorsRequeue(t *testing.T) { provider := &configbutleraiv1alpha3.ClusterProvider{ ObjectMeta: metav1.ObjectMeta{Name: "prod-eu-1"}, Spec: configbutleraiv1alpha3.ClusterProviderSpec{ - AllowedNamespaces: &configbutleraiv1alpha3.AllowedNamespaces{Names: []string{"team-a"}}, + AllowedNamespaces: &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-a"}}, }, } @@ -324,7 +324,7 @@ func TestReconcile_UnauthorizedNamespaceStartsNoWatch(t *testing.T) { provider := &configbutleraiv1alpha3.ClusterProvider{ ObjectMeta: metav1.ObjectMeta{Name: providerName}, Spec: configbutleraiv1alpha3.ClusterProviderSpec{ - AllowedNamespaces: &configbutleraiv1alpha3.AllowedNamespaces{Names: []string{"team-b"}}, + AllowedNamespaces: &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-b"}}, }, } gitProvider := &configbutleraiv1alpha3.GitProvider{ diff --git a/internal/controller/stream_status.go b/internal/controller/stream_status.go index 21e612dd..bd08123f 100644 --- a/internal/controller/stream_status.go +++ b/internal/controller/stream_status.go @@ -39,10 +39,14 @@ func watchRuleStreamsStatus(streams watch.StreamSummary) *configbutleraiv1alpha3 } } +// ruleReadyReason is the reason every rule kind stamps on its Ready/Reconciling/Stalled trio once +// it is healthy. WatchRule and ClusterWatchRule both spell it "Ready", so it is a constant here +// rather than a parameter each caller passes identically. +const ruleReadyReason = "Ready" + func applyRuleKstatus( conditions []metav1.Condition, readyMessage string, - readyReason string, notStalledMessage string, setCondition func(string, metav1.ConditionStatus, string, string), setStalled func(string, string), @@ -50,11 +54,24 @@ func applyRuleKstatus( resources := conditionByType(conditions, ConditionTypeResourcesResolved) gitTarget := conditionByType(conditions, ConditionTypeGitTargetReady) streams := conditionByType(conditions, ConditionTypeStreamsRunning) + // Absent on ClusterWatchRule, which does not carry this condition; nil simply never gates. + sourceNS := conditionByType(conditions, ConditionTypeSourceNamespaceAuthorized) - if stalled := stalledRuleCondition(resources, gitTarget, streams); stalled != nil { + if stalled := stalledRuleCondition(resources, gitTarget, streams, sourceNS); stalled != nil { setStalled(stalled.Reason, stalled.Message) return } + // Source authorization is an ADDITIONAL prerequisite of Ready, so Ready=True means the source + // namespace is authorized AND the GitTarget is ready AND resources resolved AND streams are + // running — never merely that the gate passed. Unknown yields InProgress here rather than a + // separate status path, which is what makes the retained-scope and cache-syncing cases + // representable without inventing a second readiness model. + if sourceNS != nil && sourceNS.Status != metav1.ConditionTrue { + reason, message := conditionReasonMessage( + sourceNS, ReasonProgressing, "Establishing source-namespace authorization") + setRuleProgressing(reason, message, notStalledMessage, setCondition) + return + } if gitTarget != nil && gitTarget.Status != metav1.ConditionTrue { reason, message := conditionReasonMessage(gitTarget, ReasonProgressing, "Waiting for GitTarget to become ready") setRuleProgressing(reason, message, notStalledMessage, setCondition) @@ -63,9 +80,9 @@ func applyRuleKstatus( switch { case streams != nil && streams.Status == metav1.ConditionTrue: - setCondition(ConditionTypeReady, metav1.ConditionTrue, readyReason, readyMessage) - setCondition(ConditionTypeReconciling, metav1.ConditionFalse, readyReason, "Reconciliation complete") - setCondition(ConditionTypeStalled, metav1.ConditionFalse, readyReason, notStalledMessage) + setCondition(ConditionTypeReady, metav1.ConditionTrue, ruleReadyReason, readyMessage) + setCondition(ConditionTypeReconciling, metav1.ConditionFalse, ruleReadyReason, "Reconciliation complete") + setCondition(ConditionTypeStalled, metav1.ConditionFalse, ruleReadyReason, notStalledMessage) default: reason, message := ReasonProgressing, "Waiting for streams to run" if streams != nil { @@ -75,8 +92,14 @@ func applyRuleKstatus( } } -func stalledRuleCondition(resources, gitTarget, streams *metav1.Condition) *metav1.Condition { +func stalledRuleCondition(resources, gitTarget, streams, sourceNS *metav1.Condition) *metav1.Condition { switch { + // A source-authorization REFUSAL outranks every other cause: the rule is not running and no + // amount of waiting changes that, so it must not be reported as merely progressing behind an + // unresolved type or an unready target. Only False is terminal here — Unknown is the + // deliberately non-terminal "cannot say yet", handled by the caller as progressing. + case sourceNS != nil && sourceNS.Status == metav1.ConditionFalse: + return sourceNS case resources != nil && resources.Status == metav1.ConditionFalse: return resources case gitTarget != nil && gitTarget.Status == metav1.ConditionFalse && gitTargetReadyReasonIsStalled(gitTarget.Reason): diff --git a/internal/controller/stream_status_test.go b/internal/controller/stream_status_test.go index a7d254f9..ff96d85e 100644 --- a/internal/controller/stream_status_test.go +++ b/internal/controller/stream_status_test.go @@ -33,7 +33,6 @@ func TestApplyRuleKstatus_GitTargetReadyStalledBlocksRule(t *testing.T) { applyRuleKstatus( conditions, "rule ready", - "Ready", "rule is not stalled", func(conditionType string, status metav1.ConditionStatus, reason, _ string) { got[conditionType] = status diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index c30431e4..afcf2762 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -141,7 +141,7 @@ var _ = BeforeSuite(func() { Expect(k8sClient.Create(ctx, &configbutleraiv1alpha3.ClusterProvider{ ObjectMeta: metav1.ObjectMeta{Name: configbutleraiv1alpha3.DefaultClusterProviderName}, Spec: configbutleraiv1alpha3.ClusterProviderSpec{ - AllowedNamespaces: &configbutleraiv1alpha3.AllowedNamespaces{Selector: &metav1.LabelSelector{}}, + AllowedNamespaces: &configbutleraiv1alpha3.NamespaceMatcher{Selector: &metav1.LabelSelector{}}, }, })).To(Succeed()) diff --git a/internal/controller/superseded_fields_admission_test.go b/internal/controller/superseded_fields_admission_test.go new file mode 100644 index 00000000..e647c8f2 --- /dev/null +++ b/internal/controller/superseded_fields_admission_test.go @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" +) + +// Both superseded fields were NARROWED rather than deleted, and this is the guard that they still +// are. It runs against the GENERATED CRDs, so it fails the moment somebody "cleans up" either field +// out of the Go types. +// +// Deleting a field is the silent option, which is why it was rejected: CRD pruning happens on WRITE, +// so once the schema drops a field, re-applying a legacy manifest is ACCEPTED with the value pruned +// away — no error anywhere — and the rule quietly changes what it mirrors. A retained-but-narrowed +// field turns that into an apply-time rejection an operator cannot miss. +var _ = Describe("Superseded source-scope fields are rejected, not pruned", func() { + It("rejects ClusterWatchRule scope: Namespaced at admission", func() { + ctx := context.Background() + + rule := &configbutleraiv1alpha3.ClusterWatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: "legacy-namespaced-scope"}, + Spec: configbutleraiv1alpha3.ClusterWatchRuleSpec{ + TargetRef: configbutleraiv1alpha3.NamespacedTargetReference{ + Name: "any-target", Namespace: "default", + }, + Rules: []configbutleraiv1alpha3.ClusterResourceRule{{ + Resources: []string{"configmaps"}, + Scope: configbutleraiv1alpha3.ResourceScopeNamespaced, + }}, + }, + } + + err := k8sClient.Create(ctx, rule) + + Expect(err).To(HaveOccurred(), + "a legacy namespaced ClusterWatchRule must FAIL to apply, never be silently pruned") + Expect(err.Error()).To(ContainSubstring("scope")) + }) + + It("accepts a ClusterWatchRule that omits scope, defaulting it to Cluster", func() { + ctx := context.Background() + + rule := &configbutleraiv1alpha3.ClusterWatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: "cluster-only-rule"}, + Spec: configbutleraiv1alpha3.ClusterWatchRuleSpec{ + TargetRef: configbutleraiv1alpha3.NamespacedTargetReference{ + Name: "any-target", Namespace: "default", + }, + Rules: []configbutleraiv1alpha3.ClusterResourceRule{{ + Resources: []string{"customresourcedefinitions"}, + APIGroups: []string{"apiextensions.k8s.io"}, + }}, + }, + } + + Expect(k8sClient.Create(ctx, rule)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, rule) }) + + //nolint:staticcheck // reading the deprecated field is the point: it must still default. + Expect(rule.Spec.Rules[0].Scope).To(Equal(configbutleraiv1alpha3.ResourceScopeCluster), + "the field is omittable and defaults to Cluster, so a converted manifest need not set it") + }) + + It("accepts rules[].sourceNamespace, including the wildcard", func() { + ctx := context.Background() + + rule := &configbutleraiv1alpha3.WatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: "per-item-source", Namespace: "default"}, + Spec: configbutleraiv1alpha3.WatchRuleSpec{ + TargetRef: configbutleraiv1alpha3.LocalTargetReference{Name: "any-target"}, + Rules: []configbutleraiv1alpha3.ResourceRule{ + {Resources: []string{"configmaps"}}, + {Resources: []string{"secrets"}, SourceNamespace: "repo-config"}, + {Resources: []string{"deployments"}, SourceNamespace: "*"}, + }, + }, + } + + Expect(k8sClient.Create(ctx, rule)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, rule) }) + }) + + It("rejects a structurally invalid rules[].sourceNamespace", func() { + ctx := context.Background() + + rule := &configbutleraiv1alpha3.WatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: "malformed-source", Namespace: "default"}, + Spec: configbutleraiv1alpha3.WatchRuleSpec{ + TargetRef: configbutleraiv1alpha3.LocalTargetReference{Name: "any-target"}, + Rules: []configbutleraiv1alpha3.ResourceRule{{ + Resources: []string{"configmaps"}, SourceNamespace: "Not A Namespace", + }}, + }, + } + + err := k8sClient.Create(ctx, rule) + + Expect(err).To(HaveOccurred(), + "a malformed namespace must be rejected at admission rather than resolving to nothing "+ + "at compile time") + }) +}) diff --git a/internal/controller/watchrule_controller.go b/internal/controller/watchrule_controller.go index ad164249..fc662f87 100644 --- a/internal/controller/watchrule_controller.go +++ b/internal/controller/watchrule_controller.go @@ -18,6 +18,7 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/predicate" ctrlreconcile "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" "github.com/ConfigButler/gitops-reverser/internal/rulestore" @@ -50,6 +51,7 @@ type WatchRuleReconciler struct { // +kubebuilder:rbac:groups=configbutler.ai,resources=watchrules/status,verbs=get;update;patch // +kubebuilder:rbac:groups=configbutler.ai,resources=gittargets,verbs=get;list;watch // +kubebuilder:rbac:groups=configbutler.ai,resources=gitproviders,verbs=get;list;watch +// +kubebuilder:rbac:groups=configbutler.ai,resources=clusterproviders,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch // Reconcile is part of the main kubernetes reconciliation loop which aims to @@ -69,6 +71,18 @@ func (r *WatchRuleReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( r.RuleStore.Delete(req.NamespacedName) log.Info("WatchRule deleted, removed from store", "name", req.Name, "namespace", req.Namespace) + // Drop the retained source-scope grant with it. The grant is what tells the gate a rule + // is MAINTAINING an already-resolved scope rather than ESTABLISHING one, and a rule that + // no longer exists is neither. Left behind, it is inherited by the next rule created + // under the same name and spec — a name a different tenant may now own — and an + // unevaluatable policy then reads as "retaining a known-good scope" instead of "no + // scope was ever established". The rule sits Unknown and Reconciling indefinitely + // rather than publishing the terminal, actionable refusal that tells its owner the + // policy cannot be evaluated. A recreated rule must establish from scratch. + if scope := r.sourceScope(); scope != nil { + scope.ForgetSourceScopeGrant(req.NamespacedName) + } + // Trigger WatchManager reconciliation for deletion if r.WatchManager != nil { if err := r.WatchManager.ReconcileForRuleChange(ctx); err != nil { @@ -109,6 +123,13 @@ func (r *WatchRuleReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( ReasonProgressing, "Blocked by validation; GitTarget not evaluated", ) + r.setTypedCondition( + &watchRule, + ConditionTypeSourceNamespaceAuthorized, + metav1.ConditionUnknown, + WatchRuleReasonCheckingSourceNamespacePolicy, + "Blocked by validation; source namespace not evaluated", + ) r.setTypedCondition( &watchRule, ConditionTypeReconciling, @@ -221,14 +242,13 @@ func (r *WatchRuleReconciler) reconcileWatchRuleViaTarget( // I added GitProviderStatus with Conditions. // TODO: Check GitProvider readiness. For now assume ready if found. - // Add rule to store with GitTarget reference and resolved values - r.RuleStore.AddOrUpdateWatchRule( - *watchRule, - target.Name, targetNS, // GitTarget reference (replaces GitDestination) - provider.Name, providerNS, // GitProvider reference (replaces GitRepoConfig) - target.Spec.Branch, - target.Spec.Path, - ) + // Source-namespace gate AND compilation, in that order and in one call — see + // gateSourceNamespace. There is deliberately no AddOrUpdateWatchRule here: routing every + // compilation through watch.CompileWatchRule is what stops the startup bootstrap from being a + // second, ungated path into the store. + if handled, result, err := r.gateSourceNamespace(ctx, watchRule, target, provider, log); handled { + return result, err + } // Trigger WatchManager reconciliation for new/updated rule if r.WatchManager != nil { @@ -332,7 +352,6 @@ func (r *WatchRuleReconciler) setRuleKstatus( applyRuleKstatus( watchRule.Status.Conditions, readyMessage, - WatchRuleReasonReady, "WatchRule is not stalled", func(conditionType string, status metav1.ConditionStatus, reason, message string) { r.setTypedCondition(watchRule, conditionType, status, reason, message) @@ -441,7 +460,7 @@ func (r *WatchRuleReconciler) updateStatusWithRetry( // SetupWithManager sets up the controller with the Manager. func (r *WatchRuleReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). + b := ctrl.NewControllerManagedBy(mgr). For(&configbutleraiv1alpha3.WatchRule{}). // GenerationChangedPredicate keeps these watches reacting to a freshly // applied or spec-changed dependency while ignoring the status-only @@ -457,8 +476,80 @@ func (r *WatchRuleReconciler) SetupWithManager(mgr ctrl.Manager) error { handler.EnqueueRequestsFromMapFunc(r.gitProviderToWatchRules), builder.WithPredicates(predicate.GenerationChangedPredicate{}), ). - Named("watchrule"). - Complete(r) + // React to a ClusterProvider's allowWatchRuleSourceNamespaceOverride (or allowedNamespaces) + // changing. The GitTarget->WatchRules edge above CANNOT carry this: a ClusterProvider change + // reaches the GitTarget as a STATUS update, which GenerationChangedPredicate deliberately + // drops. 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.ClusterProvider{}, + handler.EnqueueRequestsFromMapFunc(r.clusterProviderToWatchRules), + builder.WithPredicates(clusterProviderReadyOrSpecChanged()), + ). + Named("watchrule") + + // React to a SOURCE-cluster Namespace label change, which grants or revokes any rule whose + // GitTarget admits by selector. Those labels live in a cluster this controller has no client + // for, so the watch manager observes them and pushes the affected GitTargets here; the + // gitTargetToWatchRules mapper fans them out to the rules. See + // internal/watch/source_namespace_scope.go. + if r.WatchManager != nil { + if events := r.WatchManager.SourceNamespaceEvents(); events != nil { + b = b.WatchesRawSource(source.Channel( + events, + handler.EnqueueRequestsFromMapFunc(r.gitTargetToWatchRules), + )) + } + } + + return b.Complete(r) +} + +// clusterProviderToWatchRules maps a ClusterProvider change to every WatchRule whose GitTarget +// mirrors through that provider, so a delegation grant or revocation converges on the event rather +// than on the periodic reconcile. +func (r *WatchRuleReconciler) clusterProviderToWatchRules( + ctx context.Context, + obj client.Object, +) []ctrlreconcile.Request { + var targets configbutleraiv1alpha3.GitTargetList + if err := r.List(ctx, &targets); err != nil { + logDependencyListError(ctx, err, "GitTargets", obj) + return nil + } + + // A WatchRule's targetRef is a LocalTargetReference, so candidates always live in their + // GitTarget's own namespace — collect the affected (namespace, target name) pairs. + affected := make(map[types.NamespacedName]struct{}, len(targets.Items)) + for i := range targets.Items { + t := &targets.Items[i] + if t.SourceCluster() != obj.GetName() { + continue + } + affected[types.NamespacedName{Name: t.Name, Namespace: t.Namespace}] = struct{}{} + } + if len(affected) == 0 { + return nil + } + + var rules configbutleraiv1alpha3.WatchRuleList + if err := r.List(ctx, &rules); err != nil { + logDependencyListError(ctx, err, "WatchRules", obj) + return nil + } + + var requests []ctrlreconcile.Request + for i := range rules.Items { + rule := &rules.Items[i] + key := types.NamespacedName{Name: rule.Spec.TargetRef.Name, Namespace: rule.Namespace} + if _, ok := affected[key]; !ok { + continue + } + requests = append(requests, ctrlreconcile.Request{ + NamespacedName: types.NamespacedName{Name: rule.Name, Namespace: rule.Namespace}, + }) + } + return requests } // gitTargetToWatchRules maps a GitTarget event to every WatchRule in the diff --git a/internal/controller/watchrule_controller_test.go b/internal/controller/watchrule_controller_test.go index c46c848f..4894a554 100644 --- a/internal/controller/watchrule_controller_test.go +++ b/internal/controller/watchrule_controller_test.go @@ -262,8 +262,19 @@ var _ = Describe("WatchRule Controller", func() { Expect(err).NotTo(HaveOccurred()) By("Verifying WatchRule is reconciling until the GitTarget and streams are ready") - Expect(updatedRule.Status.Conditions).To(HaveLen(5)) + Expect(updatedRule.Status.Conditions).To(HaveLen(6)) var condition, streamsRunning, gitTargetReady, reconciling, stalled metav1.Condition + var sourceAuthorized metav1.Condition + for _, c := range updatedRule.Status.Conditions { + if c.Type == ConditionTypeSourceNamespaceAuthorized { + sourceAuthorized = c + } + } + + 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)) + for _, c := range updatedRule.Status.Conditions { if c.Type == ConditionTypeReady { condition = c diff --git a/internal/controller/watchrule_kstatus_test.go b/internal/controller/watchrule_kstatus_test.go new file mode 100644 index 00000000..3d2936d4 --- /dev/null +++ b/internal/controller/watchrule_kstatus_test.go @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + kstatus "sigs.k8s.io/cli-utils/pkg/kstatus/status" +) + +// TestWatchRuleSourceNamespaceKstatusContract walks every row of the PR 4 status table through the +// REAL kstatus helper, because the whole point of the contract is that +// sigs.k8s.io/cli-utils clients see the same Current/InProgress/Failed results as for the other +// CRDs — no phase, no state string, no second readiness model. +// +// The two rows that matter most are the two that LOOK alike and must not be: a selector that is +// permanently unevaluatable is Failed when NO scope was ever resolved (nothing runs, and only an +// operator change fixes it) and InProgress when a scope is being retained (the rule is still +// mirroring its granted namespace). Collapsing those would either stop a working stream or claim a +// dead rule is fine. +func TestWatchRuleSourceNamespaceKstatusContract(t *testing.T) { + tests := []struct { + name string + conds []map[string]interface{} + wantStatus kstatus.Status + wantMsg string + }{{ + name: "selector cache starting: source authorization Unknown", + conds: []map[string]interface{}{ + conditionMap(ConditionTypeSourceNamespaceAuthorized, "Unknown", + WatchRuleReasonCheckingSourceNamespacePolicy, "cache still syncing"), + conditionMap(ConditionTypeReady, "False", + WatchRuleReasonCheckingSourceNamespacePolicy, "cache still syncing"), + conditionMap(ConditionTypeReconciling, "True", + WatchRuleReasonCheckingSourceNamespacePolicy, "cache still syncing"), + conditionMap(ConditionTypeStalled, "False", + WatchRuleReasonCheckingSourceNamespacePolicy, "WatchRule is not stalled"), + }, + wantStatus: kstatus.InProgressStatus, + }, { + name: "authorized, but streams still replaying", + conds: []map[string]interface{}{ + conditionMap(ConditionTypeSourceNamespaceAuthorized, "True", + WatchRuleReasonSourceNamespaceAllowed, "admitted"), + conditionMap(ConditionTypeReady, "False", ReasonProgressing, "Waiting for streams to run"), + conditionMap(ConditionTypeReconciling, "True", ReasonProgressing, "Waiting for streams to run"), + conditionMap(ConditionTypeStalled, "False", ReasonProgressing, "WatchRule is not stalled"), + }, + wantStatus: kstatus.InProgressStatus, + }, { + name: "authorized and every prerequisite healthy", + conds: []map[string]interface{}{ + conditionMap(ConditionTypeSourceNamespaceAuthorized, "True", + WatchRuleReasonSourceNamespaceAllowed, "admitted"), + conditionMap(ConditionTypeReady, "True", WatchRuleReasonReady, "WatchRule is ready"), + conditionMap(ConditionTypeReconciling, "False", WatchRuleReasonReady, "Reconciliation complete"), + conditionMap(ConditionTypeStalled, "False", WatchRuleReasonReady, "WatchRule is not stalled"), + }, + wantStatus: kstatus.CurrentStatus, + }, { + name: "selector unevaluatable but a scope is already resolved: retained and still running", + conds: []map[string]interface{}{ + conditionMap(ConditionTypeSourceNamespaceAuthorized, "Unknown", + WatchRuleReasonSourceNamespacePolicyUnavailable, "retaining the last known-good scope"), + conditionMap(ConditionTypeReady, "False", + WatchRuleReasonSourceNamespacePolicyUnavailable, "retaining the last known-good scope"), + conditionMap(ConditionTypeReconciling, "True", + WatchRuleReasonSourceNamespacePolicyUnavailable, "retaining the last known-good scope"), + conditionMap(ConditionTypeStalled, "False", + WatchRuleReasonSourceNamespacePolicyUnavailable, "WatchRule is not stalled"), + }, + wantStatus: kstatus.InProgressStatus, + }, { + name: "delegation disabled, or the policy evaluated and denies", + conds: []map[string]interface{}{ + conditionMap(ConditionTypeSourceNamespaceAuthorized, "False", + WatchRuleReasonSourceNamespaceNotAllowed, + "source namespace \"repo-config\" is not admitted"), + conditionMap(ConditionTypeReady, "False", + WatchRuleReasonSourceNamespaceNotAllowed, + "source namespace \"repo-config\" is not admitted"), + conditionMap(ConditionTypeReconciling, "False", + WatchRuleReasonSourceNamespaceNotAllowed, "Reconciliation is stalled"), + conditionMap(ConditionTypeStalled, "True", + WatchRuleReasonSourceNamespaceNotAllowed, + "source namespace \"repo-config\" is not admitted"), + }, + wantStatus: kstatus.FailedStatus, + wantMsg: "repo-config", + }, { + name: "selector unevaluatable and no scope ever resolved: nothing runs", + conds: []map[string]interface{}{ + conditionMap(ConditionTypeSourceNamespaceAuthorized, "False", + WatchRuleReasonSourceNamespacePolicyUnavailable, "namespaces list is forbidden"), + conditionMap(ConditionTypeReady, "False", + WatchRuleReasonSourceNamespacePolicyUnavailable, "namespaces list is forbidden"), + conditionMap(ConditionTypeReconciling, "False", + WatchRuleReasonSourceNamespacePolicyUnavailable, "Reconciliation is stalled"), + conditionMap(ConditionTypeStalled, "True", + WatchRuleReasonSourceNamespacePolicyUnavailable, "namespaces list is forbidden"), + }, + wantStatus: kstatus.FailedStatus, + wantMsg: "forbidden", + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := kstatus.Compute(watchRuleStatusObject(tt.conds)) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, result.Status) + if tt.wantMsg != "" { + assert.Contains(t, result.Message, tt.wantMsg) + } + }) + } +} + +// TestApplyRuleKstatus_SourceAuthorizationIsAPrerequisite asserts the aggregation itself, not just +// its inputs: Ready=True must require SourceNamespaceAuthorized=True even when every pre-existing +// prerequisite is healthy. Without this, a rule could report Ready while its gate said otherwise. +func TestApplyRuleKstatus_SourceAuthorizationIsAPrerequisite(t *testing.T) { + healthy := []metav1.Condition{ + {Type: ConditionTypeResourcesResolved, Status: metav1.ConditionTrue, Reason: "Resolved"}, + {Type: ConditionTypeGitTargetReady, Status: metav1.ConditionTrue, Reason: "Ready"}, + {Type: ConditionTypeStreamsRunning, Status: metav1.ConditionTrue, Reason: "Streaming"}, + } + + tests := []struct { + name string + sourceNS *metav1.Condition + wantReady metav1.ConditionStatus + wantStalled metav1.ConditionStatus + }{{ + name: "no source condition at all (ClusterWatchRule): unchanged behavior", + sourceNS: nil, + wantReady: metav1.ConditionTrue, + wantStalled: metav1.ConditionFalse, + }, { + name: "authorized: Ready", + sourceNS: &metav1.Condition{ + Type: ConditionTypeSourceNamespaceAuthorized, Status: metav1.ConditionTrue, + Reason: WatchRuleReasonSourceNamespaceAllowed, + }, + wantReady: metav1.ConditionTrue, + wantStalled: metav1.ConditionFalse, + }, { + name: "unknown: progressing, never stalled", + sourceNS: &metav1.Condition{ + Type: ConditionTypeSourceNamespaceAuthorized, Status: metav1.ConditionUnknown, + Reason: WatchRuleReasonCheckingSourceNamespacePolicy, + }, + wantReady: metav1.ConditionFalse, + wantStalled: metav1.ConditionFalse, + }, { + name: "refused: stalled, even with every other prerequisite healthy", + sourceNS: &metav1.Condition{ + Type: ConditionTypeSourceNamespaceAuthorized, Status: metav1.ConditionFalse, + Reason: WatchRuleReasonSourceNamespaceNotAllowed, + }, + wantReady: metav1.ConditionFalse, + wantStalled: metav1.ConditionTrue, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conditions := append([]metav1.Condition(nil), healthy...) + if tt.sourceNS != nil { + conditions = append(conditions, *tt.sourceNS) + } + + got := map[string]metav1.ConditionStatus{} + applyRuleKstatus( + conditions, "ready", "not stalled", + func(conditionType string, status metav1.ConditionStatus, _, _ string) { + got[conditionType] = status + }, + func(string, string) { + got[ConditionTypeReady] = metav1.ConditionFalse + got[ConditionTypeReconciling] = metav1.ConditionFalse + got[ConditionTypeStalled] = metav1.ConditionTrue + }, + ) + + assert.Equal(t, tt.wantReady, got[ConditionTypeReady], "Ready") + assert.Equal(t, tt.wantStalled, got[ConditionTypeStalled], "Stalled") + }) + } +} + +func watchRuleStatusObject(conditions []map[string]interface{}) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "configbutler.ai/v1alpha3", + "kind": "WatchRule", + "metadata": map[string]interface{}{ + "name": "repo-config-rule", + "namespace": "tenant-acme", + "generation": int64(4), + }, + "status": map[string]interface{}{ + "observedGeneration": int64(4), + "conditions": conditions, + }, + }} +} diff --git a/internal/controller/watchrule_source_namespace.go b/internal/controller/watchrule_source_namespace.go new file mode 100644 index 00000000..410dc3bd --- /dev/null +++ b/internal/controller/watchrule_source_namespace.go @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + + "github.com/go-logr/logr" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + + configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/authz" + "github.com/ConfigButler/gitops-reverser/internal/watch" +) + +// SourceNamespaceAuthorized condition reasons, re-exported from internal/authz so the decision and +// the status surface can never drift apart. +const ( + // WatchRuleReasonLegacySourceNamespace is the True reason when every item watches the rule's + // own namespace against a GitTarget that declares no allowedSourceNamespaces policy. + 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 wildcard, + // or an own-namespace item a declared policy explicitly lists. + WatchRuleReasonSourceNamespaceAllowed = authz.ReasonSourceNamespaceAllowed + // WatchRuleReasonNoAdmittedSourceNamespaces is the True reason when every item is admitted but + // the resolved scope is EMPTY. Not stalled — but not silently healthy either. + WatchRuleReasonNoAdmittedSourceNamespaces = authz.ReasonNoAdmittedSourceNamespaces + // WatchRuleReasonSourceNamespaceNotAllowed is the TERMINAL False reason for a refusal. + WatchRuleReasonSourceNamespaceNotAllowed = authz.ReasonSourceNamespaceNotAllowed + // WatchRuleReasonSourceNamespacePolicyUnavailable is the reason for a selector policy that + // cannot be evaluated as written. It is False/Stalled while ESTABLISHING and Unknown while + // MAINTAINING an already-resolved scope — same reason, different claim about the rule. + WatchRuleReasonSourceNamespacePolicyUnavailable = authz.ReasonSourceNamespacePolicyUnavailable + // WatchRuleReasonCheckingSourceNamespacePolicy is the Unknown reason while the answer is still + // being established or a retryable source-cluster error is being retried. + WatchRuleReasonCheckingSourceNamespacePolicy = authz.ReasonCheckingSourceNamespacePolicy +) + +// gateSourceNamespace is the WatchRule source-namespace gate and the ONE place this controller +// compiles a rule. It runs after the GitTarget and GitProvider are resolved and instead of a bare +// AddOrUpdateWatchRule, so there is no ungated path from a WatchRule to a compiled rule. +// +// The gate is cross-object (WatchRule → GitTarget → ClusterProvider) and its selector half needs +// source-cluster state, 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 policy TIGHTENED after a +// rule was accepted revoke that rule. +// +// Every item is resolved, and the aggregate is published as one condition per the status contract's +// reason precedence. A DENIED explicit 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. +// +// It returns handled=false when the rule compiled and the reconcile should continue; handled=true +// means the reconcile is over and the caller must return the accompanying result and error +// unchanged. +func (r *WatchRuleReconciler) gateSourceNamespace( + ctx context.Context, + watchRule *configbutleraiv1alpha3.WatchRule, + target configbutleraiv1alpha3.GitTarget, + provider configbutleraiv1alpha3.GitProvider, + log logr.Logger, +) (bool, ctrl.Result, error) { + resolved, err := watch.CompileWatchRule( + ctx, r.Client, r.RuleStore, r.sourceScope(), *watchRule, target, provider) + if err != nil { + // A transient apiserver failure must NOT tear down a running stream: CompileWatchRule left + // the compiled rule in place, so requeue with the error and re-run the gate on real data. + log.Error(err, "Failed to evaluate source-namespace authorization", + "gitTargetName", target.Name, "gitTargetNamespace", target.Namespace) + return true, ctrl.Result{}, err + } + + switch { + case resolved.Admitted(): + r.setTypedCondition( + watchRule, + ConditionTypeSourceNamespaceAuthorized, + metav1.ConditionTrue, + resolved.Reason, + resolved.Message, + ) + return false, ctrl.Result{}, nil + + case resolved.Terminal(): + result, refuseErr := r.refuseSourceNamespace(ctx, watchRule, resolved, log) + return true, result, refuseErr + + default: + // Cannot say yet — the cache is syncing, a retryable source error is being retried, or a + // rule with an already-resolved scope is retaining it through an unevaluatable policy. In + // every case this is PROGRESSING, not failed: turning a temporary connection problem into + // a terminal Stalled=True would stop a stream over an outage nobody chose. + result, updateErr := r.holdSourceNamespaceUnknown(ctx, watchRule, resolved) + return true, result, updateErr + } +} + +// refuseSourceNamespace is the denial half of the gate. +// +// Order is the contract, not an implementation detail: CompileWatchRule has ALREADY removed the +// compiled rule, this replans the watch manager, and only then is the terminal status published. A +// gate that writes a condition while the stream keeps running is not a gate — so any test that +// 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 ClusterProvider flag +// or policy change, a GitTarget policy edit, or a source-cluster Namespace label change — through +// the mappers and channel registered in SetupWithManager. +func (r *WatchRuleReconciler) refuseSourceNamespace( + ctx context.Context, + watchRule *configbutleraiv1alpha3.WatchRule, + resolved authz.ResolvedSourceScope, + log logr.Logger, +) (ctrl.Result, error) { + log.Info("Refusing WatchRule: its source-namespace scope is not authorized", + "name", watchRule.Name, + "namespace", watchRule.Namespace, + "reason", resolved.Reason, + "message", resolved.Message) + + // The compiled rule is already out of the store; replan so the watch manager tears down any + // stream this rule was keeping alive. + if r.WatchManager != nil { + if err := r.WatchManager.ReconcileForRuleChange(ctx); err != nil { + log.Error(err, "Failed to reconcile watch manager after refusing WatchRule", + "name", watchRule.Name, "namespace", watchRule.Namespace) + // Don't fail the reconciliation: the rule is already out of the store, so the next + // replan from any source converges. Publishing the refusal matters more than this. + } + } + + r.setTypedCondition( + watchRule, + ConditionTypeSourceNamespaceAuthorized, + metav1.ConditionFalse, + resolved.Reason, + resolved.Message, + ) + r.setTypedCondition( + watchRule, + ConditionTypeStreamsRunning, + metav1.ConditionFalse, + resolved.Reason, + "No streams: the rule's source-namespace scope is not authorized", + ) + r.setRuleStalled(watchRule, resolved.Reason, resolved.Message) + + return r.updateStatusAndRequeue(ctx, watchRule) +} + +// holdSourceNamespaceUnknown publishes the "cannot say yet" state: SourceNamespaceAuthorized is +// Unknown and the rule is Reconciling, never Stalled. +// +// Nothing is compiled and nothing is removed. A rule still ESTABLISHING a grant runs nothing; a +// rule MAINTAINING an already-resolved scope keeps both its compiled rule and its streams and only +// moves this condition. Neither may narrow to the empty set — a narrowed set is the input to a +// resync sweep, so failing closed here would delete a tenant's Git content over a transient +// outage. +func (r *WatchRuleReconciler) holdSourceNamespaceUnknown( + ctx context.Context, + watchRule *configbutleraiv1alpha3.WatchRule, + resolved authz.ResolvedSourceScope, +) (ctrl.Result, error) { + r.setTypedCondition( + watchRule, + ConditionTypeSourceNamespaceAuthorized, + metav1.ConditionUnknown, + resolved.Reason, + resolved.Message, + ) + r.setTypedCondition( + watchRule, + ConditionTypeStreamsRunning, + metav1.ConditionUnknown, + resolved.Reason, + "Streams not re-evaluated while source-namespace authorization is unsettled", + ) + r.setRuleKstatus(watchRule, "WatchRule source-namespace authorization is unsettled") + + if err := r.updateStatusWithRetry(ctx, watchRule); err != nil { + return ctrl.Result{}, err + } + // Retry on the fast settle cadence: the answer usually arrives with the next source-cluster + // refresh, and the enqueue edge may not fire when nothing observably changed. + return ctrl.Result{RequeueAfter: RequeueStreamSettleInterval}, nil +} + +// sourceScope returns the source-scope service, or nil when the data plane is not wired. A nil +// service degrades selector policies to "cannot say yet" and leaves exact-NAME policies fully +// working — never a denial, which would refuse rules for a reason that has nothing to do with +// their configuration. +func (r *WatchRuleReconciler) sourceScope() watch.SourceScopeService { + if r.WatchManager == nil { + return nil + } + return r.WatchManager.SourceScope() +} diff --git a/internal/controller/watchrule_source_namespace_test.go b/internal/controller/watchrule_source_namespace_test.go new file mode 100644 index 00000000..ba497d69 --- /dev/null +++ b/internal/controller/watchrule_source_namespace_test.go @@ -0,0 +1,580 @@ +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "errors" + "testing" + + "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" + k8stypes "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/authz" + "github.com/ConfigButler/gitops-reverser/internal/rulestore" +) + +const ( + wrsnTenantNS = "tenant-acme" + wrsnSourceNS = "repo-config" + wrsnTarget = "acme" + wrsnRule = "repo-config-rule" + wrsnProvider = "workspaces" +) + +func wrsnGitTarget(policy *configbutleraiv1alpha3.NamespaceMatcher) *configbutleraiv1alpha3.GitTarget { + return &configbutleraiv1alpha3.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: wrsnTarget, Namespace: wrsnTenantNS}, + Spec: configbutleraiv1alpha3.GitTargetSpec{ + ProviderRef: configbutleraiv1alpha3.GitProviderReference{Name: "git"}, + ClusterProviderRef: &configbutleraiv1alpha3.ClusterProviderReference{Name: wrsnProvider}, + Branch: "main", + Path: "tenants/acme", + AllowedSourceNamespaces: policy, + }, + } +} + +func wrsnGitProvider() *configbutleraiv1alpha3.GitProvider { + return &configbutleraiv1alpha3.GitProvider{ + ObjectMeta: metav1.ObjectMeta{Name: "git", Namespace: wrsnTenantNS}, + } +} + +func wrsnClusterProvider(delegate bool) *configbutleraiv1alpha3.ClusterProvider { + return &configbutleraiv1alpha3.ClusterProvider{ + ObjectMeta: metav1.ObjectMeta{Name: wrsnProvider}, + Spec: configbutleraiv1alpha3.ClusterProviderSpec{ + AllowedNamespaces: &configbutleraiv1alpha3.NamespaceMatcher{ + Names: []string{wrsnTenantNS}, + }, + AllowSourceNamespaceOverride: delegate, + }, + } +} + +// wrsnWatchRule builds a rule with one item per given rules[].sourceNamespace ("" = omitted). +func wrsnWatchRule(sourceNamespaces ...string) *configbutleraiv1alpha3.WatchRule { + items := make([]configbutleraiv1alpha3.ResourceRule, 0, len(sourceNamespaces)) + for _, ns := range sourceNamespaces { + items = append(items, configbutleraiv1alpha3.ResourceRule{ + Resources: []string{"configmaps"}, SourceNamespace: ns, + }) + } + return &configbutleraiv1alpha3.WatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: wrsnRule, Namespace: wrsnTenantNS, Generation: 1}, + Spec: configbutleraiv1alpha3.WatchRuleSpec{ + TargetRef: configbutleraiv1alpha3.LocalTargetReference{Name: wrsnTarget}, + Rules: items, + }, + } +} + +type wrsnFixture struct { + reconciler *WatchRuleReconciler + store *rulestore.RuleStore + wm *cwaWatchManager + client client.Client +} + +func newWRSNFixture(t *testing.T, objects []client.Object) *wrsnFixture { + t.Helper() + + cl := fake.NewClientBuilder(). + WithScheme(scScheme(t)). + WithObjects(objects...). + WithStatusSubresource(&configbutleraiv1alpha3.WatchRule{}). + Build() + + store := rulestore.NewStore() + wm := &cwaWatchManager{} + + return &wrsnFixture{ + reconciler: &WatchRuleReconciler{ + Client: cl, + Scheme: cl.Scheme(), + RuleStore: store, + WatchManager: wm, + }, + store: store, + wm: wm, + client: cl, + } +} + +func (f *wrsnFixture) reconcile(ctx context.Context) (ctrl.Result, error) { + return f.reconciler.Reconcile(ctx, ctrl.Request{ + NamespacedName: k8stypes.NamespacedName{Name: wrsnRule, Namespace: wrsnTenantNS}, + }) +} + +func (f *wrsnFixture) compiledNames() []string { + names := []string{} + for _, r := range f.store.SnapshotWatchRules() { + names = append(names, r.Source.Name) + } + return names +} + +func (f *wrsnFixture) reloadRule(ctx context.Context, t *testing.T) *configbutleraiv1alpha3.WatchRule { + t.Helper() + var rule configbutleraiv1alpha3.WatchRule + require.NoError(t, f.client.Get(ctx, + k8stypes.NamespacedName{Name: wrsnRule, Namespace: wrsnTenantNS}, &rule)) + return &rule +} + +func wrsnCondition(t *testing.T, rule *configbutleraiv1alpha3.WatchRule, conditionType string) *metav1.Condition { + t.Helper() + cond := apimeta.FindStatusCondition(rule.Status.Conditions, conditionType) + require.NotNil(t, cond, "condition %s must be published", conditionType) + return cond +} + +func wrsnBaseObjects( + policy *configbutleraiv1alpha3.NamespaceMatcher, + delegate bool, + sourceNamespaces ...string, +) []client.Object { + return []client.Object{ + wrsnGitTarget(policy), + wrsnGitProvider(), + wrsnClusterProvider(delegate), + 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) { + ctx := context.Background() + f := newWRSNFixture(t, wrsnBaseObjects(nil, false, "")) + + _, err := f.reconcile(ctx) + + require.NoError(t, err) + assert.Equal(t, []string{wrsnRule}, f.compiledNames(), + "an existing own-namespace WatchRule must keep working with no new configuration") + + cond := wrsnCondition(t, f.reloadRule(ctx, t), ConditionTypeSourceNamespaceAuthorized) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + assert.Equal(t, WatchRuleReasonLegacySourceNamespace, cond.Reason) +} + +// TestReconcile_DeniedSourceNamespaceStartsNoWatch mirrors +// TestReconcile_UnauthorizedNamespaceStartsNoWatch: a denied override must leave NO compiled rule. +// 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. + f := newWRSNFixture(t, wrsnBaseObjects( + &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{wrsnSourceNS}}, + false, wrsnSourceNS)) + + _, err := f.reconcile(ctx) + + require.NoError(t, err) + assert.Empty(t, f.compiledNames(), "a denied override must start no watch") + + rule := f.reloadRule(ctx, t) + cond := wrsnCondition(t, rule, ConditionTypeSourceNamespaceAuthorized) + assert.Equal(t, metav1.ConditionFalse, cond.Status) + assert.Equal(t, WatchRuleReasonSourceNamespaceNotAllowed, cond.Reason) + assert.Contains(t, cond.Message, "allowSourceNamespaceOverride", + "the message must name the fix") +} + +// TestReconcile_DeniedSourceNamespacePublishesTheFailedTrio pins the whole kstatus verdict a +// refusal produces: Failed, under the one reason an operator greps for. +func TestReconcile_DeniedSourceNamespacePublishesTheFailedTrio(t *testing.T) { + ctx := context.Background() + f := newWRSNFixture(t, wrsnBaseObjects(nil, true, wrsnSourceNS)) + + _, err := f.reconcile(ctx) + require.NoError(t, err) + + rule := f.reloadRule(ctx, t) + for _, want := range []struct { + conditionType string + status metav1.ConditionStatus + }{ + {ConditionTypeSourceNamespaceAuthorized, metav1.ConditionFalse}, + {ConditionTypeStreamsRunning, metav1.ConditionFalse}, + {ConditionTypeReady, metav1.ConditionFalse}, + {ConditionTypeReconciling, metav1.ConditionFalse}, + {ConditionTypeStalled, metav1.ConditionTrue}, + } { + cond := wrsnCondition(t, rule, want.conditionType) + assert.Equal(t, want.status, cond.Status, "condition %s status", want.conditionType) + assert.Equal(t, WatchRuleReasonSourceNamespaceNotAllowed, cond.Reason, + "condition %s reason", want.conditionType) + assert.Equal(t, rule.Generation, cond.ObservedGeneration, + "condition %s must carry the observed generation, or a stale verdict reads as current", + want.conditionType) + } +} + +// TestReconcile_AuthorizedOverrideCompilesWithItsSourceNamespace is the grant path end to end: all +// three legs pass, and the compiled rule carries the SOURCE namespace rather than the rule's own. +func TestReconcile_AuthorizedOverrideCompilesWithItsSourceNamespace(t *testing.T) { + ctx := context.Background() + f := newWRSNFixture(t, wrsnBaseObjects( + &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{wrsnSourceNS}}, + true, wrsnSourceNS)) + + _, err := f.reconcile(ctx) + require.NoError(t, err) + + compiled := f.store.SnapshotWatchRules() + require.Len(t, compiled, 1) + assert.Equal(t, []string{wrsnSourceNS}, compiled[0].ResourceRules[0].SourceNamespaces) + assert.Equal(t, wrsnTenantNS, compiled[0].Source.Namespace) + + cond := wrsnCondition(t, f.reloadRule(ctx, t), ConditionTypeSourceNamespaceAuthorized) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + assert.Equal(t, WatchRuleReasonSourceNamespaceAllowed, cond.Reason) +} + +// TestReconcile_RevokedSourceNamespaceRemovesTheCompiledRuleAndReplans is the REVOCATION contract. +// A rule accepted and then denied by a tightened policy 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. +func TestReconcile_RevokedSourceNamespaceRemovesTheCompiledRuleAndReplans(t *testing.T) { + ctx := context.Background() + f := newWRSNFixture(t, wrsnBaseObjects( + &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{wrsnSourceNS}}, + true, wrsnSourceNS)) + + _, err := f.reconcile(ctx) + require.NoError(t, err) + require.Equal(t, []string{wrsnRule}, f.compiledNames(), "precondition: the rule is compiled") + + // The target owner tightens the policy so it no longer admits the namespace. + var target configbutleraiv1alpha3.GitTarget + require.NoError(t, f.client.Get(ctx, + k8stypes.NamespacedName{Name: wrsnTarget, Namespace: wrsnTenantNS}, &target)) + target.Spec.AllowedSourceNamespaces = &configbutleraiv1alpha3.NamespaceMatcher{ + Names: []string{"a-completely-different-namespace"}, + } + require.NoError(t, f.client.Update(ctx, &target)) + + // Observe the world at the exact moment the data plane is replanned. + var compiledAtReplan []string + f.wm.onReconcile = func() { compiledAtReplan = f.compiledNames() } + + _, err = f.reconcile(ctx) + require.NoError(t, err) + + assert.Empty(t, f.compiledNames(), "a revoked rule must be removed from the store") + assert.Empty(t, compiledAtReplan, + "the compiled rule must already be gone when the watch manager is replanned") + + cond := wrsnCondition(t, f.reloadRule(ctx, t), ConditionTypeSourceNamespaceAuthorized) + assert.Equal(t, metav1.ConditionFalse, cond.Status) +} + +// TestReconcile_DeclaredPolicyDeniesCoResidentLegacyRule is the no-self-namespace-exception rule at +// the reconciler, plus its mitigation: the denial must NAME the fix, since this is the design's +// acknowledged authoring footgun. +func TestReconcile_DeclaredPolicyDeniesCoResidentLegacyRule(t *testing.T) { + ctx := context.Background() + // A policy was added for some other namespace; this rule watches its OWN namespace. + f := newWRSNFixture(t, wrsnBaseObjects( + &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{wrsnSourceNS}}, + true, "")) + + _, err := f.reconcile(ctx) + require.NoError(t, err) + + assert.Empty(t, f.compiledNames(), "a declared policy is exhaustive, own namespace included") + + cond := wrsnCondition(t, f.reloadRule(ctx, t), ConditionTypeSourceNamespaceAuthorized) + assert.Equal(t, metav1.ConditionFalse, cond.Status) + assert.Equal(t, WatchRuleReasonSourceNamespaceNotAllowed, cond.Reason) + assert.Contains(t, cond.Message, "add it to keep watching this rule's own namespace", + "the footgun is only acceptable because the denial names the exact fix") +} + +// TestReconcile_DeclaredPolicyAdmittingOwnNamespaceCompiles is the other half: listing the rule's +// own namespace explicitly is how a legacy rule co-exists with a policy. +func TestReconcile_DeclaredPolicyAdmittingOwnNamespaceCompiles(t *testing.T) { + ctx := context.Background() + f := newWRSNFixture(t, wrsnBaseObjects( + &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{wrsnTenantNS, wrsnSourceNS}}, + true, "")) + + _, err := f.reconcile(ctx) + require.NoError(t, err) + + assert.Equal(t, []string{wrsnRule}, f.compiledNames()) + cond := wrsnCondition(t, f.reloadRule(ctx, t), ConditionTypeSourceNamespaceAuthorized) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + assert.Equal(t, WatchRuleReasonSourceNamespaceAllowed, cond.Reason) +} + +// wrsnScopeRecorder is a SourceScopeService that keeps grants exactly as the real one does — keyed +// by rule AND spec hash — and answers every selector question with a configurable verdict, so a +// test can make a policy unevaluatable without a source cluster. +type wrsnScopeRecorder struct { + grants map[k8stypes.NamespacedName]string + forgotten []k8stypes.NamespacedName + answer authz.SourceScopeResult +} + +func newWRSNScopeRecorder(answer authz.SourceScopeResult) *wrsnScopeRecorder { + return &wrsnScopeRecorder{grants: map[k8stypes.NamespacedName]string{}, answer: answer} +} + +func (s *wrsnScopeRecorder) ResolveSourceNamespace( + context.Context, *configbutleraiv1alpha3.GitTarget, string, +) authz.SourceScopeResult { + return s.answer +} + +func (s *wrsnScopeRecorder) EnumerateSourceNamespaces( + context.Context, *configbutleraiv1alpha3.GitTarget, +) ([]string, authz.SourceScopeResult) { + return nil, s.answer +} + +func (s *wrsnScopeRecorder) RetainedSourceScope( + rule k8stypes.NamespacedName, specHash string, +) ([][]string, bool) { + stored, ok := s.grants[rule] + if !ok || stored != specHash { + return nil, false + } + return [][]string{{wrsnSourceNS}}, true +} + +func (s *wrsnScopeRecorder) RecordSourceScopeGrant( + rule k8stypes.NamespacedName, specHash string, _ [][]string, +) { + s.grants[rule] = specHash +} + +func (s *wrsnScopeRecorder) ForgetSourceScopeGrant(rule k8stypes.NamespacedName) { + s.forgotten = append(s.forgotten, rule) + delete(s.grants, rule) +} + +// TestReconcile_DeletedWatchRuleForgetsItsRetainedScope closes the delete/recreate inheritance. +// +// The retained grant is the ONE thing that distinguishes a rule MAINTAINING an already-resolved +// scope from one ESTABLISHING its first — and the two branches are deliberately opposite: the first +// retains and reports Unknown, the second refuses and reports a terminal, actionable Stalled. A +// grant left behind by a deleted rule is inherited by the next rule created under that name and +// spec, which a different tenant may now own, and its unevaluatable policy then reads as +// "maintaining" forever. The rule never runs and never explains why. +// +// The recreated rule here is byte-identical to the deleted one, because that is the case the spec +// hash cannot catch — only forgetting the grant can. +func TestReconcile_DeletedWatchRuleForgetsItsRetainedScope(t *testing.T) { + ctx := context.Background() + ruleKey := k8stypes.NamespacedName{Name: wrsnRule, Namespace: wrsnTenantNS} + + f := newWRSNFixture(t, wrsnBaseObjects( + &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{wrsnSourceNS}}, true, wrsnSourceNS)) + scope := newWRSNScopeRecorder(authz.SourceScopeResult{ + Verdict: authz.SourceScopeUnavailable, + Message: "listing Namespaces is forbidden for this credential", + }) + f.wm.scope = scope + + // An exact-name policy needs no source-cluster access, so the rule compiles and records a grant. + _, err := f.reconcile(ctx) + require.NoError(t, err) + require.Equal(t, []string{wrsnRule}, f.compiledNames()) + require.Contains(t, scope.grants, ruleKey, "precondition: an admitted rule records its grant") + + // Delete it. + require.NoError(t, f.client.Delete(ctx, wrsnWatchRule(wrsnSourceNS))) + _, err = f.reconcile(ctx) + require.NoError(t, err) + require.Empty(t, f.compiledNames()) + + assert.Equal(t, []k8stypes.NamespacedName{ruleKey}, scope.forgotten, + "the deleted rule's grant must be dropped with it") + assert.NotContains(t, scope.grants, ruleKey) + + // The same name and the same spec come back — but now the target's policy is a selector that + // cannot be evaluated. With no grant of its own, this rule is ESTABLISHING. + target := &configbutleraiv1alpha3.GitTarget{} + require.NoError(t, f.client.Get(ctx, + k8stypes.NamespacedName{Name: wrsnTarget, Namespace: wrsnTenantNS}, target)) + target.Spec.AllowedSourceNamespaces = &configbutleraiv1alpha3.NamespaceMatcher{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirrorable": "true"}}, + } + require.NoError(t, f.client.Update(ctx, target)) + require.NoError(t, f.client.Create(ctx, wrsnWatchRule(wrsnSourceNS))) + + _, err = f.reconcile(ctx) + require.NoError(t, err) + + assert.Empty(t, f.compiledNames(), "an unevaluatable policy establishes nothing") + rule := f.reloadRule(ctx, t) + cond := wrsnCondition(t, rule, ConditionTypeSourceNamespaceAuthorized) + assert.Equal(t, metav1.ConditionFalse, cond.Status, + "establishing must refuse; inheriting the dead rule's grant would report Unknown instead") + assert.Equal(t, WatchRuleReasonSourceNamespacePolicyUnavailable, cond.Reason) + assert.Equal(t, metav1.ConditionTrue, wrsnCondition(t, rule, ConditionTypeStalled).Status, + "only an operator change fixes this, so it must be terminal and visible") +} + +// TestReconcile_SelectorPolicyWithNoSourceScopeIsInProgress covers the Unknown row of the status +// table: with no source-scope service wired, a selector policy is "cannot say yet". It must be +// InProgress (Reconciling=True, Stalled=False), never Failed — turning a transient into a terminal +// state is precisely what the three-valued result exists to prevent. +func TestReconcile_SelectorPolicyWithNoSourceScopeIsInProgress(t *testing.T) { + ctx := context.Background() + f := newWRSNFixture(t, wrsnBaseObjects( + &configbutleraiv1alpha3.NamespaceMatcher{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirrorable": "true"}}, + }, + true, wrsnSourceNS)) + + _, err := f.reconcile(ctx) + require.NoError(t, err) + + assert.Empty(t, f.compiledNames(), "no grant is established, so nothing runs") + + rule := f.reloadRule(ctx, t) + cond := wrsnCondition(t, rule, ConditionTypeSourceNamespaceAuthorized) + assert.Equal(t, metav1.ConditionUnknown, cond.Status) + assert.Equal(t, WatchRuleReasonCheckingSourceNamespacePolicy, cond.Reason) + + assert.Equal(t, metav1.ConditionFalse, wrsnCondition(t, rule, ConditionTypeReady).Status) + assert.Equal(t, metav1.ConditionTrue, wrsnCondition(t, rule, ConditionTypeReconciling).Status) + assert.Equal(t, metav1.ConditionFalse, wrsnCondition(t, rule, ConditionTypeStalled).Status, + "a cache that has not synced is not a stalled rule") +} + +// TestReconcile_ClusterProviderReadErrorRequeuesWithoutDenying: a transient apiserver failure must +// surface as an error the controller requeues on, and must NOT tear down an already-compiled rule. +func TestReconcile_ClusterProviderReadErrorRequeuesWithoutDenying(t *testing.T) { + ctx := context.Background() + boom := errors.New("etcdserver: request timed out") + + f := newWRSNFixture(t, wrsnBaseObjects( + &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{wrsnSourceNS}}, + true, wrsnSourceNS)) + + // Compile it once cleanly. + _, err := f.reconcile(ctx) + require.NoError(t, err) + require.Equal(t, []string{wrsnRule}, f.compiledNames()) + + // Now make the ClusterProvider read fail. + f.reconciler.Client = fake.NewClientBuilder(). + WithScheme(scScheme(t)). + WithObjects(wrsnBaseObjects( + &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{wrsnSourceNS}}, + true, wrsnSourceNS)...). + WithStatusSubresource(&configbutleraiv1alpha3.WatchRule{}). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func( + ctx context.Context, c client.WithWatch, key client.ObjectKey, + obj client.Object, opts ...client.GetOption, + ) error { + if _, ok := obj.(*configbutleraiv1alpha3.ClusterProvider); ok { + return boom + } + return c.Get(ctx, key, obj, opts...) + }, + }). + Build() + + _, err = f.reconcile(ctx) + + require.Error(t, err, "a transient read error must requeue, not silently deny") + assert.Equal(t, []string{wrsnRule}, f.compiledNames(), + "a running stream must survive an apiserver blip") +} + +// TestReconcile_MixedItemsCompileTheirOwnScopes is the point of moving the field onto the items, +// asserted through the real reconciler: one rule can follow one type in its own namespace and +// another in a different, admitted one, and the compiled rule carries both scopes independently. +func TestReconcile_MixedItemsCompileTheirOwnScopes(t *testing.T) { + ctx := context.Background() + f := newWRSNFixture(t, wrsnBaseObjects( + &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{wrsnTenantNS, wrsnSourceNS}}, + true, "", wrsnSourceNS, configbutleraiv1alpha3.SourceNamespaceWildcard)) + + _, err := f.reconcile(ctx) + require.NoError(t, err) + + compiled := f.store.SnapshotWatchRules() + require.Len(t, compiled, 1) + require.Len(t, compiled[0].ResourceRules, 3) + assert.Equal(t, []string{wrsnTenantNS}, compiled[0].ResourceRules[0].SourceNamespaces, + "an omitted item resolves to the rule's own namespace") + assert.Equal(t, []string{wrsnSourceNS}, compiled[0].ResourceRules[1].SourceNamespaces, + "an explicit item resolves to exactly what it named") + assert.Equal(t, []string{wrsnSourceNS, wrsnTenantNS}, compiled[0].ResourceRules[2].SourceNamespaces, + `"*" expands to the target's whole admitted set`) + + cond := wrsnCondition(t, f.reloadRule(ctx, t), ConditionTypeSourceNamespaceAuthorized) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + assert.Equal(t, WatchRuleReasonSourceNamespaceAllowed, cond.Reason) +} + +// TestReconcile_DeniedItemRefusesTheWholeRule is decision 5 at the reconciler: a denied explicit +// item is never trimmed away so the other items can run. Mirroring two of the three namespaces a +// 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() + f := newWRSNFixture(t, wrsnBaseObjects( + &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{wrsnTenantNS, wrsnSourceNS}}, + true, "", wrsnSourceNS, "tenant-zen")) + + _, err := f.reconcile(ctx) + require.NoError(t, err) + + assert.Empty(t, f.compiledNames(), + "one denied item stops the WHOLE rule; a partial mirror is worse than a loud failure") + + cond := wrsnCondition(t, f.reloadRule(ctx, t), ConditionTypeSourceNamespaceAuthorized) + assert.Equal(t, metav1.ConditionFalse, cond.Status) + assert.Equal(t, WatchRuleReasonSourceNamespaceNotAllowed, cond.Reason) + assert.Contains(t, cond.Message, "spec.rules[2]", "the message names the failing item by index...") + assert.Contains(t, cond.Message, "tenant-zen", "...and by what it asked for") +} + +// TestReconcile_EmptyWildcardIsAuthorizedButNotSilentlyHealthy is the other half of decision 5. A +// "*" against a policy that currently admits nothing is valid — the rule is NOT stalled — but it +// mirrors nothing, and a rule that mirrors nothing while reporting Ready=True with no explanation is +// a silent no-op. The reason is what makes it visible. +func TestReconcile_EmptyWildcardIsAuthorizedButNotSilentlyHealthy(t *testing.T) { + ctx := context.Background() + f := newWRSNFixture(t, wrsnBaseObjects( + &configbutleraiv1alpha3.NamespaceMatcher{}, // declared, and admits nothing + true, configbutleraiv1alpha3.SourceNamespaceWildcard)) + + _, err := f.reconcile(ctx) + require.NoError(t, err) + + compiled := f.store.SnapshotWatchRules() + require.Len(t, compiled, 1, "an empty admitted set is not a refusal: the rule still compiles") + assert.Empty(t, compiled[0].ResourceRules[0].SourceNamespaces, + "...but it watches nothing, rather than falling back to a wider scope") + + rule := f.reloadRule(ctx, t) + cond := wrsnCondition(t, rule, ConditionTypeSourceNamespaceAuthorized) + assert.Equal(t, metav1.ConditionTrue, cond.Status) + assert.Equal(t, WatchRuleReasonNoAdmittedSourceNamespaces, cond.Reason) + assert.Equal(t, metav1.ConditionFalse, wrsnCondition(t, rule, ConditionTypeStalled).Status, + "a rule with nothing to watch is not stalled — nothing is wrong with it") +} diff --git a/internal/manifestanalyzer/placement.go b/internal/manifestanalyzer/placement.go index dd828e37..509ccece 100644 --- a/internal/manifestanalyzer/placement.go +++ b/internal/manifestanalyzer/placement.go @@ -784,9 +784,10 @@ func cohortDestination( // weighted by member count); every file holding exactly one document contributes to // the singleton-style candidate. A tainted file (fileIsAppendSafe false) is // excluded from both. namespaceAgnostic applies the P4 safety rule (see -// resolveInferred): a bundle must already span more than one namespace, and -// singleton style must resolve to a single shared directory, or the candidate is -// dropped. It returns the eligible singleton directories, the winning bundle +// resolveInferred): a candidate must PROVE it is namespace-agnostic by already +// spanning more than one namespace — a bundle through its own documents, singleton +// style through the documents of its one shared directory — or it is dropped. +// It returns the eligible singleton directories, the winning bundle // (path/count), and one representative document per singleton directory / bundle // path — used to decide whether the destination's namespace is inherited from // build context (see PlacementResult.NamespaceInherited) — determined @@ -797,6 +798,7 @@ func classifyCohortLocations( namespaceAgnostic bool, ) ([]string, string, int, map[string]*DocumentModel, map[string]*DocumentModel) { var singletonDirs []string + var singletonDocs []*DocumentModel dirReps := map[string]*DocumentModel{} bundleReps := map[string]*DocumentModel{} bundleCounts := map[string]int{} @@ -815,11 +817,19 @@ func classifyCohortLocations( } dir := slashDir(p) singletonDirs = append(singletonDirs, dir) + singletonDocs = append(singletonDocs, ms...) if _, seen := dirReps[dir]; !seen { dirReps[dir] = ms[0] } } - if namespaceAgnostic && !allSameDir(singletonDirs) { + // P4 for singleton style, symmetric with the bundle branch above: reuse needs POSITIVE + // proof of namespace-agnosticism, not merely the absence of contrary evidence. One + // directory holding one namespace's documents is exactly as consistent with a + // per-namespace-segmented layout whose second namespace has simply never been written as + // it is with a shared directory — and guessing wrong files another namespace's object + // under the first namespace's folder. Declining costs nothing: the canonical path builds + // the correct namespace segment directly. + if namespaceAgnostic && (!allSameDir(singletonDirs) || !spansMultipleNamespaces(singletonDocs)) { singletonDirs = nil // unproven: directories look namespace-segmented (P4) } diff --git a/internal/manifestanalyzer/placement_test.go b/internal/manifestanalyzer/placement_test.go index 8571b6c1..445c4664 100644 --- a/internal/manifestanalyzer/placement_test.go +++ b/internal/manifestanalyzer/placement_test.go @@ -393,6 +393,75 @@ func TestLocateNew_Step2_NewNamespaceUnderPerNamespaceDirectories_FallsToCanonic } } +func TestLocateNew_Step2_NewNamespaceUnderTheOnlyExistingDirectory_FallsToCanonical(t *testing.T) { + // ONE directory holding ONE namespace is the case that used to slip through: "all the + // singleton directories agree" is trivially true of a single directory, so a + // per-namespace-segmented layout whose second namespace had simply never been written + // was indistinguishable from a shared one — and the new namespace's object was filed + // under the first namespace's folder. Absence of contrary evidence is not proof. + fsys := fstest.MapFS{ + "ns1/configmap-a.yaml": {Data: []byte(configMapYAML("a", "ns1"))}, + } + store := placementStore(t, fsys) + req := newConfigMapRequest("cache", "ns2") + + res, err := LocateNew(store, nil, req) + if err != nil { + t.Fatalf("LocateNew: %v", err) + } + if res.Path != req.Identifier.ToGitPath() || res.Source != PlacementSourceCanonical { + t.Fatalf("got %+v, want canonical fallback, never ns1/ for a resource in ns2", res) + } +} + +func TestLocateNew_Step2_SameNameInANewNamespace_IsNeverAppendedOntoTheFirstNamespacesFile(t *testing.T) { + // The production shape of the bug. Objects that exist under the SAME NAME in every + // namespace (kube-root-ca.crt is in all of them) made the inferred path collide exactly + // with the first namespace's file, so the second namespace's object was appended as an + // extra document. That file then genuinely spanned two namespaces, so every later object + // of the type legitimately preferred the bundle and the whole type collapsed into one + // file — one wrong inference cascading into total collapse. + fsys := fstest.MapFS{ + "ns1/configmaps/kube-root-ca.crt.yaml": {Data: []byte(configMapYAML("kube-root-ca.crt", "ns1"))}, + } + store := placementStore(t, fsys) + req := newConfigMapRequest("kube-root-ca.crt", "ns2") + + res, err := LocateNew(store, nil, req) + if err != nil { + t.Fatalf("LocateNew: %v", err) + } + if res.Append { + t.Fatalf("got %+v, want a distinct file; appending merges two namespaces' objects", res) + } + if res.Path == "ns1/configmaps/kube-root-ca.crt.yaml" { + t.Fatalf("ns2's object was filed onto ns1's own file: %+v", res) + } + if res.Path != req.Identifier.ToGitPath() || res.Source != PlacementSourceCanonical { + t.Fatalf("got %+v, want canonical fallback carrying ns2's own namespace segment", res) + } +} + +func TestLocateNew_Step2_ProvenNamespaceAgnosticDirectory_IsStillReused(t *testing.T) { + // The other side of the rule: one shared directory that ALREADY holds more than one + // namespace has proven itself namespace-agnostic, so a third namespace still joins it. + // Requiring proof must not degrade into refusing every singleton-style layout. + fsys := fstest.MapFS{ + "shared/configmap-a.yaml": {Data: []byte(configMapYAML("a", "ns1"))}, + "shared/configmap-b.yaml": {Data: []byte(configMapYAML("b", "ns2"))}, + } + store := placementStore(t, fsys) + req := newConfigMapRequest("cache", "ns3") + + res, err := LocateNew(store, nil, req) + if err != nil { + t.Fatalf("LocateNew: %v", err) + } + if res.Path != "shared/cache.yaml" || res.Source != PlacementSourceInferred { + t.Fatalf("got %+v, want the proven shared directory reused for the new namespace", res) + } +} + func TestLocateNew_SensitiveCollision_Errors(t *testing.T) { // The existing file already occupies exactly the path the declared template // will render for the new resource (a misconfigured template lacking {name} diff --git a/internal/rulestore/store.go b/internal/rulestore/store.go index ead71758..bdf338eb 100644 --- a/internal/rulestore/store.go +++ b/internal/rulestore/store.go @@ -48,6 +48,23 @@ type CompiledResourceRule struct { APIVersions []string // Resources specifies which resource types this rule matches. Resources []string + + // SourceNamespaces is this item's RESOLVED source-namespace set IN THE SOURCE CLUSTER — + // spec.rules[i].sourceNamespace fully expanded to concrete names at compile time. Neither a + // wildcard nor a policy reference ever survives into the data plane. + // + // It is per item, and separate from Source.Namespace, because the two are genuinely different + // namespaces in (potentially) different clusters: Source names the WatchRule OBJECT in the + // control plane, while these name the namespaces whose objects are mirrored. They coincide + // only for a legacy item. Every watch-planning consumer — the watched-type selection, the + // stream roll-up, and the fingerprint that decides whether that table is re-projected — must + // read THIS field; reading Source.Namespace instead yields a stale watch, not an error. + // + // An EMPTY set is a legitimate resolved answer for a wildcard whose policy currently admits + // nothing: the item watches nothing. It is never a stand-in for "could not resolve" — an + // unevaluatable policy stops the rule from compiling at all, because an empty set that reached + // here would be the input to a resync sweep. + SourceNamespaces []string } // CompiledClusterRule represents a fully processed ClusterWatchRule, ready for quick lookups. @@ -69,7 +86,12 @@ type CompiledClusterRule struct { Rules []CompiledClusterResourceRule } -// CompiledClusterResourceRule represents a single cluster resource rule with scope. +// CompiledClusterResourceRule represents a single cluster resource rule. +// +// It carries no scope: a ClusterWatchRule is cluster-scope-only, so resolution always matches +// cluster-scoped records and a stored scope other than Cluster is refused at compile time. Keeping +// a scope field here would let a pruned or absent value widen a stream, which is the failure the +// narrowing exists to prevent. type CompiledClusterResourceRule struct { // Operations specifies which operations trigger this rule. Operations []configv1alpha3.OperationType @@ -79,8 +101,6 @@ type CompiledClusterResourceRule struct { APIVersions []string // Resources specifies which resource types this rule matches. Resources []string - // Scope indicates whether this rule watches Cluster or Namespaced resources. - Scope configv1alpha3.ResourceScope } // RuleStore holds the in-memory representation of all active watch rules. @@ -102,8 +122,18 @@ func NewStore() *RuleStore { // AddOrUpdateWatchRule adds or updates a WatchRule with a resolved target from GitTarget. // The chain is: WatchRule -> GitTarget -> GitProvider +// +// The whole compiled rule — including every item's resolved source-namespace set — is replaced +// ATOMICALLY. Nothing per-item survives a spec change, which is what lets rule items have no stable +// API identity: no state outlives the spec that produced it, so a reorder cannot make one item +// inherit another's grant. +// // Parameters: // - rule: the WatchRule to add or update +// - sourceNamespaces: the resolved source-namespace set PER rule item, index-aligned with +// rule.Spec.Rules. A shorter slice leaves the remaining items with no namespaces, which is a +// compile bug rather than a scope: callers must resolve every item (see +// authz.ResolveWatchRuleSourceScope). // - gitTargetName: the name of the GitTarget // - gitTargetNamespace: the namespace containing the GitTarget // - gitProviderName: the name of the resolved GitProvider (from GitTarget.Spec.Provider) @@ -112,6 +142,7 @@ func NewStore() *RuleStore { // - path: POSIX-like relative path prefix for writes (from GitTarget.Spec.Path, sanitized upstream) func (s *RuleStore) AddOrUpdateWatchRule( rule configv1alpha3.WatchRule, + sourceNamespaces [][]string, gitTargetName string, gitTargetNamespace string, gitProviderName string, @@ -139,18 +170,39 @@ func (s *RuleStore) AddOrUpdateWatchRule( ResourceRules: make([]CompiledResourceRule, 0, len(rule.Spec.Rules)), } - for _, r := range rule.Spec.Rules { + for i, r := range rule.Spec.Rules { + var namespaces []string + if i < len(sourceNamespaces) { + namespaces = append([]string(nil), sourceNamespaces[i]...) + } compiled.ResourceRules = append(compiled.ResourceRules, CompiledResourceRule{ - Operations: r.Operations, - APIGroups: r.APIGroups, - APIVersions: r.APIVersions, - Resources: r.Resources, + Operations: r.Operations, + APIGroups: r.APIGroups, + APIVersions: r.APIVersions, + Resources: r.Resources, + SourceNamespaces: namespaces, }) } s.rules[key] = compiled } +// GetWatchRule returns a compiled WatchRule by key, and whether it is compiled at all. +// +// The status roll-up reads it because a WatchRule's watched namespaces can no longer be derived +// from its spec: a "*" item's set exists only after resolution, so a roll-up computed from the spec +// would look for streams under keys that were never opened and report a healthy wildcard rule as +// permanently not-ready. +func (s *RuleStore) GetWatchRule(key types.NamespacedName) (CompiledRule, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + rule, ok := s.rules[key] + if !ok { + return CompiledRule{}, false + } + return deepCopyCompiledRule(rule), true +} + // AddOrUpdateClusterWatchRule adds or updates a ClusterWatchRule with a resolved target from GitTarget. // The chain is: ClusterWatchRule -> GitTarget -> GitProvider // Parameters: @@ -195,7 +247,6 @@ func (s *RuleStore) AddOrUpdateClusterWatchRule( APIGroups: r.APIGroups, APIVersions: r.APIVersions, Resources: r.Resources, - Scope: r.Scope, }) } @@ -251,6 +302,11 @@ func (s *RuleStore) GetMatchingRules( s.mu.RLock() defer s.mu.RUnlock() + eventNamespace := "" + if obj != nil { + eventNamespace = obj.GetNamespace() + } + var matchingRules []CompiledRule for _, rule := range s.rules { // First check: Does rule scope match resource scope? @@ -258,11 +314,7 @@ func (s *RuleStore) GetMatchingRules( continue // WatchRule can't match cluster resources } - if !isClusterScoped && obj != nil && obj.GetNamespace() != "" && rule.Source.Namespace != obj.GetNamespace() { - continue - } - - if rule.matches(resourcePlural, operation, apiGroup, apiVersion) { + if rule.matches(eventNamespace, resourcePlural, operation, apiGroup, apiVersion) { matchingRules = append(matchingRules, rule) } } @@ -306,6 +358,11 @@ func (s *RuleStore) GetMatchingClusterRules( } // clusterRuleMatches checks if a cluster rule matches the given criteria. +// +// A ClusterWatchRule is cluster-scope-only, so a NAMESPACED object never matches one — whatever a +// stored (or pruned) scope value might say. Keying on the object's discovered scope rather than on +// the rule's stored one is the third enforcement point: even if admission and compile were both +// bypassed, resolution cannot widen a stream. func (s *RuleStore) clusterRuleMatches( clusterRule CompiledClusterRule, resourcePlural string, @@ -314,32 +371,24 @@ func (s *RuleStore) clusterRuleMatches( apiVersion string, isClusterScoped bool, ) bool { + if !isClusterScoped { + return false + } for _, rule := range clusterRule.Rules { - if s.ruleMatchesScope(rule, isClusterScoped) && - rule.matchesCluster(resourcePlural, operation, apiGroup, apiVersion) { + if rule.matchesCluster(resourcePlural, operation, apiGroup, apiVersion) { return true } } return false } -// ruleMatchesScope checks if a rule's scope matches the resource scope. -// Simplified MVP: Namespaced scope matches all namespaces (no NamespaceSelector). -func (s *RuleStore) ruleMatchesScope( - rule CompiledClusterResourceRule, - isClusterScoped bool, -) bool { - // For cluster-scoped resources, only match Cluster scope rules - if isClusterScoped { - return rule.Scope == configv1alpha3.ResourceScopeCluster - } - - // For namespaced resources, match Namespaced scope (all namespaces) - return rule.Scope == configv1alpha3.ResourceScopeNamespaced -} - // matches checks if a single rule matches the given filters. +// +// The namespace is matched PER ITEM, not once per rule: each item resolved its own source-namespace +// set, so a rule can legitimately follow configmaps in its own namespace and secrets in another. +// Matching the rule object's namespace instead would drop every event an override asked for. func (r *CompiledRule) matches( + eventNamespace string, resourcePlural string, operation configv1alpha3.OperationType, apiGroup string, @@ -347,7 +396,7 @@ func (r *CompiledRule) matches( ) bool { // Check if any resource rule matches (logical OR) for _, rule := range r.ResourceRules { - if rule.matches(resourcePlural, operation, apiGroup, apiVersion) { + if rule.matches(eventNamespace, resourcePlural, operation, apiGroup, apiVersion) { return true } } @@ -357,11 +406,18 @@ func (r *CompiledRule) matches( // matches checks if a resource rule matches the given filters. func (r *CompiledResourceRule) matches( + eventNamespace string, resourcePlural string, operation configv1alpha3.OperationType, apiGroup string, apiVersion string, ) bool { + // Match the resolved source-namespace set (a cluster-scoped or namespace-less event is left to + // the caller's scope check). + if !r.matchesSourceNamespace(eventNamespace) { + return false + } + // Match operations (empty = match all) if !r.matchesOperations(operation) { return false @@ -381,6 +437,20 @@ func (r *CompiledResourceRule) matches( return r.resourceMatches(resourcePlural) } +// matchesSourceNamespace checks the event's namespace against this item's RESOLVED set. An event +// with no namespace is left to the caller's cluster-scope check rather than being filtered here. +func (r *CompiledResourceRule) matchesSourceNamespace(eventNamespace string) bool { + if eventNamespace == "" { + return true + } + for _, ns := range r.SourceNamespaces { + if ns == eventNamespace { + return true + } + } + return false +} + // matchesOperations checks if the operation matches any in the rule. func (r *CompiledResourceRule) matchesOperations(operation configv1alpha3.OperationType) bool { if len(r.Operations) == 0 { @@ -602,6 +672,10 @@ func deepCopyCompiledRule(in CompiledRule) CompiledRule { if len(in.ResourceRules) > 0 { cp.ResourceRules = make([]CompiledResourceRule, len(in.ResourceRules)) copy(cp.ResourceRules, in.ResourceRules) + for i := range cp.ResourceRules { + cp.ResourceRules[i].SourceNamespaces = + append([]string(nil), in.ResourceRules[i].SourceNamespaces...) + } } return cp } diff --git a/internal/rulestore/store_test.go b/internal/rulestore/store_test.go index baeec8cf..0ad1be99 100644 --- a/internal/rulestore/store_test.go +++ b/internal/rulestore/store_test.go @@ -13,6 +13,17 @@ import ( configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" ) +// ownNamespaceScope is the resolved scope a WatchRule whose items set no sourceNamespace compiles +// to: every item watches the rule's OWN namespace. Tests that are not about the source-namespace +// gate use it so the compiled rule looks exactly as watch.CompileWatchRule would leave it. +func ownNamespaceScope(rule configv1alpha3.WatchRule) [][]string { + out := make([][]string, len(rule.Spec.Rules)) + for i := range rule.Spec.Rules { + out[i] = []string{rule.Spec.Rules[i].EffectiveSourceNamespace(rule.Namespace)} + } + return out +} + // TestNewStore verifies that NewStore creates an initialized store. func TestNewStore(t *testing.T) { store := NewStore() @@ -49,6 +60,7 @@ func TestAddOrUpdateWatchRule(t *testing.T) { // Add rule store.AddOrUpdateWatchRule( rule, + ownNamespaceScope(rule), "test-target", "default", "test-provider", @@ -90,6 +102,7 @@ func TestAddOrUpdateWatchRule(t *testing.T) { // Update rule with different values store.AddOrUpdateWatchRule( rule, + ownNamespaceScope(rule), "test-target", "default", "updated-provider", @@ -166,8 +179,8 @@ func TestAddOrUpdateClusterWatchRule(t *testing.T) { if len(compiled.Rules) != 1 { t.Errorf("Expected 1 rule, got %d", len(compiled.Rules)) } - if compiled.Rules[0].Scope != configv1alpha3.ResourceScopeCluster { - t.Errorf("Scope mismatch: got %v, want %v", compiled.Rules[0].Scope, configv1alpha3.ResourceScopeCluster) + if compiled.Rules[0].Resources[0] != "nodes" { + t.Errorf("Resources mismatch: got %v, want [nodes]", compiled.Rules[0].Resources) } } @@ -188,7 +201,8 @@ func TestDelete(t *testing.T) { key := types.NamespacedName{Name: "delete-test", Namespace: "default"} // Add rule - store.AddOrUpdateWatchRule(rule, "test-dest", "default", "test-repo", "gitops-system", "main", "test") + store.AddOrUpdateWatchRule( + rule, ownNamespaceScope(rule), "test-dest", "default", "test-repo", "gitops-system", "main", "test") // Verify it exists if _, exists := store.rules[key]; !exists { @@ -274,8 +288,10 @@ func TestGetMatchingRules(t *testing.T) { rule2.Name = "deployment-rule" rule2.Namespace = "default" - store.AddOrUpdateWatchRule(rule1, "dest1", "default", "repo1", "gitops-system", "main", "test1") - store.AddOrUpdateWatchRule(rule2, "dest2", "default", "repo2", "gitops-system", "main", "test2") + store.AddOrUpdateWatchRule( + rule1, ownNamespaceScope(rule1), "dest1", "default", "repo1", "gitops-system", "main", "test1") + store.AddOrUpdateWatchRule( + rule2, ownNamespaceScope(rule2), "dest2", "default", "repo2", "gitops-system", "main", "test2") tests := []struct { name string @@ -411,12 +427,15 @@ func TestGetMatchingRules_OverlappingRulesUnionOperations(t *testing.T) { // second CREATE rule to prove matches are additive rather than first-wins. store.AddOrUpdateWatchRule( podsRule("pod-create", configv1alpha3.OperationCreate), + ownNamespaceScope(podsRule("pod-create", configv1alpha3.OperationCreate)), "dest-a", "default", "repo", "gitops-system", "main", "a") store.AddOrUpdateWatchRule( podsRule("pod-update", configv1alpha3.OperationUpdate), + ownNamespaceScope(podsRule("pod-update", configv1alpha3.OperationUpdate)), "dest-a", "default", "repo", "gitops-system", "main", "a") store.AddOrUpdateWatchRule( podsRule("pod-create-2", configv1alpha3.OperationCreate), + ownNamespaceScope(podsRule("pod-create-2", configv1alpha3.OperationCreate)), "dest-b", "default", "repo", "gitops-system", "main", "b") tests := []struct { @@ -486,11 +505,14 @@ func TestRuleStore_Readiness(t *testing.T) { } } -// TestGetMatchingClusterRules verifies cluster rule matching. +// TestGetMatchingClusterRules verifies cluster rule matching — and, since PR 4, that a +// ClusterWatchRule never matches a NAMESPACED object. That is the third enforcement point of the +// cluster-scope-only narrowing: admission rejects `scope: Namespaced` and the shared compile path +// refuses a stored one, so keying resolution on the OBJECT's discovered scope means even a rule +// that somehow got into the store cannot widen a stream. func TestGetMatchingClusterRules(t *testing.T) { store := NewStore() - // Cluster-scoped rule clusterRule := configv1alpha3.ClusterWatchRule{ Spec: configv1alpha3.ClusterWatchRuleSpec{ Rules: []configv1alpha3.ClusterResourceRule{ @@ -506,8 +528,9 @@ func TestGetMatchingClusterRules(t *testing.T) { } clusterRule.Name = "node-rule" - // Namespaced rule in cluster watch rule - namespacedRule := configv1alpha3.ClusterWatchRule{ + // A rule selecting a NAMESPACED resource plural. Its selector is legal (a `resources: ["*"]` + // rule selects namespaced plurals too), but no namespaced object may ever match it. + podRule := configv1alpha3.ClusterWatchRule{ Spec: configv1alpha3.ClusterWatchRuleSpec{ Rules: []configv1alpha3.ClusterResourceRule{ { @@ -515,12 +538,11 @@ func TestGetMatchingClusterRules(t *testing.T) { APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"pods"}, - Scope: configv1alpha3.ResourceScopeNamespaced, }, }, }, } - namespacedRule.Name = "pod-cluster-rule" + podRule.Name = "pod-cluster-rule" store.AddOrUpdateClusterWatchRule( clusterRule, @@ -532,7 +554,7 @@ func TestGetMatchingClusterRules(t *testing.T) { "cluster", ) store.AddOrUpdateClusterWatchRule( - namespacedRule, + podRule, "dest2", "gitops-system", "repo2", @@ -562,14 +584,14 @@ func TestGetMatchingClusterRules(t *testing.T) { expectedNames: []string{"node-rule"}, }, { - name: "Match namespaced pods via cluster rule", + name: "No match: a namespaced object never matches a ClusterWatchRule", resourcePlural: "pods", operation: configv1alpha3.OperationUpdate, apiGroup: "", apiVersion: "v1", isClusterScoped: false, - expectedCount: 1, - expectedNames: []string{"pod-cluster-rule"}, + expectedCount: 0, + expectedNames: []string{}, }, { name: "No match: cluster rule doesn't match namespaced scope", @@ -789,8 +811,10 @@ func TestSnapshotWatchRules(t *testing.T) { rule2.Name = "rule2" rule2.Namespace = "default" - store.AddOrUpdateWatchRule(rule1, "dest1", "default", "repo1", "gitops-system", "main", "test1") - store.AddOrUpdateWatchRule(rule2, "dest2", "default", "repo2", "gitops-system", "main", "test2") + store.AddOrUpdateWatchRule( + rule1, ownNamespaceScope(rule1), "dest1", "default", "repo1", "gitops-system", "main", "test1") + store.AddOrUpdateWatchRule( + rule2, ownNamespaceScope(rule2), "dest2", "default", "repo2", "gitops-system", "main", "test2") // Get snapshot snapshot := store.SnapshotWatchRules() @@ -867,7 +891,8 @@ func TestConcurrentAccess(t *testing.T) { rule.Name = "concurrent-rule" rule.Namespace = "default" - store.AddOrUpdateWatchRule(rule, "dest", "default", "repo", "gitops-system", "main", "test") + store.AddOrUpdateWatchRule( + rule, ownNamespaceScope(rule), "dest", "default", "repo", "gitops-system", "main", "test") } }(i) } @@ -914,7 +939,8 @@ func TestMultipleResourceRules(t *testing.T) { rule.Name = "multi-rule" rule.Namespace = "default" - store.AddOrUpdateWatchRule(rule, "dest", "default", "repo", "gitops-system", "main", "test") + store.AddOrUpdateWatchRule( + rule, ownNamespaceScope(rule), "dest", "default", "repo", "gitops-system", "main", "test") // Should match pod CREATE matches := store.GetMatchingRules(nil, "pods", configv1alpha3.OperationCreate, "", "v1", false) @@ -955,7 +981,9 @@ func TestGetMatchingRules_MustFilterByNamespaceForNamespacedWatchRule(t *testing } rule.Name = "playground-wr" rule.Namespace = "tilt-playground" - store.AddOrUpdateWatchRule(rule, "target", "tilt-playground", "provider", "tilt-playground", "main", "live") + store.AddOrUpdateWatchRule( + rule, ownNamespaceScope(rule), + "target", "tilt-playground", "provider", "tilt-playground", "main", "live") sameNSObject := &unstructured.Unstructured{} sameNSObject.SetNamespace("tilt-playground") @@ -992,7 +1020,8 @@ func TestGetMatchingRules_NamespacedWatchRule_NamespaceContract(t *testing.T) { } rule.Name = "ns-wr" rule.Namespace = "tilt-playground" - store.AddOrUpdateWatchRule(rule, "tgt", "tilt-playground", "prov", "tilt-playground", "main", "live") + store.AddOrUpdateWatchRule( + rule, ownNamespaceScope(rule), "tgt", "tilt-playground", "prov", "tilt-playground", "main", "live") store.AddOrUpdateClusterWatchRule( configv1alpha3.ClusterWatchRule{ @@ -1004,7 +1033,6 @@ func TestGetMatchingRules_NamespacedWatchRule_NamespaceContract(t *testing.T) { APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"services"}, - Scope: configv1alpha3.ResourceScopeNamespaced, }}, }, }, @@ -1031,7 +1059,7 @@ func TestGetMatchingRules_NamespacedWatchRule_NamespaceContract(t *testing.T) { } }) - t.Run("cluster-scoped rule still matches any namespace", func(t *testing.T) { + t.Run("a ClusterWatchRule never matches a namespaced object", func(t *testing.T) { matches := store.GetMatchingClusterRules( "services", configv1alpha3.OperationCreate, @@ -1040,8 +1068,101 @@ func TestGetMatchingRules_NamespacedWatchRule_NamespaceContract(t *testing.T) { false, nil, ) - if len(matches) != 1 { - t.Fatalf("expected ClusterWatchRule match to return 1 rule, got %d", len(matches)) + if len(matches) != 0 { + t.Fatalf("expected ClusterWatchRule match to return 0 rules for a namespaced object, got %d", + len(matches)) } }) } + +// TestGetMatchingRules_PerItemSourceNamespaces is the per-item half of the matcher contract: one +// WatchRule can follow one type in its own namespace and another in a different, admitted one, so +// the namespace filter belongs on the ITEM's resolved set rather than on the rule object. +func TestGetMatchingRules_PerItemSourceNamespaces(t *testing.T) { + store := NewStore() + + rule := configv1alpha3.WatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: "mixed", Namespace: "tenant-acme"}, + Spec: configv1alpha3.WatchRuleSpec{ + Rules: []configv1alpha3.ResourceRule{ + {APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"configmaps"}}, + { + APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"secrets"}, + SourceNamespace: "repo-config", + }, + }, + }, + } + store.AddOrUpdateWatchRule( + rule, + [][]string{{"tenant-acme"}, {"repo-config"}}, + "tgt", "tenant-acme", "prov", "tenant-acme", "main", "live", + ) + + object := func(namespace string) *unstructured.Unstructured { + obj := &unstructured.Unstructured{} + obj.SetNamespace(namespace) + return obj + } + + cases := []struct { + name string + namespace string + resource string + want int + }{ + {"omitted item matches its own namespace", "tenant-acme", "configmaps", 1}, + {"omitted item does NOT reach the overridden namespace", "repo-config", "configmaps", 0}, + {"overriding item matches its named namespace", "repo-config", "secrets", 1}, + {"overriding item does NOT fall back to the rule's own namespace", "tenant-acme", "secrets", 0}, + {"neither item reaches an unrelated namespace", "tenant-zen", "configmaps", 0}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + matches := store.GetMatchingRules( + object(tc.namespace), tc.resource, configv1alpha3.OperationCreate, "", "v1", false) + if len(matches) != tc.want { + t.Fatalf("expected %d matches, got %d", tc.want, len(matches)) + } + }) + } +} + +// TestGetMatchingRules_WildcardItemMatchesEveryResolvedNamespace covers the expanded shape: a "*" +// item compiles to a concrete list, and every namespace in it matches while nothing outside does. +func TestGetMatchingRules_WildcardItemMatchesEveryResolvedNamespace(t *testing.T) { + store := NewStore() + + rule := configv1alpha3.WatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: "wild", Namespace: "tenant-acme"}, + Spec: configv1alpha3.WatchRuleSpec{ + Rules: []configv1alpha3.ResourceRule{{ + APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"configmaps"}, + SourceNamespace: configv1alpha3.SourceNamespaceWildcard, + }}, + }, + } + store.AddOrUpdateWatchRule( + rule, + [][]string{{"repo-config", "team-payments"}}, + "tgt", "tenant-acme", "prov", "tenant-acme", "main", "live", + ) + + for _, ns := range []string{"repo-config", "team-payments"} { + obj := &unstructured.Unstructured{} + obj.SetNamespace(ns) + if got := store.GetMatchingRules( + obj, "configmaps", configv1alpha3.OperationCreate, "", "v1", false); len(got) != 1 { + t.Fatalf("expected the wildcard item to match %s, got %d matches", ns, len(got)) + } + } + + obj := &unstructured.Unstructured{} + obj.SetNamespace("tenant-acme") + if got := store.GetMatchingRules( + obj, "configmaps", configv1alpha3.OperationCreate, "", "v1", false); len(got) != 0 { + t.Fatalf("a wildcard resolves to the ADMITTED set only; the rule's own namespace is not "+ + "implicitly included, got %d matches", len(got)) + } +} diff --git a/internal/watch/api_resource_catalog_test.go b/internal/watch/api_resource_catalog_test.go index ebf364f2..76055af7 100644 --- a/internal/watch/api_resource_catalog_test.go +++ b/internal/watch/api_resource_catalog_test.go @@ -132,7 +132,7 @@ func TestAPIResourceCatalog_PartialRefreshPreservesFailedGroupVersion(t *testing // request a concrete informer for a resource trusted discovery does not serve. func TestNotServedResourceProducesNoGVR(t *testing.T) { store := rulestore.NewStore() - store.AddOrUpdateWatchRule(configv1alpha3.WatchRule{ + rule := configv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "missing-gvr-rule", Namespace: "default"}, Spec: configv1alpha3.WatchRuleSpec{ Rules: []configv1alpha3.ResourceRule{{ @@ -141,7 +141,9 @@ func TestNotServedResourceProducesNoGVR(t *testing.T) { Resources: []string{"customresources"}, }}, }, - }, "target", "default", "provider", "default", "main", "live") + } + store.AddOrUpdateWatchRule( + rule, ownNamespaceScope(rule), "target", "default", "provider", "default", "main", "live") manager := &Manager{RuleStore: store, resourceCatalog: newCommonTestCatalog(t)} assert.Empty(t, manager.ComputeRequestedGVRs()) diff --git a/internal/watch/bootstrap.go b/internal/watch/bootstrap.go index f024ebe5..ec396065 100644 --- a/internal/watch/bootstrap.go +++ b/internal/watch/bootstrap.go @@ -10,7 +10,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" - "github.com/ConfigButler/gitops-reverser/internal/authz" ) // bootstrapRuleStore loads existing WatchRule and ClusterWatchRule objects into the in-memory RuleStore @@ -58,13 +57,21 @@ func (m *Manager) bootstrapWatchRule(ctx context.Context, rule configv1alpha3.Wa return err } - m.RuleStore.AddOrUpdateWatchRule( - rule, - target.Name, target.Namespace, - provider.Name, provider.Namespace, - target.Spec.Branch, - target.Spec.Path, - ) + // Route through the SHARED gated compile path, never straight at the store. Bootstrap runs + // BEFORE the first reconcile on every restart, so a source-namespace gate the reconciler alone + // enforced would be bypassed for the whole startup window — long enough to compile a denied + // override and watch a namespace the policy refuses. A denial is not fatal to startup: the + // rule is simply left out of the store (bootstrap has no controllers yet and cannot publish + // status), and the first reconcile re-decides and writes the terminal condition. + resolved, err := CompileWatchRule(ctx, m.Client, m.RuleStore, m, rule, target, provider) + if err != nil { + return fmt.Errorf("evaluating source-namespace authorization for WatchRule %s/%s: %w", + rule.Namespace, rule.Name, err) + } + if !resolved.Admitted() { + return fmt.Errorf("WatchRule %s/%s source-namespace scope was not authorized: %s", + rule.Namespace, rule.Name, resolved.Message) + } return nil } @@ -80,30 +87,21 @@ func (m *Manager) bootstrapClusterWatchRule(ctx context.Context, rule configv1al return err } - // Re-apply the referenced GitTarget's ClusterProvider admission before seeding. Bootstrap runs + // Route through the SHARED gated compile path, never straight at the store. Bootstrap runs // BEFORE the first reconcile on every restart, so a gate the reconciler alone enforced would be // bypassed for the whole startup window — long enough to compile a rule and plan a stream for a - // target the provider never admitted. A denial 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. - admitted, admitErr := authz.GitTargetAdmitted(ctx, m.Client, &target) - if admitErr != nil { - return fmt.Errorf("evaluating ClusterProvider admission for GitTarget %s/%s: %w", - target.Namespace, target.Name, admitErr) + // target the provider never admitted, or to open a namespaced watch for a stored + // `scope: Namespaced` this release no longer supports. 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, provider) + if err != nil { + return fmt.Errorf("evaluating admission for ClusterWatchRule %q: %w", rule.Name, err) } - if !admitted.Allowed { - return fmt.Errorf("ClusterWatchRule %q may not compile against GitTarget %s/%s: %s", - rule.Name, target.Namespace, target.Name, admitted.Message) + if !decision.Admitted { + return fmt.Errorf("ClusterWatchRule %q was not compiled: %s", rule.Name, decision.Message) } - m.RuleStore.AddOrUpdateClusterWatchRule( - rule, - target.Name, target.Namespace, - provider.Name, provider.Namespace, - target.Spec.Branch, - target.Spec.Path, - ) - return nil } diff --git a/internal/watch/bootstrap_admission_test.go b/internal/watch/bootstrap_admission_test.go index c2c39050..cd2bbaf9 100644 --- a/internal/watch/bootstrap_admission_test.go +++ b/internal/watch/bootstrap_admission_test.go @@ -48,7 +48,7 @@ func bootGitProvider() *configv1alpha3.GitProvider { } } -func bootClusterProvider(policy *configv1alpha3.AllowedNamespaces) *configv1alpha3.ClusterProvider { +func bootClusterProvider(policy *configv1alpha3.NamespaceMatcher) *configv1alpha3.ClusterProvider { return &configv1alpha3.ClusterProvider{ ObjectMeta: metav1.ObjectMeta{Name: bootProviderName}, Spec: configv1alpha3.ClusterProviderSpec{AllowedNamespaces: policy}, @@ -63,13 +63,24 @@ func bootClusterWatchRule() *configv1alpha3.ClusterWatchRule { Kind: "GitTarget", Name: bootTargetName, Namespace: bootTargetNS, }, Rules: []configv1alpha3.ClusterResourceRule{{ - Scope: configv1alpha3.ResourceScopeNamespaced, - Resources: []string{"configmaps"}, + Resources: []string{"customresourcedefinitions"}, + APIGroups: []string{"apiextensions.k8s.io"}, }}, }, } } +// bootNamespacedClusterWatchRule is a STORED pre-release object: `scope: Namespaced` is rejected at +// admission from this release on, but etcd still holds objects written before it. +func bootNamespacedClusterWatchRule() *configv1alpha3.ClusterWatchRule { + rule := bootClusterWatchRule() + rule.Spec.Rules = []configv1alpha3.ClusterResourceRule{ + {Resources: []string{"customresourcedefinitions"}, APIGroups: []string{"apiextensions.k8s.io"}}, + {Resources: []string{"configmaps"}, Scope: configv1alpha3.ResourceScopeNamespaced}, + } + return rule +} + func bootManager(t *testing.T, objects ...client.Object) *Manager { t.Helper() return &Manager{ @@ -92,7 +103,7 @@ func bootCompiledNames(m *Manager) []string { func TestBootstrapClusterWatchRule_RefusesUnauthorizedGitTargetNamespace(t *testing.T) { m := bootManager(t, bootGitTarget(), bootGitProvider(), - bootClusterProvider(&configv1alpha3.AllowedNamespaces{Names: []string{"some-other-namespace"}}), + bootClusterProvider(&configv1alpha3.NamespaceMatcher{Names: []string{"some-other-namespace"}}), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: bootTargetNS}}, ) @@ -122,7 +133,7 @@ func TestBootstrapClusterWatchRule_RefusesMissingClusterProvider(t *testing.T) { func TestBootstrapClusterWatchRule_SeedsAdmittedRule(t *testing.T) { m := bootManager(t, bootGitTarget(), bootGitProvider(), - bootClusterProvider(&configv1alpha3.AllowedNamespaces{Names: []string{bootTargetNS}}), + bootClusterProvider(&configv1alpha3.NamespaceMatcher{Names: []string{bootTargetNS}}), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: bootTargetNS}}, ) @@ -161,7 +172,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. - bootClusterProvider(&configv1alpha3.AllowedNamespaces{Names: []string{"team-ok"}}), + bootClusterProvider(&configv1alpha3.NamespaceMatcher{Names: []string{"team-ok"}}), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: bootTargetNS}}, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-ok"}}, ) @@ -174,3 +185,78 @@ func TestBootstrapRuleStore_SkipsUnauthorizedRuleButStillReady(t *testing.T) { assert.True(t, m.RuleStore.IsReady(), "the store must still be marked ready: a refused rule is a refusal, not a startup failure") } + +// TestBootstrap_PreExistingNamespacedClusterRuleIsRefused is THE cluster-scope-only test. +// +// A ClusterWatchRule stored with `scope: Namespaced` before this release keeps that value in etcd, +// and bootstrap seeds the store BEFORE the first reconcile can publish any status. So the refusal +// has to live in the shared compile path: a reconciler-only check would let every restart open a +// cluster-wide namespaced watch for the whole startup window. This asserts the state at the moment +// MarkReady() returns — the only moment that proves it. +func TestBootstrap_PreExistingNamespacedClusterRuleIsRefused(t *testing.T) { + m := bootManager(t, + bootGitTarget(), bootGitProvider(), + bootClusterProvider(&configv1alpha3.NamespaceMatcher{Names: []string{bootTargetNS}}), + bootNamespacedClusterWatchRule(), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: bootTargetNS}}, + ) + + require.NoError(t, m.bootstrapRuleStore(context.Background(), logr.Discard()), + "a refused rule is a refusal, not a startup failure") + + assert.Empty(t, bootCompiledNames(m), + "a stored namespaced ClusterWatchRule must compile NO stream before status can be published") + assert.True(t, m.RuleStore.IsReady()) +} + +// TestBootstrapClusterWatchRule_WildcardStillResolvesItsClusterScopedTypes: the refusal keys on the +// STORED scope, not on what the selector happens to resolve. `resources: ["*"]` legitimately +// resolves cluster-scoped records — inferring the refusal from the resolution would break exactly +// the rule that the restart fixture exists to protect. +func TestBootstrapClusterWatchRule_WildcardStillResolvesItsClusterScopedTypes(t *testing.T) { + rule := bootClusterWatchRule() + rule.Spec.Rules = []configv1alpha3.ClusterResourceRule{{ + Resources: []string{"*"}, APIVersions: []string{"*"}, + }} + + m := bootManager(t, + bootGitTarget(), bootGitProvider(), + bootClusterProvider(&configv1alpha3.NamespaceMatcher{Names: []string{bootTargetNS}}), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: bootTargetNS}}, + ) + + require.NoError(t, m.bootstrapClusterWatchRule(context.Background(), *rule)) + + assert.Equal(t, []string{bootRuleName}, bootCompiledNames(m), + "a wildcard selector is not itself a namespaced scope declaration") +} + +// TestCompileClusterWatchRule_RefusalRemovesAnAlreadyCompiledRule is the REVOCATION contract for the +// cluster kind: a rule accepted earlier and then refused must have its compiled rule REMOVED, not +// merely reported unready. +func TestCompileClusterWatchRule_RefusalRemovesAnAlreadyCompiledRule(t *testing.T) { + ctx := context.Background() + m := bootManager(t, + bootGitTarget(), bootGitProvider(), + bootClusterProvider(&configv1alpha3.NamespaceMatcher{Names: []string{bootTargetNS}}), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: bootTargetNS}}, + ) + + decision, err := CompileClusterWatchRule( + ctx, m.Client, m.RuleStore, *bootClusterWatchRule(), *bootGitTarget(), *bootGitProvider()) + require.NoError(t, err) + require.True(t, decision.Admitted) + require.Len(t, bootCompiledNames(m), 1, "precondition: the rule is compiled") + + // Somebody re-applies the pre-release manifest (or an old object is re-observed). + decision, err = CompileClusterWatchRule( + ctx, m.Client, m.RuleStore, *bootNamespacedClusterWatchRule(), *bootGitTarget(), *bootGitProvider()) + + require.NoError(t, err) + assert.False(t, decision.Admitted) + assert.Equal(t, ClusterWatchRuleReasonScopeNotSupported, decision.Reason) + assert.Contains(t, decision.Message, "rules[].sourceNamespace", + "the refusal must name the replacement, because the migration is cross-kind") + assert.Empty(t, bootCompiledNames(m), + "a refused rule must be removed from the store, not left running with a bad condition") +} diff --git a/internal/watch/config_plane_split_review_fixes_test.go b/internal/watch/config_plane_split_review_fixes_test.go index 845e376e..7f015130 100644 --- a/internal/watch/config_plane_split_review_fixes_test.go +++ b/internal/watch/config_plane_split_review_fixes_test.go @@ -60,6 +60,12 @@ func seedClusterCatalog(t *testing.T, m *Manager, id string, disco staticCatalog // remote clusters can be made to legitimately disagree on what they serve. An empty group is the // core group. func oneResourceDiscovery(group, name, kind string) staticCatalogDiscovery { + return oneScopedResourceDiscovery(group, name, kind, true) +} + +// oneScopedResourceDiscovery is oneResourceDiscovery with an explicit scope, for the tests that +// need a genuinely CLUSTER-scoped type — the only kind a ClusterWatchRule can resolve. +func oneScopedResourceDiscovery(group, name, kind string, namespaced bool) staticCatalogDiscovery { listWatch := metav1.Verbs{"get", "list", "watch"} groupVersion := "v1" if group != "" { @@ -69,7 +75,7 @@ func oneResourceDiscovery(group, name, kind string) staticCatalogDiscovery { groups: []*metav1.APIGroup{testAPIGroup(group, "v1")}, resources: []*metav1.APIResourceList{{ GroupVersion: groupVersion, - APIResources: []metav1.APIResource{{Name: name, Kind: kind, Namespaced: true, Verbs: listWatch}}, + APIResources: []metav1.APIResource{{Name: name, Kind: kind, Namespaced: namespaced, Verbs: listWatch}}, }}, } } @@ -259,21 +265,21 @@ func TestRefreshWatchedTypeTables_RetargetReResolvesAtEqualRevisions(t *testing. m := &Manager{Log: logr.Discard(), RuleStore: store, resourceCatalog: newCommonTestCatalog(t)} const clusterA, clusterB = "team-a/kc/value", "team-b/kc/value" - ccA := seedClusterCatalog(t, m, clusterA, oneResourceDiscovery("", "configmaps", "ConfigMap")) - ccB := seedClusterCatalog(t, m, clusterB, oneResourceDiscovery("", "secrets", "Secret")) + ccA := seedClusterCatalog(t, m, clusterA, oneScopedResourceDiscovery("", "namespaces", "Namespace", false)) + ccB := seedClusterCatalog(t, m, clusterB, oneScopedResourceDiscovery("", "nodes", "Node", false)) require.Equal(t, ccA.registry.Revision(), ccB.registry.Revision(), "both remotes are one scan in, so their registry revisions are equal on purpose") store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "configmaps"), + clusterRuleForResource("rule-1", "namespaces"), "t", "test-ns", "test-provider", "test-ns", "main", "test-path", ) m.rememberGitTargetCluster(gitDestRef("t"), clusterA) m.refreshWatchedTypeTables() first, ok := m.watchedTypeTableForGitDest(gitDestRef("t")) require.True(t, ok) - require.Len(t, first.Types, 1, "cluster A serves configmaps") - assert.Equal(t, "ConfigMap", first.Types[0].GVK.Kind) + require.Len(t, first.Types, 1, "cluster A serves namespaces") + assert.Equal(t, "Namespace", first.Types[0].GVK.Kind) // Retarget to B (equal revision, unchanged rule): only the cluster mapping fingerprint moved. m.forgetGitTargetCluster(gitDestRef("t")) // last referencer of A -> A is torn down @@ -283,7 +289,7 @@ func TestRefreshWatchedTypeTables_RetargetReResolvesAtEqualRevisions(t *testing. second, ok := m.watchedTypeTableForGitDest(gitDestRef("t")) require.True(t, ok) assert.Empty(t, second.Types, - "after retargeting to a cluster that does not serve configmaps the table must be "+ + "after retargeting to a cluster that does not serve namespaces the table must be "+ "re-resolved to empty, not keep cluster A's GVRs") } @@ -325,7 +331,8 @@ func TestResolveWatchRuleResources_ResolvesAgainstSourceCluster(t *testing.T) { func TestResolveClusterWatchRuleResources_ResolvesAgainstSourceCluster(t *testing.T) { m := &Manager{Log: logr.Discard(), resourceCatalog: newCommonTestCatalog(t)} const remote = "team-a/kc/value" - seedClusterCatalog(t, m, remote, oneResourceDiscovery("example.com", "widgets", "Widget")) + seedClusterCatalog(t, m, remote, + oneScopedResourceDiscovery("example.com", "widgetclasses", "WidgetClass", false)) m.rememberGitTargetCluster(types.NewResourceReference("t", "test-ns"), remote) rule := configv1alpha3.ClusterWatchRule{ @@ -333,8 +340,7 @@ func TestResolveClusterWatchRuleResources_ResolvesAgainstSourceCluster(t *testin TargetRef: configv1alpha3.NamespacedTargetReference{Name: "t", Namespace: "test-ns"}, Rules: []configv1alpha3.ClusterResourceRule{{ APIGroups: []string{"example.com"}, - Resources: []string{"widgets"}, - Scope: configv1alpha3.ResourceScopeNamespaced, + Resources: []string{"widgetclasses"}, }}, }, } diff --git a/internal/watch/helpers_test.go b/internal/watch/helpers_test.go index c9610e02..08feddc2 100644 --- a/internal/watch/helpers_test.go +++ b/internal/watch/helpers_test.go @@ -8,6 +8,23 @@ import ( configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" ) +// ownNamespaceScope is the resolved scope a WatchRule whose items set no sourceNamespace compiles +// to: every item watches the rule's OWN namespace. Tests that are not about the source-namespace +// gate use it so the compiled rule looks exactly as CompileWatchRule would leave it. +func ownNamespaceScope(rule configv1alpha3.WatchRule) [][]string { + out := make([][]string, len(rule.Spec.Rules)) + for i := range rule.Spec.Rules { + out[i] = []string{rule.Spec.Rules[i].EffectiveSourceNamespace(rule.Namespace)} + } + return out +} + +// itemScope is the resolved scope for a rule whose single item watches the given namespaces — +// the shape a `sourceNamespace: "*"` item compiles to once expanded. +func itemScope(namespaces ...string) [][]string { + return [][]string{namespaces} +} + func TestNormalizeResource(t *testing.T) { got := normalizeResource(" Deployments ") if got != "deployments" { diff --git a/internal/watch/manager.go b/internal/watch/manager.go index 7d4f6aad..e42be4d2 100644 --- a/internal/watch/manager.go +++ b/internal/watch/manager.go @@ -204,6 +204,20 @@ type Manager struct { // data plane reads it to drive the per-(GitTarget, type) watch set; re-declaring is idempotent. declaredGVRsMu sync.Mutex declaredGVRs map[string]map[schema.GroupVersionResource]struct{} + + // sourceNamespaceScope is the source-scope service: the per-source-cluster Namespace label + // snapshot that GitTarget.allowedSourceNamespaces selectors are evaluated against, plus the + // per-rule resolved scopes the establishing/maintaining contract turns on. See + // source_namespace_scope.go. Lazily built so a zero-value Manager works in tests. + sourceScopeInit sync.Once + sourceNamespaceScope *sourceNamespaceScope + + // sourceNamespaceEventsCh carries a GenericEvent for every GitTarget on a source cluster whose + // Namespace labels changed, so a selector-driven grant or revocation reaches the WatchRule + // controller on the change instead of waiting up to RequeueSteadyInterval (5m). Lazily created + // by SourceNamespaceEvents() and guarded by sourceNamespaceEventsMu. + sourceNamespaceEventsMu sync.Mutex + sourceNamespaceEventsCh chan event.GenericEvent } // GitPathAcceptanceStatus is the whole-target write-safety status for a GitTarget path. @@ -298,6 +312,11 @@ func (m *Manager) ReconcileForRuleChange(ctx context.Context) error { return err } + // Re-list the source-cluster Namespace labels any selector policy has asked about, BEFORE the + // tables are re-resolved: this is where a source-namespace grant or revocation is observed, + // and a change enqueues the affected GitTargets so their WatchRules re-run the gate. + m.refreshSourceNamespaceScopes(ctx) + // Re-resolve the resident watched-type tables now that the catalog is fresh. This is // gated on a rule-set change or catalog generation bump, so a periodic reconcile with // neither reuses the resolved tables. The target-watch runner reads these tables. diff --git a/internal/watch/manager_catalog.go b/internal/watch/manager_catalog.go index ba99e490..e0a1b10d 100644 --- a/internal/watch/manager_catalog.go +++ b/internal/watch/manager_catalog.go @@ -403,8 +403,11 @@ func (m *Manager) ResolveClusterWatchRuleResources( ) (bool, string) { selectors := make([]ruleResourceSelector, 0, len(rule.Spec.Rules)) for _, rr := range rule.Spec.Rules { + // Always Cluster: a ClusterWatchRule is cluster-scope-only, so reporting resolution against + // any other scope would claim types the rule can never watch. selectors = append(selectors, ruleResourceSelector{ - groups: rr.APIGroups, versions: rr.APIVersions, resources: rr.Resources, scope: rr.Scope, + groups: rr.APIGroups, versions: rr.APIVersions, resources: rr.Resources, + scope: configv1alpha3.ResourceScopeCluster, }) } gitDest := types.NewResourceReference(rule.Spec.TargetRef.Name, rule.Spec.TargetRef.Namespace) diff --git a/internal/watch/manager_snapshot_test.go b/internal/watch/manager_snapshot_test.go index 97210aa3..e7d24341 100644 --- a/internal/watch/manager_snapshot_test.go +++ b/internal/watch/manager_snapshot_test.go @@ -66,16 +66,18 @@ func gitTargetFixture() *configv1alpha3.GitTarget { // addSecretsWatchRule registers a namespaced WatchRule in ns-a for my-target watching secrets — // the standard single-namespaced-type fixture the splice/scope/audit-tail tests resolve against. func addSecretsWatchRule(store *rulestore.RuleStore) { - store.AddOrUpdateWatchRule( - configv1alpha3.WatchRule{ - ObjectMeta: metav1.ObjectMeta{Name: "wr-secrets", Namespace: "ns-a"}, - Spec: configv1alpha3.WatchRuleSpec{ - TargetRef: configv1alpha3.LocalTargetReference{Name: "my-target"}, - Rules: []configv1alpha3.ResourceRule{{ - APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"secrets"}, - }}, - }, + rule := configv1alpha3.WatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: "wr-secrets", Namespace: "ns-a"}, + Spec: configv1alpha3.WatchRuleSpec{ + TargetRef: configv1alpha3.LocalTargetReference{Name: "my-target"}, + Rules: []configv1alpha3.ResourceRule{{ + APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"secrets"}, + }}, }, + } + store.AddOrUpdateWatchRule( + rule, + ownNamespaceScope(rule), "my-target", "gitops-reverser", "provider", "gitops-reverser", "main", "live", ) } diff --git a/internal/watch/manager_startup_test.go b/internal/watch/manager_startup_test.go index c395bd8a..b1f239fe 100644 --- a/internal/watch/manager_startup_test.go +++ b/internal/watch/manager_startup_test.go @@ -93,14 +93,13 @@ func TestManagerStart_MustSeedRuleStoreFromExistingClusterWatchRules(t *testing. require.NoError(t, configv1alpha3.AddToScheme(scheme)) existingClusterWatchRule := &configv1alpha3.ClusterWatchRule{ - ObjectMeta: metav1.ObjectMeta{Name: "cluster-services"}, + ObjectMeta: metav1.ObjectMeta{Name: "cluster-namespaces"}, Spec: configv1alpha3.ClusterWatchRuleSpec{ TargetRef: configv1alpha3.NamespacedTargetReference{Name: "ops-target", Namespace: "ops"}, Rules: []configv1alpha3.ClusterResourceRule{{ - Scope: configv1alpha3.ResourceScopeNamespaced, APIGroups: []string{""}, APIVersions: []string{"v1"}, - Resources: []string{"services"}, + Resources: []string{"namespaces"}, }}, }, } @@ -129,7 +128,7 @@ func TestManagerStart_MustSeedRuleStoreFromExistingClusterWatchRules(t *testing. defaultClusterProvider := &configv1alpha3.ClusterProvider{ ObjectMeta: metav1.ObjectMeta{Name: configv1alpha3.DefaultClusterProviderName}, Spec: configv1alpha3.ClusterProviderSpec{ - AllowedNamespaces: &configv1alpha3.AllowedNamespaces{Selector: &metav1.LabelSelector{}}, + AllowedNamespaces: &configv1alpha3.NamespaceMatcher{Selector: &metav1.LabelSelector{}}, }, } diff --git a/internal/watch/source_namespace_planning_test.go b/internal/watch/source_namespace_planning_test.go new file mode 100644 index 00000000..77352819 --- /dev/null +++ b/internal/watch/source_namespace_planning_test.go @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: Apache-2.0 + +package watch + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/rulestore" +) + +// These are the SILENT-FAILURE guards. Every site they cover produces a stale watch rather than a +// visible failure: nothing errors, nothing is unready, the operator just quietly mirrors the wrong +// namespaces. Without these tests the omissions are invisible until someone notices in production. + +// watchRuleOwnNamespace is the control-plane namespace every rule in these tests lives in; the +// point of each case is to vary the SOURCE namespaces against it. +const watchRuleOwnNamespace = "tenant-acme" + +// watchRuleWithSource is watchRuleForTarget with an explicit rules[0].sourceNamespace, so a test can +// vary the SOURCE namespace independently of the namespace the rule object lives in. +func watchRuleWithSource(name, gitTargetName, sourceNamespace string) configv1alpha3.WatchRule { + rule := watchRuleForTarget(name, gitTargetName, watchRuleOwnNamespace) + rule.Spec.Rules[0].SourceNamespace = sourceNamespace + return rule +} + +// addRule compiles one WatchRule with an explicit resolved scope — exactly what CompileWatchRule +// would hand the store — so the planning assertions run against the real shape. +func addRule(store *rulestore.RuleStore, rule configv1alpha3.WatchRule, scope [][]string) { + store.AddOrUpdateWatchRule( + rule, scope, rule.Spec.TargetRef.Name, "test-ns", "test-provider", "test-ns", "main", "test-path") +} + +// makeStoreWithScope compiles one WatchRule with a given resolved scope into a fresh store, for the +// tests that assert on the compiled form rather than on the resolved watch plan. +func makeStoreWithScope(rule configv1alpha3.WatchRule, scope [][]string) *rulestore.RuleStore { + store := rulestore.NewStore() + addRule(store, rule, scope) + return store +} + +// TestWatchRuleFingerprint_ChangesWithResolvedSourceScope is THE §4.3 guard. The watched-type table +// is only re-projected when the rules fingerprint changes, and a wildcard item's inputs — the +// GitTarget policy and the source cluster's Namespace labels — are not rule state at all. So two +// BYTE-IDENTICAL WatchRules whose targets admit different sets must fingerprint differently, or a +// policy edit re-reconciles the rule, finds the fingerprint unchanged, skips the rebuild, and leaves +// every stream running at its old width with nothing anywhere reporting a problem. +func TestWatchRuleFingerprint_ChangesWithResolvedSourceScope(t *testing.T) { + wildcard := watchRuleWithSource("rule", "target", configv1alpha3.SourceNamespaceWildcard) + + narrow := makeStoreWithScope(wildcard, itemScope("repo-config")).SnapshotWatchRules()[0] + widened := makeStoreWithScope(wildcard, itemScope("repo-config", "team-payments")).SnapshotWatchRules()[0] + tightened := makeStoreWithScope(wildcard, itemScope("team-payments")).SnapshotWatchRules()[0] + + assert.NotEqual(t, watchRuleFingerprint(narrow), watchRuleFingerprint(widened), + "WIDENING a policy under an untouched rule object must change the fingerprint") + assert.NotEqual(t, watchRuleFingerprint(narrow), watchRuleFingerprint(tightened), + "TIGHTENING a policy under an untouched rule object must change the fingerprint too") + assert.Equal(t, watchRuleFingerprint(narrow), + watchRuleFingerprint(makeStoreWithScope(wildcard, itemScope("repo-config")).SnapshotWatchRules()[0]), + "an unchanged resolved scope must be stable, or every reconcile rebuilds the table") +} + +// TestWatchRuleFingerprint_DiffersBySourceNamespace guards the explicit-name half of the same step. +func TestWatchRuleFingerprint_DiffersBySourceNamespace(t *testing.T) { + rule := watchRuleWithSource("rule", "target", "repo-config") + withOverride := makeStoreWithScope(rule, itemScope("repo-config")).SnapshotWatchRules()[0] + + other := watchRuleWithSource("rule", "target", "other-namespace") + withDifferentOverride := makeStoreWithScope(other, itemScope("other-namespace")).SnapshotWatchRules()[0] + + legacyRule := watchRuleWithSource("rule", "target", "") + legacy := makeStoreWithScope(legacyRule, ownNamespaceScope(legacyRule)).SnapshotWatchRules()[0] + + assert.NotEqual(t, watchRuleFingerprint(withOverride), watchRuleFingerprint(withDifferentOverride), + "two rules differing ONLY in sourceNamespace must fingerprint differently, or a change to "+ + "the field never re-projects the watched-type table") + assert.NotEqual(t, watchRuleFingerprint(withOverride), watchRuleFingerprint(legacy), + "adding an override must re-project the table") +} + +// TestCollectWatchRuleSelections_UsesResolvedSourceNamespace guards the selection step: the watch +// scope must be the item's RESOLVED source namespace, not the namespace the WatchRule object lives +// in. +func TestCollectWatchRuleSelections_UsesResolvedSourceNamespace(t *testing.T) { + manager, store := makeWatchedTypeManager(t) + addRule(store, + watchRuleWithSource("override-rule", "src-target", "repo-config"), + itemScope("repo-config")) + + manager.refreshWatchedTypeTables() + + table, ok := manager.watchedTypeTableForGitDest(gitDestRef("src-target")) + require.True(t, ok) + require.Len(t, table.Types, 1) + assert.Equal(t, []string{"repo-config"}, table.Types[0].WatchScopes(), + "the stream must watch the SOURCE namespace, not the WatchRule's own namespace") +} + +// TestCollectWatchRuleSelections_WildcardExpandsToOneScopePerNamespace is §4.2: expansion happens at +// the selection site, so the scope rides through the plan hash, the informers, and the resync path +// for free. A read-site filter would have to be repeated at each of them. +func TestCollectWatchRuleSelections_WildcardExpandsToOneScopePerNamespace(t *testing.T) { + manager, store := makeWatchedTypeManager(t) + addRule(store, + watchRuleWithSource("wild", "wild-target", configv1alpha3.SourceNamespaceWildcard), + itemScope("repo-config", "team-payments")) + + manager.refreshWatchedTypeTables() + + table, ok := manager.watchedTypeTableForGitDest(gitDestRef("wild-target")) + require.True(t, ok) + require.Len(t, table.Types, 1) + assert.Equal(t, []string{"repo-config", "team-payments"}, table.Types[0].WatchScopes()) + assert.False(t, table.Types[0].ClusterWide(), + "a wildcard must never collapse into a cluster-wide stream: that would widen the resync sweep") +} + +// TestCollectWatchRuleSelections_EmptyWildcardWatchesNothing: an admitted-but-empty set is a real +// resolved answer, and it must produce no stream rather than a cluster-wide one. +func TestCollectWatchRuleSelections_EmptyWildcardWatchesNothing(t *testing.T) { + manager, store := makeWatchedTypeManager(t) + addRule(store, + watchRuleWithSource("empty", "empty-target", configv1alpha3.SourceNamespaceWildcard), + itemScope()) + + manager.refreshWatchedTypeTables() + + table, ok := manager.watchedTypeTableForGitDest(gitDestRef("empty-target")) + require.True(t, ok) + assert.Empty(t, table.Types, "no resolved namespace means no watched type, never a wider watch") +} + +// TestCollectWatchRuleSelections_LegacyRuleStillWatchesItsOwnNamespace is the upgrade guarantee at +// the planning layer: with sourceNamespace omitted, nothing about the resolved scope changes. +func TestCollectWatchRuleSelections_LegacyRuleStillWatchesItsOwnNamespace(t *testing.T) { + manager, store := makeWatchedTypeManager(t) + legacy := watchRuleForTarget("legacy-rule", "legacy-target", "tenant-acme") + addRule(store, legacy, ownNamespaceScope(legacy)) + + manager.refreshWatchedTypeTables() + + table, ok := manager.watchedTypeTableForGitDest(gitDestRef("legacy-target")) + require.True(t, ok) + require.Len(t, table.Types, 1) + assert.Equal(t, []string{"tenant-acme"}, table.Types[0].WatchScopes()) +} + +// TestWatchedTypeTable_RebuildsWhenOnlyThePolicyChanged is the invalidation twin one level up from +// the fingerprint: the resident table must actually RE-PROJECT when only the resolved scope moved, +// not merely have its reconcile re-run. The rule object is byte-identical across both compiles. +func TestWatchedTypeTable_RebuildsWhenOnlyThePolicyChanged(t *testing.T) { + manager, store := makeWatchedTypeManager(t) + rule := watchRuleWithSource("rule", "policy-target", configv1alpha3.SourceNamespaceWildcard) + + addRule(store, rule, itemScope("repo-config")) + manager.refreshWatchedTypeTables() + table, ok := manager.watchedTypeTableForGitDest(gitDestRef("policy-target")) + require.True(t, ok) + require.Equal(t, []string{"repo-config"}, table.Types[0].WatchScopes()) + + // The GitTarget policy widened. The WatchRule itself did not change one byte. + addRule(store, rule, itemScope("repo-config", "team-payments")) + manager.refreshWatchedTypeTables() + + table, ok = manager.watchedTypeTableForGitDest(gitDestRef("policy-target")) + require.True(t, ok) + require.Len(t, table.Types, 1) + assert.Equal(t, []string{"repo-config", "team-payments"}, table.Types[0].WatchScopes(), + "a policy edit must re-project the resident table, not just re-run the reconcile") +} + +// TestRefreshWatchedTypeTables_SourceNamespaceChangeReProjects is the end of the same chain for an +// explicit name: an edit to rules[].sourceNamespace must actually move the resolved watch scope. +func TestRefreshWatchedTypeTables_SourceNamespaceChangeReProjects(t *testing.T) { + manager, store := makeWatchedTypeManager(t) + addRule(store, + watchRuleWithSource("rule", "reproj-target", "repo-config"), + itemScope("repo-config")) + manager.refreshWatchedTypeTables() + + table, ok := manager.watchedTypeTableForGitDest(gitDestRef("reproj-target")) + require.True(t, ok) + require.Equal(t, []string{"repo-config"}, table.Types[0].WatchScopes()) + + // Same rule name, different source namespace — the update path, not a new rule. + addRule(store, + watchRuleWithSource("rule", "reproj-target", "moved-namespace"), + itemScope("moved-namespace")) + manager.refreshWatchedTypeTables() + + table, ok = manager.watchedTypeTableForGitDest(gitDestRef("reproj-target")) + require.True(t, ok) + require.Len(t, table.Types, 1) + assert.Equal(t, []string{"moved-namespace"}, table.Types[0].WatchScopes(), + "changing sourceNamespace must rebuild the watched-type table, not leave a stale watch") +} + +// TestCompiledRule_SourceNamespacesAreSeparateFromTheRuleObject pins the field's meaning: Source +// names the WatchRule OBJECT in the control plane and each item's SourceNamespaces name the +// namespaces being mirrored. Collapsing them is the mistake the split exists to prevent. +func TestCompiledRule_SourceNamespacesAreSeparateFromTheRuleObject(t *testing.T) { + store := makeStoreWithScope( + watchRuleWithSource("rule", "target", "repo-config"), itemScope("repo-config")) + + compiled := store.SnapshotWatchRules()[0] + + assert.Equal(t, "tenant-acme", compiled.Source.Namespace, "the WatchRule object's namespace") + assert.Equal(t, []string{"repo-config"}, compiled.ResourceRules[0].SourceNamespaces, + "the namespaces being mirrored") +} diff --git a/internal/watch/source_namespace_scope.go b/internal/watch/source_namespace_scope.go new file mode 100644 index 00000000..d00d5394 --- /dev/null +++ b/internal/watch/source_namespace_scope.go @@ -0,0 +1,515 @@ +// SPDX-License-Identifier: Apache-2.0 + +package watch + +import ( + "context" + "fmt" + "maps" + "sort" + "strings" + "sync" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/event" + + configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/authz" + "github.com/ConfigButler/gitops-reverser/internal/types" +) + +// namespacesGVR is the source-cluster resource a selector policy is evaluated against. +func namespacesGVR() schema.GroupVersionResource { + return schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"} +} + +// sourceNamespaceEventsBuffer sizes the grant/revocation channel. A full buffer means reconciles +// are already pending, so a dropped event is harmless — the periodic requeue is the backstop. +const sourceNamespaceEventsBuffer = 256 + +// sourceNamespaceListTimeout bounds ONE source cluster's Namespace list. A list that has not +// answered in this long is not slow, it is wedged, and the refresh runs again on the next reconcile +// anyway — so giving up costs at most one interval's freshness, while not giving up costs every +// other tenant their reconcile. A deadline is the right tool here (unlike the discovery path, which +// needs a rest.Config timeout because ServerGroupsAndResources takes no context). +const sourceNamespaceListTimeout = 15 * time.Second + +// maxConcurrentSourceNamespaceRefreshes bounds how many source clusters are listed at once, so a +// large tenant fan-out cannot open an unbounded number of simultaneous connections. It mirrors +// maxConcurrentCatalogRefreshes, which bounds the same fan-out for the catalog. +const maxConcurrentSourceNamespaceRefreshes = 8 + +// sourceNamespaceScope is the SOURCE-SCOPE SERVICE: the manager-owned answer to "does this +// GitTarget's allowedSourceNamespaces admit this namespace in its source cluster?". +// +// It exists because the gate runs in internal/controller while the labels it needs live in a +// source cluster whose connection and cache internal/watch already owns. Without it a reconciler +// would dial the source cluster on every pass, duplicating both. +// +// It provides the three things the design requires of it: +// +// 1. RESOLUTION, backed by a per-source-cluster Namespace snapshot rather than an inline API call +// from the reconciler. Exact NAMES are answered by the API types without ever reaching here, +// so a cluster whose Namespace access is denied still supports name-based policies. +// 2. READINESS AND ERROR STATE AS A FIRST-CLASS RESULT — three-valued, never boolean. A +// two-valued answer would force "cannot say yet" to be encoded as "denied", which is how a +// transient outage becomes a terminal Stalled=True and a stopped stream. +// 3. ENQUEUE. A label change, a first sync, or a source-cluster reconnection pushes the affected +// GitTargets onto a channel the WatchRule controller maps to its rules, so grants and +// revocations land promptly instead of going stale in the cache. +// +// The snapshot is refreshed on the manager's existing reconcile cadence (every 30s and on every +// rule change) rather than by a dedicated informer. That is the deliberate choice: it matches how +// this package already treats every other source-cluster input — it does not WATCH credentials, it +// RE-CHECKS them — and it keeps source-cluster state on one lifecycle instead of two. The cost is +// that a revocation converges within a refresh interval rather than instantly, which the gate is +// built to tolerate: the compiled rule is what stops mirroring, and it is dropped the moment the +// reconcile the enqueue triggers observes the change. +// +// Clusters are refreshed LAZILY: a cluster is only listed once some target on it has actually +// asked a selector question. A deployment with no selector policies never lists a namespace. +type sourceNamespaceScope struct { + mu sync.RWMutex + // wanted is the set of source clusters some selector policy has asked about. It arms the + // refresh loop, so listing is driven by demand rather than by the active-cluster set. + wanted map[string]struct{} + // snapshots holds the last observed Namespace labels per source cluster. + snapshots map[string]namespaceSnapshot + // grants records the whole resolved scope last successfully GRANTED to each WatchRule — the + // "previously resolved scope" the establishing/maintaining contract turns on. + grants map[k8stypes.NamespacedName]sourceScopeGrant +} + +// sourceScopeGrant is one WatchRule's last known-good resolved scope, stamped with the spec that +// produced it. +// +// The spec hash is what makes retention safe with per-item namespaces: retention applies only while +// the spec is unchanged, so an edit discards the grant and re-establishes from scratch. Keying by +// item index instead would let a reorder inherit another item's grant, which is a silent widening. +type sourceScopeGrant struct { + specHash string + namespaces [][]string +} + +// namespaceSnapshot is one source cluster's Namespace label state, plus why it might be unusable. +type namespaceSnapshot struct { + // labels maps namespace name to its labels. Valid only when synced is true. + labels map[string]map[string]string + // synced reports whether a list has EVER succeeded for this cluster. Before that, a selector + // question is "cannot say yet", never "denied". + synced bool + // forbidden records a TERMINAL failure: the source credential may not list Namespaces, so a + // selector policy can never be evaluated without an operator change (granting the RBAC, or + // switching the policy to exact names). It is distinct from err precisely so the controller + // can render it as Stalled rather than as a retry. + forbidden bool + // err is the last RETRYABLE list failure, if any. + err error +} + +func (m *Manager) sourceScope() *sourceNamespaceScope { + m.sourceScopeInit.Do(func() { + m.sourceNamespaceScope = &sourceNamespaceScope{ + wanted: map[string]struct{}{}, + snapshots: map[string]namespaceSnapshot{}, + grants: map[k8stypes.NamespacedName]sourceScopeGrant{}, + } + }) + return m.sourceNamespaceScope +} + +// SourceScope exposes the manager itself as the source-scope service the WatchRule gate resolves +// through. It is a method rather than a bare interface assertion so the controller's +// WatchManagerInterface can carry it and tests can supply a stand-in. +func (m *Manager) SourceScope() SourceScopeService { return m } + +// sourceScopeClusterID is the cluster whose Namespace labels decide a GitTarget's source-namespace +// policy: the one the GitTarget itself names. +// +// It deliberately does NOT go through clusterIDForGitTarget, which defaults an undeclared GitTarget +// to the config plane. That default is right for the read paths it was written for — a status read +// racing the first Declare — and wrong here, because AUTHORIZATION is not a status read. The +// WatchRule reconciler gates as soon as it has resolved the GitTarget, while DeclareForGitTarget is +// the GitTarget controller's job, and after a restart the two run concurrently; resolving through +// the cache in that window would evaluate a REMOTE target's selector against CONFIG-PLANE Namespace +// labels, so a namespace could be admitted because a same-named namespace here carried the right +// label. The GitTarget carries the answer already, and the two can never disagree: the controller +// passes exactly this value to DeclareForGitTarget. +func sourceScopeClusterID(target *configv1alpha3.GitTarget) string { + return target.SourceCluster() +} + +// ResolveSourceNamespace answers whether a GitTarget's declared allowedSourceNamespaces admits a +// namespace in that target's source cluster. It implements authz.SourceNamespaceResolver. +// +// It only ever sees SELECTOR questions: authz answers the exact-name half itself, without a cache +// and without any source-cluster access at all, which is what keeps name-based policies working +// against a cluster whose Namespace reads are denied. +func (m *Manager) ResolveSourceNamespace( + _ context.Context, + target *configv1alpha3.GitTarget, + namespace string, +) authz.SourceScopeResult { + clusterID := sourceScopeClusterID(target) + scope := m.sourceScope() + + // Arm the refresh loop for this cluster. The first question is always "cannot say yet"; the + // answer arrives with the next refresh, which then ENQUEUES this target's rules. + scope.want(clusterID) + + snapshot, ok := scope.snapshot(clusterID) + if result, unusable := m.unusableSnapshot(clusterID, snapshot, ok); unusable { + return result + } + + labels, known := snapshot.labels[namespace] + if !known { + // A namespace absent from the source cluster cannot match a selector. This is a real + // answer, not an absence of one: the cache IS synced, so the namespace does not exist. + labels = map[string]string{} + } + + allowed, err := target.AllowsSourceNamespace(namespace, labels) + if err != nil { + // A malformed selector will never evaluate as written — terminal, not retryable. + return authz.SourceScopeResult{ + Verdict: authz.SourceScopeUnavailable, + Message: fmt.Sprintf("spec.allowedSourceNamespaces selector is invalid: %v", err), + } + } + if !allowed { + detail := fmt.Sprintf("namespace %q does not match the policy's selector", namespace) + if !known { + detail = fmt.Sprintf("namespace %q does not exist in source cluster %q", + namespace, describeCluster(clusterID)) + } + return authz.SourceScopeResult{Verdict: authz.SourceScopeDenied, Message: detail} + } + return authz.SourceScopeResult{ + Verdict: authz.SourceScopeAdmitted, + Message: "admitted by the policy's selector", + } +} + +// EnumerateSourceNamespaces expands a GitTarget's allowedSourceNamespaces SELECTOR into the +// concrete set of source-cluster namespaces it currently admits. It implements the wildcard half of +// authz.SourceNamespaceResolver. +// +// It answers only the SELECTOR half; authz unions the policy's exact names itself, without a cache +// and without any source-cluster access, which is what keeps a `sourceNamespace: "*"` item against +// a names-only policy resolving on a cluster whose Namespace list is Forbidden. +// +// An empty slice with an Admitted verdict is a real answer — the selector currently matches nothing +// — while Unknown and Unavailable mean the set could not be computed. The caller must never read +// the latter as the empty set: an empty resolved scope is the input to a resync sweep. +func (m *Manager) EnumerateSourceNamespaces( + _ context.Context, + target *configv1alpha3.GitTarget, +) ([]string, authz.SourceScopeResult) { + clusterID := sourceScopeClusterID(target) + scope := m.sourceScope() + + // Arm the refresh loop for this cluster, exactly as the single-candidate path does. + scope.want(clusterID) + + snapshot, ok := scope.snapshot(clusterID) + if result, unusable := m.unusableSnapshot(clusterID, snapshot, ok); unusable { + return nil, result + } + + names := make([]string, 0, len(snapshot.labels)) + for name, nsLabels := range snapshot.labels { + admitted, err := target.Spec.AllowedSourceNamespaces.SelectorAdmits(nsLabels) + if err != nil { + // A malformed selector will never evaluate as written — terminal, not retryable. + return nil, authz.SourceScopeResult{ + Verdict: authz.SourceScopeUnavailable, + Message: fmt.Sprintf("spec.allowedSourceNamespaces selector is invalid: %v", err), + } + } + if admitted { + names = append(names, name) + } + } + sort.Strings(names) + return names, authz.SourceScopeResult{ + Verdict: authz.SourceScopeAdmitted, + Message: fmt.Sprintf("the policy's selector matches %d source namespace(s)", len(names)), + } +} + +// unusableSnapshot maps a missing, unsynced, or Forbidden snapshot onto the three-valued result +// both resolver entry points must return. It is shared so the single-candidate and enumeration +// paths cannot drift on the one distinction that matters: TERMINAL (the credential may never list +// Namespaces) versus RETRYABLE (not synced yet). +func (m *Manager) unusableSnapshot( + clusterID string, + snapshot namespaceSnapshot, + ok bool, +) (authz.SourceScopeResult, bool) { + switch { + case ok && snapshot.forbidden: + return authz.SourceScopeResult{ + Verdict: authz.SourceScopeUnavailable, + Message: fmt.Sprintf( + "listing Namespaces in source cluster %q is forbidden for its credential, so a "+ + "selector policy cannot be evaluated; grant that identity namespaces "+ + "get/list/watch, or use exact names in allowedSourceNamespaces", + describeCluster(clusterID)), + }, true + case !ok || !snapshot.synced: + reason := "the source-cluster Namespace cache has not synced yet" + if ok && snapshot.err != nil { + reason = fmt.Sprintf("the source-cluster Namespace cache is not usable yet: %v", snapshot.err) + } + // Nudge the loop so the first answer does not wait for the periodic tick. + m.signalCatalogRefresh() + return authz.SourceScopeResult{Verdict: authz.SourceScopeUnknown, Message: reason}, true + default: + return authz.SourceScopeResult{}, false + } +} + +// RetainedSourceScope reports the resolved scope last GRANTED to a rule FOR A GIVEN SPEC, and +// whether any grant was ever established for that spec. It is what separates ESTABLISHING a scope +// from MAINTAINING one: an unevaluatable policy must never produce a resolved namespace set, so +// while establishing the rule simply does not compile, and while maintaining the last known-good +// scope is retained instead of being narrowed to nothing — because a narrowed set is the input to a +// sweep, and failing closed there would delete a tenant's Git content on a transient outage. +// +// A grant recorded under a DIFFERENT spec hash is not reported: a rule whose items changed is +// establishing a new scope, so it must not inherit the old one. +func (m *Manager) RetainedSourceScope(rule k8stypes.NamespacedName, specHash string) ([][]string, bool) { + scope := m.sourceScope() + scope.mu.RLock() + defer scope.mu.RUnlock() + grant, ok := scope.grants[rule] + if !ok || grant.specHash != specHash { + return nil, false + } + return grant.namespaces, true +} + +// RecordSourceScopeGrant remembers that a rule resolved a whole scope under this spec, establishing +// what RetainedSourceScope will later report. The grant replaces any previous one atomically. +func (m *Manager) RecordSourceScopeGrant( + rule k8stypes.NamespacedName, + specHash string, + namespaces [][]string, +) { + scope := m.sourceScope() + scope.mu.Lock() + defer scope.mu.Unlock() + scope.grants[rule] = sourceScopeGrant{specHash: specHash, namespaces: namespaces} +} + +// ForgetSourceScopeGrant drops a rule's resolved scope. It is called on a REFUSAL or a +// deletion — never on an unevaluatable policy, which must retain the scope. +func (m *Manager) ForgetSourceScopeGrant(rule k8stypes.NamespacedName) { + scope := m.sourceScope() + scope.mu.Lock() + defer scope.mu.Unlock() + delete(scope.grants, rule) +} + +// SourceScopeSpecHash fingerprints the part of a WatchRule that decides its resolved scope: every +// item's requested source namespace, in order, plus the rule's own namespace (the value an omitted +// item resolves to). A change to any of them means the rule is ESTABLISHING a new scope rather than +// maintaining its old one, so the retained grant must not be reused. +func SourceScopeSpecHash(rule *configv1alpha3.WatchRule) string { + parts := make([]string, 0, len(rule.Spec.Rules)+1) + parts = append(parts, "own="+rule.Namespace) + for i := range rule.Spec.Rules { + parts = append(parts, fmt.Sprintf("%d=%s", i, rule.Spec.Rules[i].SourceNamespace)) + } + return strings.Join(parts, "\x00") +} + +func (s *sourceNamespaceScope) want(clusterID string) { + s.mu.Lock() + defer s.mu.Unlock() + s.wanted[clusterID] = struct{}{} +} + +func (s *sourceNamespaceScope) snapshot(clusterID string) (namespaceSnapshot, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + snap, ok := s.snapshots[clusterID] + return snap, ok +} + +func (s *sourceNamespaceScope) wantedClusters() []string { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]string, 0, len(s.wanted)) + for id := range s.wanted { + out = append(out, id) + } + return out +} + +// store records a fresh snapshot and reports whether the OBSERVABLE state changed — a label edit, +// a namespace appearing or disappearing, or the usability of the cache itself flipping. Only a +// change enqueues, so a steady cluster produces no reconcile churn on every refresh tick. +func (s *sourceNamespaceScope) store(clusterID string, next namespaceSnapshot) bool { + s.mu.Lock() + defer s.mu.Unlock() + previous, had := s.snapshots[clusterID] + s.snapshots[clusterID] = next + if !had { + return true + } + if previous.synced != next.synced || previous.forbidden != next.forbidden { + return true + } + if !next.synced { + // Two consecutive unusable refreshes are not an observable change worth a reconcile. + return false + } + return !labelSetsEqual(previous.labels, next.labels) +} + +func labelSetsEqual(a, b map[string]map[string]string) bool { + if len(a) != len(b) { + return false + } + for name, aLabels := range a { + bLabels, ok := b[name] + if !ok || !maps.Equal(aLabels, bLabels) { + return false + } + } + return true +} + +// refreshSourceNamespaceScopes re-lists Namespaces on every source cluster some selector policy +// has asked about, and enqueues the affected GitTargets when the answer changed. It runs on the +// manager's existing reconcile cadence, so a grant or revocation lands within one interval rather +// than waiting for a WatchRule to happen to be edited. +// +// Each cluster is listed under its OWN timeout and they are listed CONCURRENTLY, for the reason +// refreshRemoteCatalogsConcurrently already documents one file over: serially, total latency grows +// as clusterCount × the slowest cluster, so one tenant's unreachable source cluster delays every +// other tenant's grants and revocations. The timeout is the other half of that — a source config +// deliberately carries no rest.Config.Timeout (its watches must stay open) and only its DIAL is +// bounded, so a cluster that accepts the connection and then hangs on the response would otherwise +// block ReconcileForRuleChange forever, and the watched-type tables and target watches after this +// call would never refresh at all. +func (m *Manager) refreshSourceNamespaceScopes(ctx context.Context) { + scope := m.sourceScope() + clusters := scope.wantedClusters() + if len(clusters) == 0 { + return + } + + sem := make(chan struct{}, maxConcurrentSourceNamespaceRefreshes) + var wg sync.WaitGroup + for _, clusterID := range clusters { + wg.Add(1) + sem <- struct{}{} + go func(clusterID string) { + defer wg.Done() + defer func() { <-sem }() + + listCtx, cancel := context.WithTimeout(ctx, sourceNamespaceListTimeout) + defer cancel() + + next := m.listSourceNamespaces(listCtx, clusterID) + if scope.store(clusterID, next) { + m.enqueueSourceNamespaceChange(clusterID) + } + }(clusterID) + } + wg.Wait() +} + +// listSourceNamespaces reads one source cluster's Namespace labels, classifying failure into the +// TERMINAL (Forbidden — the credential may never list namespaces) and RETRYABLE (everything else) +// halves the three-valued contract depends on. A failed refresh never discards the previous +// snapshot's usefulness by itself: a cluster that was synced and then hits a retryable error keeps +// answering from what it last saw, so a momentary blip does not revoke anything. +func (m *Manager) listSourceNamespaces(ctx context.Context, clusterID string) namespaceSnapshot { + previous, _ := m.sourceScope().snapshot(clusterID) + + dc, err := m.clusterDynamicClient(ctx, clusterID) + if err != nil { + return retainOnRetryableError(previous, err) + } + + list, err := dc.Resource(namespacesGVR()).List(ctx, metav1.ListOptions{}) + if err != nil { + if apierrors.IsForbidden(err) { + m.Log.Info("source cluster forbids listing Namespaces; selector-based "+ + "allowedSourceNamespaces cannot be evaluated there (exact names still work)", + "clusterID", clusterID) + return namespaceSnapshot{forbidden: true, err: err} + } + return retainOnRetryableError(previous, err) + } + + labels := make(map[string]map[string]string, len(list.Items)) + for i := range list.Items { + item := &list.Items[i] + labels[item.GetName()] = maps.Clone(item.GetLabels()) + } + return namespaceSnapshot{labels: labels, synced: true} +} + +// retainOnRetryableError keeps a previously synced snapshot usable across a transient failure, +// recording the error for the message. An unsynced cluster stays unsynced ("cannot say yet"). +func retainOnRetryableError(previous namespaceSnapshot, err error) namespaceSnapshot { + return namespaceSnapshot{ + labels: previous.labels, + synced: previous.synced, + forbidden: false, + err: err, + } +} + +// SourceNamespaceEvents returns the channel the WatchRule controller wires via source.Channel so a +// source-cluster Namespace label change re-reconciles the rules it grants or revokes. It carries +// GitTargets — the object the rules are mapped from — and is lazily created so a zero-value +// Manager (tests) and the cmd-wired Manager share one channel. +func (m *Manager) SourceNamespaceEvents() <-chan event.GenericEvent { + m.sourceNamespaceEventsMu.Lock() + defer m.sourceNamespaceEventsMu.Unlock() + if m.sourceNamespaceEventsCh == nil { + m.sourceNamespaceEventsCh = make(chan event.GenericEvent, sourceNamespaceEventsBuffer) + } + return m.sourceNamespaceEventsCh +} + +// enqueueSourceNamespaceChange emits a non-blocking GenericEvent for every GitTarget mirroring +// from a cluster whose Namespace labels changed. The send is best-effort: a full buffer means a +// reconcile is already pending, and the periodic requeue is the backstop. +func (m *Manager) enqueueSourceNamespaceChange(clusterID string) { + m.sourceNamespaceEventsMu.Lock() + ch := m.sourceNamespaceEventsCh + m.sourceNamespaceEventsMu.Unlock() + if ch == nil { + return + } + + m.gitTargetClustersMu.Lock() + affected := make([]types.ResourceReference, 0) + for key, id := range m.gitTargetClusters { + if id == clusterID { + affected = append(affected, resourceReferenceFromKey(key)) + } + } + m.gitTargetClustersMu.Unlock() + + for _, gitDest := range affected { + evt := event.GenericEvent{Object: &configv1alpha3.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: gitDest.Name, Namespace: gitDest.Namespace}, + }} + select { + case ch <- evt: + default: + } + } +} diff --git a/internal/watch/source_namespace_stream_summary_test.go b/internal/watch/source_namespace_stream_summary_test.go new file mode 100644 index 00000000..35724813 --- /dev/null +++ b/internal/watch/source_namespace_stream_summary_test.go @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: Apache-2.0 + +package watch + +import ( + "testing" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + 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/rulestore" + "github.com/ConfigButler/gitops-reverser/internal/types" +) + +// This is the regression test for the e2e-only bug the first cut of this work shipped with: an +// authorized override compiled and streamed, but StreamSummaryForWatchRule looked its stream up by +// the rule's OWN namespace while the stream is keyed on the WATCHED namespace. The two differ +// exactly when a source namespace is set, so the rule reported StreamsRunning=False — and therefore +// Ready=False — forever, even though its stream was live. Mock WatchManagers hid it; only the real +// summary path exercises the key. +// +// PR 4 raises the stakes: a `sourceNamespace: "*"` item's namespace set does not exist in the spec +// AT ALL, so a summary rebuilt from the spec cannot even guess the keys. The roll-up therefore reads +// the COMPILED rule. + +func srcnsSummaryManager(t *testing.T) *Manager { + t.Helper() + return &Manager{ + Log: logr.Discard(), + RuleStore: rulestore.NewStore(), + resourceCatalog: newCommonTestCatalog(t), + discoveryClient: commonTestDiscoveryClient(), + } +} + +// seedStreamState records one target's per-key stream status, the surface the summary reads. +func (m *Manager) seedStreamState(gitDest types.ResourceReference, key targetWatchKey, status targetStreamStatus) { + m.targetWatchesMu.Lock() + defer m.targetWatchesMu.Unlock() + if m.targetStreamStates == nil { + m.targetStreamStates = map[string]map[targetWatchKey]targetStreamStatus{} + } + states := m.targetStreamStates[gitDest.Key()] + if states == nil { + states = map[targetWatchKey]targetStreamStatus{} + m.targetStreamStates[gitDest.Key()] = states + } + states[key] = status +} + +func srcnsOverrideRule(sourceNamespace string) configv1alpha3.WatchRule { + return configv1alpha3.WatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: "repo-config-rule", Namespace: "tenant-acme"}, + Spec: configv1alpha3.WatchRuleSpec{ + TargetRef: configv1alpha3.LocalTargetReference{Name: "acme"}, + Rules: []configv1alpha3.ResourceRule{{ + Resources: []string{"configmaps"}, SourceNamespace: sourceNamespace, + }}, + }, + } +} + +// compileForSummary seeds the store the way CompileWatchRule would, with an explicit resolved scope. +func compileForSummary(m *Manager, rule configv1alpha3.WatchRule, scope [][]string) { + m.RuleStore.AddOrUpdateWatchRule( + rule, scope, "acme", "tenant-acme", "git", "tenant-acme", "main", "tenants/acme") +} + +func srcnsGitDest() types.ResourceReference { + return types.NewResourceReference("acme", "tenant-acme") +} + +func srcnsConfigMaps() schema.GroupVersionResource { + return schema.GroupVersionResource{Version: "v1", Resource: "configmaps"} +} + +// TestStreamSummaryForWatchRule_KeysOnResolvedSourceNamespace is the fix asserted: a stream keyed +// on the WATCHED namespace is found, and the rule reports StreamsRunning. +func TestStreamSummaryForWatchRule_KeysOnResolvedSourceNamespace(t *testing.T) { + m := srcnsSummaryManager(t) + rule := srcnsOverrideRule("repo-config") + compileForSummary(m, rule, itemScope("repo-config")) + + // The data plane keyed the live stream on the SOURCE namespace ("repo-config"). + m.seedStreamState(srcnsGitDest(), + targetWatchKey{GVR: srcnsConfigMaps(), Namespace: "repo-config"}, + targetStreamStatus{state: StreamStateStreaming}) + + summary := m.StreamSummaryForWatchRule(rule) + + assert.Equal(t, 1, summary.Total) + assert.Equal(t, 1, summary.Ready) + assert.True(t, summary.StreamsRunning(), + "an override's readiness must be looked up by the WATCHED namespace, not the rule's own") +} + +// TestStreamSummaryForWatchRule_WrongNamespaceKeyMisses is the negative proof: a stream keyed on +// the rule's OWN namespace (the bug's behavior) is NOT this rule's stream, so the summary correctly +// finds nothing ready. This pins the distinction so a future refactor cannot silently reintroduce +// the config-plane-namespace lookup. +func TestStreamSummaryForWatchRule_WrongNamespaceKeyMisses(t *testing.T) { + m := srcnsSummaryManager(t) + rule := srcnsOverrideRule("repo-config") + compileForSummary(m, rule, itemScope("repo-config")) + + // A stream keyed on the CONFIG-PLANE namespace is a different stream entirely. + m.seedStreamState(srcnsGitDest(), + targetWatchKey{GVR: srcnsConfigMaps(), Namespace: "tenant-acme"}, + targetStreamStatus{state: StreamStateStreaming}) + + summary := m.StreamSummaryForWatchRule(rule) + + assert.Equal(t, 1, summary.Total, "the rule still expects its one type") + assert.Equal(t, 0, summary.Ready, "but nothing ready is keyed under the watched namespace") + assert.False(t, summary.StreamsRunning()) +} + +// TestStreamSummaryForWatchRule_WildcardReadsTheCompiledRule is the §5 hazard. A wildcard's resolved +// namespaces exist ONLY in the compiled rule, so a summary rebuilt from the spec would look for +// streams under keys that were never opened and report a perfectly healthy rule as permanently +// not-ready. +func TestStreamSummaryForWatchRule_WildcardReadsTheCompiledRule(t *testing.T) { + m := srcnsSummaryManager(t) + rule := srcnsOverrideRule(configv1alpha3.SourceNamespaceWildcard) + compileForSummary(m, rule, itemScope("repo-config", "team-payments")) + + for _, ns := range []string{"repo-config", "team-payments"} { + m.seedStreamState(srcnsGitDest(), + targetWatchKey{GVR: srcnsConfigMaps(), Namespace: ns}, + targetStreamStatus{state: StreamStateStreaming}) + } + + summary := m.StreamSummaryForWatchRule(rule) + + assert.Equal(t, 1, summary.Total, "the roll-up counts TYPES, not (type × namespace) streams") + assert.Equal(t, 1, summary.Ready) + assert.True(t, summary.StreamsRunning(), + "a wildcard rule whose streams are running must report ready") +} + +// TestStreamSummaryForWatchRule_WildcardWithOnePendingNamespaceIsNotReady: the roll-up folds every +// resolved namespace of a type, so one namespace still replaying holds the type back. +func TestStreamSummaryForWatchRule_WildcardWithOnePendingNamespaceIsNotReady(t *testing.T) { + m := srcnsSummaryManager(t) + rule := srcnsOverrideRule(configv1alpha3.SourceNamespaceWildcard) + compileForSummary(m, rule, itemScope("repo-config", "team-payments")) + + m.seedStreamState(srcnsGitDest(), + targetWatchKey{GVR: srcnsConfigMaps(), Namespace: "repo-config"}, + targetStreamStatus{state: StreamStateStreaming}) + // team-payments has no recorded state at all, which the roll-up reads as still replaying. + + summary := m.StreamSummaryForWatchRule(rule) + + assert.False(t, summary.StreamsRunning(), + "one namespace of a wildcard still converging must hold the type back") +} + +// TestStreamSummaryForWatchRule_UncompiledRuleExpectsNoStreams: a rule the gate refused (or one the +// store has not seeded yet) expects nothing, rather than inventing keys from its spec. +func TestStreamSummaryForWatchRule_UncompiledRuleExpectsNoStreams(t *testing.T) { + m := srcnsSummaryManager(t) + + summary := m.StreamSummaryForWatchRule(srcnsOverrideRule("repo-config")) + + assert.Equal(t, 0, summary.Total) + assert.False(t, summary.StreamsRunning()) +} + +// TestStreamSummaryForWatchRule_LegacyRuleUnchanged: with no override the watched namespace IS the +// rule's own, so the legacy path is unaffected. +func TestStreamSummaryForWatchRule_LegacyRuleUnchanged(t *testing.T) { + m := srcnsSummaryManager(t) + rule := srcnsOverrideRule("") // legacy: watches its own namespace + compileForSummary(m, rule, ownNamespaceScope(rule)) + + m.seedStreamState(srcnsGitDest(), + targetWatchKey{GVR: srcnsConfigMaps(), Namespace: "tenant-acme"}, + targetStreamStatus{state: StreamStateStreaming}) + + summary := m.StreamSummaryForWatchRule(rule) + + assert.Equal(t, 1, summary.Ready) + assert.True(t, summary.StreamsRunning()) +} diff --git a/internal/watch/source_namespace_test.go b/internal/watch/source_namespace_test.go new file mode 100644 index 00000000..d55fa622 --- /dev/null +++ b/internal/watch/source_namespace_test.go @@ -0,0 +1,584 @@ +// SPDX-License-Identifier: Apache-2.0 + +package watch + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/go-logr/logr" + "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/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + k8stypes "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/dynamic" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/authz" + "github.com/ConfigButler/gitops-reverser/internal/rulestore" +) + +const ( + snbTenantNS = "tenant-acme" + snbSourceNS = "repo-config" + snbTarget = "acme" + snbRule = "repo-config-rule" + snbProvider = "workspaces" +) + +func snbGitTarget(policy *configv1alpha3.NamespaceMatcher) *configv1alpha3.GitTarget { + return &configv1alpha3.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: snbTarget, Namespace: snbTenantNS}, + Spec: configv1alpha3.GitTargetSpec{ + ProviderRef: configv1alpha3.GitProviderReference{Name: "git"}, + ClusterProviderRef: &configv1alpha3.ClusterProviderReference{Name: snbProvider}, + Branch: "main", + Path: "tenants/acme", + AllowedSourceNamespaces: policy, + }, + } +} + +func snbGitProvider() *configv1alpha3.GitProvider { + return &configv1alpha3.GitProvider{ + ObjectMeta: metav1.ObjectMeta{Name: "git", Namespace: snbTenantNS}, + } +} + +func snbClusterProvider(delegate bool) *configv1alpha3.ClusterProvider { + return &configv1alpha3.ClusterProvider{ + ObjectMeta: metav1.ObjectMeta{Name: snbProvider}, + Spec: configv1alpha3.ClusterProviderSpec{ + AllowedNamespaces: &configv1alpha3.NamespaceMatcher{Names: []string{snbTenantNS}}, + AllowSourceNamespaceOverride: delegate, + }, + } +} + +// snbWatchRule builds a rule with one item per given rules[].sourceNamespace ("" = omitted). +func snbWatchRule(sourceNamespaces ...string) *configv1alpha3.WatchRule { + items := make([]configv1alpha3.ResourceRule, 0, len(sourceNamespaces)) + for _, ns := range sourceNamespaces { + items = append(items, configv1alpha3.ResourceRule{ + Resources: []string{"configmaps"}, SourceNamespace: ns, + }) + } + return &configv1alpha3.WatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: snbRule, Namespace: snbTenantNS}, + Spec: configv1alpha3.WatchRuleSpec{ + TargetRef: configv1alpha3.LocalTargetReference{Name: snbTarget}, + Rules: items, + }, + } +} + +func snbManager(t *testing.T, objects ...client.Object) *Manager { + t.Helper() + return &Manager{ + Client: fake.NewClientBuilder().WithScheme(makeScheme(t)).WithObjects(objects...).Build(), + Log: logr.Discard(), + RuleStore: rulestore.NewStore(), + } +} + +func snbCompiledNames(m *Manager) []string { + names := []string{} + for _, r := range m.RuleStore.SnapshotWatchRules() { + names = append(names, r.Source.Name) + } + return names +} + +// TestBootstrap_DeniedSourceNamespaceIsNotCompiledOnRestart is the second must-have test. +// +// Bootstrap seeds the store BEFORE the first reconcile and then marks it ready, so a gate the +// reconciler alone enforced would be bypassed for the whole startup window — and that window +// reopens on EVERY operator restart, which is exactly when nobody is watching. This asserts the +// state at the moment MarkReady() returns, which is the only moment that proves it: a passing +// reconciler test suite actively hides this failure. +func TestBootstrap_DeniedSourceNamespaceIsNotCompiledOnRestart(t *testing.T) { + m := snbManager(t, + // The provider does NOT delegate, so the override is refused. + snbGitTarget(&configv1alpha3.NamespaceMatcher{Names: []string{snbSourceNS}}), + snbGitProvider(), + snbClusterProvider(false), + snbWatchRule(snbSourceNS), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snbTenantNS}}, + ) + + require.NoError(t, m.bootstrapRuleStore(context.Background(), logr.Discard()), + "a refused rule is a refusal, not a startup failure") + + assert.Empty(t, snbCompiledNames(m), + "a denied override must not be compiled at bootstrap; otherwise every restart reopens "+ + "the window the gate exists to close") + assert.True(t, m.RuleStore.IsReady(), + "the store must still be marked ready so one refused rule cannot wedge the data plane") +} + +// TestBootstrap_LegacyWatchRuleStillCompiles is the upgrade guarantee at the bootstrap call site: +// a rule that omits sourceNamespace against a target with no policy must seed exactly as before. +func TestBootstrap_LegacyWatchRuleStillCompiles(t *testing.T) { + m := snbManager(t, + snbGitTarget(nil), snbGitProvider(), snbClusterProvider(false), + snbWatchRule(""), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snbTenantNS}}, + ) + + require.NoError(t, m.bootstrapRuleStore(context.Background(), logr.Discard())) + + compiled := m.RuleStore.SnapshotWatchRules() + require.Len(t, compiled, 1) + assert.Equal(t, snbRule, compiled[0].Source.Name) + assert.Equal(t, []string{snbTenantNS}, compiled[0].ResourceRules[0].SourceNamespaces, + "a legacy item's source namespace is the rule's own namespace") + assert.Equal(t, "main", compiled[0].Branch) +} + +// TestBootstrap_AuthorizedOverrideCompilesWithItsSourceNamespace proves the admitted override path +// seeds the EFFECTIVE namespace, not the rule's own. +func TestBootstrap_AuthorizedOverrideCompilesWithItsSourceNamespace(t *testing.T) { + m := snbManager(t, + snbGitTarget(&configv1alpha3.NamespaceMatcher{Names: []string{snbSourceNS}}), + snbGitProvider(), snbClusterProvider(true), + snbWatchRule(snbSourceNS), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snbTenantNS}}, + ) + + require.NoError(t, m.bootstrapRuleStore(context.Background(), logr.Discard())) + + compiled := m.RuleStore.SnapshotWatchRules() + require.Len(t, compiled, 1) + assert.Equal(t, []string{snbSourceNS}, compiled[0].ResourceRules[0].SourceNamespaces) + assert.Equal(t, snbTenantNS, compiled[0].Source.Namespace, + "Source still names the WatchRule object in the control plane") +} + +// TestCompileWatchRule_TerminalRefusalRemovesAnAlreadyCompiledRule is the REVOCATION contract at +// the shared compile path: a rule accepted earlier and then denied by a tightened policy must have +// its compiled rule REMOVED, not merely reported unready. A gate that only writes a condition is +// not a gate. +func TestCompileWatchRule_TerminalRefusalRemovesAnAlreadyCompiledRule(t *testing.T) { + ctx := context.Background() + m := snbManager(t, + snbGitTarget(&configv1alpha3.NamespaceMatcher{Names: []string{snbSourceNS}}), + snbGitProvider(), snbClusterProvider(true), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snbTenantNS}}, + ) + + rule := *snbWatchRule(snbSourceNS) + target := *snbGitTarget(&configv1alpha3.NamespaceMatcher{Names: []string{snbSourceNS}}) + provider := *snbGitProvider() + + resolved, err := CompileWatchRule(ctx, m.Client, m.RuleStore, m, rule, target, provider) + require.NoError(t, err) + require.True(t, resolved.Admitted()) + require.Len(t, m.RuleStore.SnapshotWatchRules(), 1, "precondition: the rule is compiled") + + // The target owner tightens the policy so it no longer admits the namespace. + tightened := *snbGitTarget(&configv1alpha3.NamespaceMatcher{Names: []string{"something-else"}}) + + resolved, err = CompileWatchRule(ctx, m.Client, m.RuleStore, m, rule, tightened, provider) + + require.NoError(t, err) + assert.Equal(t, authz.SourceScopeDenied, resolved.Verdict) + assert.Empty(t, m.RuleStore.SnapshotWatchRules(), + "a revoked rule must be removed from the store, not left running with a bad condition") +} + +// TestCompileWatchRule_RetainsScopeWhenPolicyBecomesUnevaluatable is the MAINTAINING half of the +// establishing/maintaining contract, and the one that protects a tenant's Git content. +// +// A rule that already holds a resolved scope must keep it — and keep running — when its policy +// becomes unevaluatable. Narrowing to nothing there would feed an empty set into a resync sweep and +// DELETE the tenant's manifests over a transient source-cluster outage. +func TestCompileWatchRule_RetainsScopeWhenPolicyBecomesUnevaluatable(t *testing.T) { + ctx := context.Background() + m := snbManager(t, + snbGitTarget(nil), snbGitProvider(), snbClusterProvider(true), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snbTenantNS}}, + ) + + rule := *snbWatchRule(snbSourceNS) + provider := *snbGitProvider() + named := *snbGitTarget(&configv1alpha3.NamespaceMatcher{Names: []string{snbSourceNS}}) + + // Establish the grant through an exact name (no source-cluster access needed). + resolved, err := CompileWatchRule(ctx, m.Client, m.RuleStore, m, rule, named, provider) + require.NoError(t, err) + require.True(t, resolved.Admitted()) + require.Len(t, m.RuleStore.SnapshotWatchRules(), 1) + + // The owner swaps it for a selector, and the source cluster's Namespace list is forbidden. + selector := *snbGitTarget(&configv1alpha3.NamespaceMatcher{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirrorable": "true"}}, + }) + m.sourceScope().store(snbProvider, namespaceSnapshot{forbidden: true}) + + resolved, err = CompileWatchRule(ctx, m.Client, m.RuleStore, m, rule, selector, provider) + + require.NoError(t, err) + assert.Equal(t, authz.SourceScopeUnknown, resolved.Verdict, + "a retained scope is Unknown, never a terminal failure") + + // The ABSENCE of the sweep is the assertion that matters, and it is a property of the RESOLVED + // SCOPE, not of the condition: the watched-type table (and therefore every resync scope) is + // projected from the compiled rule's SourceNamespaces. A narrowing to the empty set would leave + // the rule present and the condition Unknown while quietly emptying the desired set — which is + // what a mark-and-sweep resync turns into a deletion of the tenant's manifests. + compiled := m.RuleStore.SnapshotWatchRules() + require.Len(t, compiled, 1, + "the last known-good scope keeps running: no narrowing, no sweep") + assert.Equal(t, []string{snbSourceNS}, compiled[0].ResourceRules[0].SourceNamespaces, + "the retained scope must be the LAST KNOWN-GOOD set, not narrowed and not widened") + + // And the resolved scope stays recorded under the same spec, so a further unevaluatable + // reconcile keeps retaining rather than flipping terminal on the second pass. + retained, ok := m.RetainedSourceScope( + k8stypes.NamespacedName{Name: snbRule, Namespace: snbTenantNS}, SourceScopeSpecHash(&rule)) + require.True(t, ok, "'cannot say' must never forget the grant") + assert.Equal(t, [][]string{{snbSourceNS}}, retained) +} + +// TestCompileWatchRule_UnevaluatablePolicyEstablishesNothing is the ESTABLISHING half. With no +// prior grant, the same unevaluatable policy must compile NOTHING — the grant is not established, +// so nothing runs and nothing is swept. +func TestCompileWatchRule_UnevaluatablePolicyEstablishesNothing(t *testing.T) { + ctx := context.Background() + m := snbManager(t, + snbGitTarget(nil), snbGitProvider(), snbClusterProvider(true), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snbTenantNS}}, + ) + m.sourceScope().store(snbProvider, namespaceSnapshot{forbidden: true}) + + selector := *snbGitTarget(&configv1alpha3.NamespaceMatcher{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirrorable": "true"}}, + }) + + resolved, err := CompileWatchRule( + ctx, m.Client, m.RuleStore, m, *snbWatchRule(snbSourceNS), selector, *snbGitProvider()) + + require.NoError(t, err) + assert.Equal(t, authz.SourceScopeUnavailable, resolved.Verdict, + "with no scope ever resolved this is terminal, not a retained scope") + assert.Empty(t, m.RuleStore.SnapshotWatchRules()) +} + +// TestCompileWatchRule_RetentionIsSpecSpecific: a rule that EDITS its items is establishing a NEW +// scope, so a stale grant recorded under the previous spec must not let an unevaluatable policy +// through. Keying the memory by spec hash rather than by item index is also what stops a REORDER +// from making one item inherit another item's grant. +func TestCompileWatchRule_RetentionIsSpecSpecific(t *testing.T) { + ctx := context.Background() + m := snbManager(t, + snbGitTarget(nil), snbGitProvider(), snbClusterProvider(true), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snbTenantNS}}, + ) + granted := snbWatchRule(snbSourceNS) + grantKey := k8stypes.NamespacedName{Name: snbRule, Namespace: snbTenantNS} + m.RecordSourceScopeGrant(grantKey, SourceScopeSpecHash(granted), [][]string{{snbSourceNS}}) + m.sourceScope().store(snbProvider, namespaceSnapshot{forbidden: true}) + + selector := *snbGitTarget(&configv1alpha3.NamespaceMatcher{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirrorable": "true"}}, + }) + + // The SAME spec retains its grant — that is the maintaining case. + resolved, err := CompileWatchRule( + ctx, m.Client, m.RuleStore, m, *granted, selector, *snbGitProvider()) + require.NoError(t, err) + assert.Equal(t, authz.SourceScopeUnknown, resolved.Verdict, + "the spec that established the grant keeps it, and reports Unknown rather than Failed") + + // An EDITED spec is establishing a new scope, so the stale grant must not let it through. + resolved, err = CompileWatchRule( + ctx, m.Client, m.RuleStore, m, *snbWatchRule("some-other-namespace"), selector, *snbGitProvider()) + + require.NoError(t, err) + assert.Equal(t, authz.SourceScopeUnavailable, resolved.Verdict, + "a grant recorded under a different spec must not be retained across an edit") + + _, retained := m.RetainedSourceScope(grantKey, SourceScopeSpecHash(granted)) + assert.False(t, retained, "the terminal refusal drops the grant so nothing stale survives it") +} + +// TestSourceScopeSpecHash_MovesWithEveryScopeInput pins what "the same spec" means for retention: any +// change that could move the resolved scope must discard the grant, and a reorder must not look +// like no change at all. +func TestSourceScopeSpecHash_MovesWithEveryScopeInput(t *testing.T) { + base := snbWatchRule("", snbSourceNS) + + assert.Equal(t, SourceScopeSpecHash(base), SourceScopeSpecHash(snbWatchRule("", snbSourceNS)), + "an unchanged spec must hash identically, or nothing is ever retained") + assert.NotEqual(t, SourceScopeSpecHash(base), SourceScopeSpecHash(snbWatchRule(snbSourceNS, "")), + "a REORDER changes which item holds which grant, so it must discard the memory") + assert.NotEqual(t, SourceScopeSpecHash(base), SourceScopeSpecHash(snbWatchRule("")), + "dropping an item changes the spec") + assert.NotEqual(t, SourceScopeSpecHash(base), + SourceScopeSpecHash(snbWatchRule("", configv1alpha3.SourceNamespaceWildcard)), + "changing an item's requested namespace changes the spec") + + moved := snbWatchRule("", snbSourceNS) + moved.Namespace = "tenant-zen" + assert.NotEqual(t, SourceScopeSpecHash(base), SourceScopeSpecHash(moved), + "the rule's own namespace is what an omitted item resolves to, so it is part of the spec") +} + +// TestResolveSourceNamespace_ThreeValuedResults pins the source-scope service's own contract: an +// unsynced cache is "cannot say yet", a Forbidden list is terminal, and a synced cache gives a real +// yes/no. Collapsing any of these into another is how a transient outage becomes a stopped stream. +func TestResolveSourceNamespace_ThreeValuedResults(t *testing.T) { + ctx := context.Background() + target := snbGitTarget(&configv1alpha3.NamespaceMatcher{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirrorable": "true"}}, + }) + + t.Run("unsynced cache is Unknown, never Denied", func(t *testing.T) { + m := snbManager(t) + result := m.ResolveSourceNamespace(ctx, target, snbSourceNS) + assert.Equal(t, authz.SourceScopeUnknown, result.Verdict) + }) + + t.Run("forbidden Namespace list is terminal", func(t *testing.T) { + m := snbManager(t) + m.sourceScope().store(snbProvider, namespaceSnapshot{forbidden: true}) + result := m.ResolveSourceNamespace(ctx, target, snbSourceNS) + assert.Equal(t, authz.SourceScopeUnavailable, result.Verdict) + assert.Contains(t, result.Message, "use exact names") + }) + + t.Run("synced cache with matching labels admits", func(t *testing.T) { + m := snbManager(t) + m.sourceScope().store(snbProvider, namespaceSnapshot{ + synced: true, + labels: map[string]map[string]string{snbSourceNS: {"mirrorable": "true"}}, + }) + result := m.ResolveSourceNamespace(ctx, target, snbSourceNS) + assert.Equal(t, authz.SourceScopeAdmitted, result.Verdict) + }) + + t.Run("synced cache with non-matching labels denies", func(t *testing.T) { + m := snbManager(t) + m.sourceScope().store(snbProvider, namespaceSnapshot{ + synced: true, + labels: map[string]map[string]string{snbSourceNS: {"mirrorable": "false"}}, + }) + result := m.ResolveSourceNamespace(ctx, target, snbSourceNS) + assert.Equal(t, authz.SourceScopeDenied, result.Verdict) + }) + + t.Run("synced cache missing the namespace denies with a legible cause", func(t *testing.T) { + m := snbManager(t) + m.sourceScope().store(snbProvider, namespaceSnapshot{ + synced: true, + labels: map[string]map[string]string{"elsewhere": {"mirrorable": "true"}}, + }) + result := m.ResolveSourceNamespace(ctx, target, snbSourceNS) + assert.Equal(t, authz.SourceScopeDenied, result.Verdict) + assert.Contains(t, result.Message, "does not exist") + }) +} + +// TestResolveSourceNamespace_ReadsTheGitTargetsOwnCluster is the divergent-labels test, and the +// divergence is the entire point: with both clusters labelled the same way, this passes against a +// resolver reading either one. +// +// A GitTarget's policy is a statement about ITS source cluster. Resolving it through the +// Declare-time cache — which defaults an undeclared GitTarget to the config plane — means a remote +// target's selector is answered from config-plane Namespace labels during the window between the +// WatchRule reconcile and the GitTarget controller's Declare. Those two controllers run +// concurrently after a restart, so the window is ordinary operation, not a corner case. Here the +// config plane would admit and the real source cluster would not; admitting is the bug. +func TestResolveSourceNamespace_ReadsTheGitTargetsOwnCluster(t *testing.T) { + ctx := context.Background() + target := snbGitTarget(&configv1alpha3.NamespaceMatcher{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirrorable": "true"}}, + }) + m := snbManager(t) + + // The config plane carries a same-named namespace that DOES match, plus one the source cluster + // has never heard of. Neither may reach a decision about this target. + m.sourceScope().store(configPlaneClusterID, namespaceSnapshot{ + synced: true, + labels: map[string]map[string]string{ + snbSourceNS: {"mirrorable": "true"}, + "only-up-here": {"mirrorable": "true"}, + }, + }) + m.sourceScope().store(snbProvider, namespaceSnapshot{ + synced: true, + labels: map[string]map[string]string{snbSourceNS: {"mirrorable": "false"}}, + }) + + result := m.ResolveSourceNamespace(ctx, target, snbSourceNS) + assert.Equal(t, authz.SourceScopeDenied, result.Verdict, + "the source cluster's labels decide, not a same-named namespace on the config plane") + + names, enumeration := m.EnumerateSourceNamespaces(ctx, target) + require.Equal(t, authz.SourceScopeAdmitted, enumeration.Verdict) + assert.Empty(t, names, + "a wildcard expands over the SOURCE cluster's namespaces; the config plane's must not leak in") + + // The refresh loop must be armed for the same cluster the answer came from — otherwise the + // snapshot that gets refreshed and the snapshot that gets read are two different clusters, and + // the enqueue that carries a revocation matches no GitTarget at all. + assert.Equal(t, []string{snbProvider}, m.sourceScope().wantedClusters()) +} + +// stubNamespaceLister is a dynamic.Interface serving one canned Namespace list, which reports the +// context its List was given. Only List is implemented: the embedded interfaces are nil, so any +// other call panics loudly instead of passing silently. +type stubNamespaceLister struct { + dynamic.Interface + + onList func(ctx context.Context) +} + +func (s stubNamespaceLister) Resource(schema.GroupVersionResource) dynamic.NamespaceableResourceInterface { + return stubNamespaceResource{onList: s.onList} +} + +type stubNamespaceResource struct { + dynamic.NamespaceableResourceInterface + + onList func(ctx context.Context) +} + +func (s stubNamespaceResource) List( + ctx context.Context, _ metav1.ListOptions, +) (*unstructured.UnstructuredList, error) { + s.onList(ctx) + return &unstructured.UnstructuredList{}, nil +} + +// armStubCluster makes clusterID a cluster the refresh loop wants, backed by a stub client. +func armStubCluster(m *Manager, clusterID string, onList func(context.Context)) { + m.sourceScope().want(clusterID) + m.cluster(clusterID).dynamicClient = stubNamespaceLister{onList: onList} +} + +// TestRefreshSourceNamespaceScopes_BoundsEveryClustersList pins the deadline. +// +// A source cluster's REST config deliberately carries no request timeout — its watches must stay +// open — and only its dial is bounded, so a cluster that accepts the connection and then never +// answers would block this refresh forever. Because the refresh runs inside +// ReconcileForRuleChange, "forever" also means the watched-type tables and target watches after it +// never refresh again, for every tenant. +func TestRefreshSourceNamespaceScopes_BoundsEveryClustersList(t *testing.T) { + m := snbManager(t) + + var mu sync.Mutex + remaining := map[string]time.Duration{} + unbounded := []string{} + + for _, id := range []string{"cluster-a", "cluster-b"} { + armStubCluster(m, id, func(ctx context.Context) { + mu.Lock() + defer mu.Unlock() + deadline, ok := ctx.Deadline() + if !ok { + unbounded = append(unbounded, id) + return + } + remaining[id] = time.Until(deadline) + }) + } + + m.refreshSourceNamespaceScopes(context.Background()) + + assert.Empty(t, unbounded, "every cluster's list must run under its own deadline") + require.Len(t, remaining, 2, "every wanted cluster must be listed") + for id, left := range remaining { + assert.Positive(t, left, "%s got an already-expired deadline", id) + assert.LessOrEqual(t, left, sourceNamespaceListTimeout, + "%s got a deadline longer than the bound", id) + } +} + +// TestRefreshSourceNamespaceScopes_OneWedgedClusterCannotStarveTheOthers pins the fan-out. +// +// Serially, total latency grows as clusterCount × the slowest cluster, so ONE tenant's unreachable +// source cluster delays every other tenant's grants and revocations — the same failure the catalog +// refresh already had, and fixed, one file over. The barrier is what makes this a real test: it +// only falls through once every cluster is inside its list at the same moment, which a serial loop +// can never achieve. +func TestRefreshSourceNamespaceScopes_OneWedgedClusterCannotStarveTheOthers(t *testing.T) { + const clusters = 3 + + m := snbManager(t) + entered := make(chan struct{}, clusters) + release := make(chan struct{}) + var concurrent atomic.Bool + concurrent.Store(true) + + for i := range clusters { + armStubCluster(m, fmt.Sprintf("cluster-%d", i), func(context.Context) { + entered <- struct{}{} + select { + case <-release: + case <-time.After(2 * time.Second): + concurrent.Store(false) + } + }) + } + + go func() { + for range clusters { + <-entered + } + close(release) + }() + + m.refreshSourceNamespaceScopes(context.Background()) + + assert.True(t, concurrent.Load(), + "every wanted cluster must be listed concurrently, so one wedged cluster blocks only itself") + for i := range clusters { + _, ok := m.sourceScope().snapshot(fmt.Sprintf("cluster-%d", i)) + assert.True(t, ok, "cluster-%d was never listed", i) + } +} + +// TestSourceNamespaceSnapshot_StoreDetectsObservableChange pins the ENQUEUE trigger. Only a real +// change may enqueue — otherwise every 30s refresh re-reconciles every rule — but a LABEL EDIT +// must, or a revocation goes stale in the cache and never lands. +func TestSourceNamespaceSnapshot_StoreDetectsObservableChange(t *testing.T) { + scope := &sourceNamespaceScope{ + wanted: map[string]struct{}{}, + snapshots: map[string]namespaceSnapshot{}, + grants: map[k8stypes.NamespacedName]sourceScopeGrant{}, + } + synced := func(labels map[string]string) namespaceSnapshot { + return namespaceSnapshot{synced: true, labels: map[string]map[string]string{snbSourceNS: labels}} + } + + assert.True(t, scope.store("c", synced(map[string]string{"a": "1"})), "the first snapshot is a change") + assert.False(t, scope.store("c", synced(map[string]string{"a": "1"})), "an identical refresh is not") + assert.True(t, scope.store("c", synced(map[string]string{"a": "2"})), "a label edit is a change") + assert.True(t, scope.store("c", namespaceSnapshot{forbidden: true}), "losing access is a change") + assert.False(t, scope.store("c", namespaceSnapshot{forbidden: true}), "still forbidden is not") +} + +// TestRetainOnRetryableError keeps a synced snapshot usable across a blip: a momentary list failure +// must not revoke anything, because the answers it already holds are still the best available. +func TestRetainOnRetryableError(t *testing.T) { + previous := namespaceSnapshot{ + synced: true, + labels: map[string]map[string]string{snbSourceNS: {"mirrorable": "true"}}, + } + + next := retainOnRetryableError(previous, assert.AnError) + + assert.True(t, next.synced, "a transient failure must not un-sync a working cache") + assert.False(t, next.forbidden, "a transient failure is not the terminal Forbidden case") + assert.Equal(t, previous.labels, next.labels) +} diff --git a/internal/watch/stream_readiness.go b/internal/watch/stream_readiness.go index b3a01cb4..d0564f4c 100644 --- a/internal/watch/stream_readiness.go +++ b/internal/watch/stream_readiness.go @@ -10,6 +10,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" + k8stypes "k8s.io/apimachinery/pkg/types" configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" "github.com/ConfigButler/gitops-reverser/internal/types" @@ -130,19 +131,41 @@ func (m *Manager) StreamSummaryForGitTarget(gitDest types.ResourceReference) Str // StreamSummaryForWatchRule reports stream readiness for one namespaced WatchRule, resolved // against the source cluster its GitTarget mirrors from. +// +// It reads the COMPILED rule, not the spec. A rule's watched namespaces can no longer be derived +// from its spec at all: a `sourceNamespace: "*"` item's set exists only after resolution against +// the GitTarget's policy and the source-cluster snapshot. Rebuilding the keys from the spec would +// look for streams under keys that were never opened, so a perfectly healthy wildcard rule would +// report permanently not-ready while its streams run — the same class of bug the singular field +// already hit once, one level up. +// +// A rule that is not compiled expects no streams, which is correct: the gate refused it, or the +// store has not been seeded yet. func (m *Manager) StreamSummaryForWatchRule(rule configv1alpha3.WatchRule) StreamSummary { + // The GitTarget is in the rule's OWN namespace (targetRef is a LocalTargetReference), but the + // streams are keyed on the namespaces being WATCHED. gitDest := types.NewResourceReference(rule.Spec.TargetRef.Name, rule.Namespace) + if m.RuleStore == nil { + return streamSummaryForTypes(nil, nil, nil) + } + compiled, ok := m.RuleStore.GetWatchRule( + k8stypes.NamespacedName{Name: rule.Name, Namespace: rule.Namespace}) + if !ok { + return streamSummaryForTypes(nil, nil, nil) + } + reg := m.registryForGitTarget(gitDest) m.refreshClusterTypeRegistry(m.cluster(m.clusterIDForGitTarget(gitDest))) records := reg.Followable() var keys []targetWatchKey names := map[schema.GroupVersionResource]string{} - for _, rr := range rule.Spec.Rules { + for _, rr := range compiled.ResourceRules { matched := matchFollowableRecords( records, rr.APIGroups, rr.APIVersions, rr.Resources, configv1alpha3.ResourceScopeNamespaced) for _, rec := range matched { - key := targetWatchKey{GVR: rec.Identity.GVR, Namespace: rule.Namespace} - keys = append(keys, key) + for _, namespace := range rr.SourceNamespaces { + keys = append(keys, targetWatchKey{GVR: rec.Identity.GVR, Namespace: namespace}) + } names[rec.Identity.GVR] = streamDisplayName(rec.Identity.GVR) } } @@ -150,7 +173,8 @@ func (m *Manager) StreamSummaryForWatchRule(rule configv1alpha3.WatchRule) Strea } // StreamSummaryForClusterWatchRule reports stream readiness for one ClusterWatchRule, resolved -// against the source cluster its GitTarget mirrors from. +// against the source cluster its GitTarget mirrors from. It always matches cluster-scoped records, +// because a ClusterWatchRule is cluster-scope-only. func (m *Manager) StreamSummaryForClusterWatchRule(rule configv1alpha3.ClusterWatchRule) StreamSummary { gitDest := types.NewResourceReference(rule.Spec.TargetRef.Name, rule.Spec.TargetRef.Namespace) reg := m.registryForGitTarget(gitDest) @@ -159,7 +183,9 @@ func (m *Manager) StreamSummaryForClusterWatchRule(rule configv1alpha3.ClusterWa var keys []targetWatchKey names := map[schema.GroupVersionResource]string{} for _, rr := range rule.Spec.Rules { - for _, rec := range matchFollowableRecords(records, rr.APIGroups, rr.APIVersions, rr.Resources, rr.Scope) { + matched := matchFollowableRecords( + records, rr.APIGroups, rr.APIVersions, rr.Resources, configv1alpha3.ResourceScopeCluster) + for _, rec := range matched { key := targetWatchKey{GVR: rec.Identity.GVR} keys = append(keys, key) names[rec.Identity.GVR] = streamDisplayName(rec.Identity.GVR) diff --git a/internal/watch/watched_type_helpers_test.go b/internal/watch/watched_type_helpers_test.go index 30949a9b..37a1ce0f 100644 --- a/internal/watch/watched_type_helpers_test.go +++ b/internal/watch/watched_type_helpers_test.go @@ -13,8 +13,10 @@ import ( configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" ) -// clusterRuleForResource builds a "test-target" ClusterWatchRule for one core/v1 namespaced -// resource. Shared by the watched-type-table and materialization tests. +// clusterRuleForResource builds a "test-target" ClusterWatchRule for one core/v1 CLUSTER-SCOPED +// resource. Shared by the watched-type-table and materialization tests. A ClusterWatchRule selects +// cluster-scoped types only, so the fixture's resources must be genuinely cluster-scoped for the +// rule to resolve anything at all. func clusterRuleForResource(name, resource string) configv1alpha3.ClusterWatchRule { return configv1alpha3.ClusterWatchRule{ ObjectMeta: metav1.ObjectMeta{Name: name}, @@ -27,7 +29,6 @@ func clusterRuleForResource(name, resource string) configv1alpha3.ClusterWatchRu APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{resource}, - Scope: configv1alpha3.ResourceScopeNamespaced, }}, }, } @@ -55,7 +56,7 @@ func watchRuleForTarget(name, gitTargetName, namespace string) configv1alpha3.Wa func TestRefreshWatchedTypeTables_ConcurrentRefreshesConverge(t *testing.T) { manager, store := makeWatchedTypeManager(t) store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "configmaps"), + clusterRuleForResource("rule-1", "namespaces"), "test-target", "test-ns", "test-provider", "test-ns", "main", "test-path", ) @@ -72,7 +73,7 @@ func TestRefreshWatchedTypeTables_ConcurrentRefreshesConverge(t *testing.T) { } // Concurrently add a second rule mid-flight. store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-2", "secrets"), + clusterRuleForResource("rule-2", "nodes"), "test-target", "test-ns", "test-provider", "test-ns", "main", "test-path", ) wg.Wait() @@ -85,5 +86,5 @@ func TestRefreshWatchedTypeTables_ConcurrentRefreshesConverge(t *testing.T) { for _, wt := range table.Types { kinds[wt.GVK.Kind] = true } - assert.True(t, kinds["ConfigMap"] && kinds["Secret"], "the settled table reflects both rules") + assert.True(t, kinds["Namespace"] && kinds["Node"], "the settled table reflects both rules") } diff --git a/internal/watch/watched_type_metrics_test.go b/internal/watch/watched_type_metrics_test.go index 02b5417c..b7b3daf8 100644 --- a/internal/watch/watched_type_metrics_test.go +++ b/internal/watch/watched_type_metrics_test.go @@ -19,7 +19,7 @@ func TestRefreshWatchedTypeTables_RecordsResolvedTypeGauge(t *testing.T) { manager, store := makeWatchedTypeManager(t) store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "configmaps"), + clusterRuleForResource("rule-1", "namespaces"), "test-target", "test-ns", "test-provider", "test-ns", "main", "test-path", ) diff --git a/internal/watch/watched_type_resolver.go b/internal/watch/watched_type_resolver.go index ffb3cd28..5740ad5b 100644 --- a/internal/watch/watched_type_resolver.go +++ b/internal/watch/watched_type_resolver.go @@ -279,8 +279,14 @@ func (m *Manager) resolveWatchedTypeTables() map[string]WatchedTypeTable { } // collectWatchRuleSelections folds every namespaced WatchRule into its GitTarget's selected -// records, scoping each record to the rule's own namespace and resolving against the -// GitTarget's own source cluster's followable set. +// records, EXPANDING each item to one selection per (matched record × resolved source namespace) +// and resolving against the GitTarget's own source cluster's followable set. +// +// Expansion happens here rather than as a filter at the read site. An expanded selection carries +// the scope through the plan hash, the informers, AND the resync path for free; a read-site filter +// would have to be repeated at each of them and is silently wrong if one is missed — and it would +// also mean an unfiltered LIST/WATCH over every namespace, so the data would cross into the process +// before being dropped. func (m *Manager) collectWatchRuleSelections( recordsFor func(clusterID string) []typeset.TypeRecord, get func(types.ResourceReference, string, string, string, string) *targetSelections, @@ -293,9 +299,21 @@ func (m *Manager) collectWatchRuleSelections( matched := matchFollowableRecords( records, rr.APIGroups, rr.APIVersions, rr.Resources, configv1alpha3.ResourceScopeNamespaced) for _, rec := range matched { - ts.selections = append(ts.selections, watchSelection{ - record: rec, namespace: rule.Source.Namespace, ops: rr.Operations, - }) + // The ITEM's RESOLVED source namespaces, NOT rule.Source.Namespace (which names the + // WatchRule object in the control plane). These differ whenever the item sets + // sourceNamespace, and this is the one place a config-plane namespace ever became a + // data-plane "namespace" — a watch selector only, discarded once the stream is open + // because every event's identity is rebuilt from the object's own + // metadata.namespace. So changing it never moves anything in Git. + // + // Neither an omitted item nor a wildcard ever emits a raw "" key: both resolved to + // concrete names at compile time, so only ClusterWatchRule emits "" and PR 2's + // stream-scope collapse rules are unaffected. + for _, namespace := range rr.SourceNamespaces { + ts.selections = append(ts.selections, watchSelection{ + record: rec, namespace: namespace, ops: rr.Operations, + }) + } } } } @@ -303,6 +321,11 @@ func (m *Manager) collectWatchRuleSelections( // collectClusterWatchRuleSelections folds every ClusterWatchRule into its GitTarget's selected // records as cluster-wide streams, resolving against the GitTarget's own source cluster. +// +// It always matches with ResourceScopeCluster — the third enforcement point of the cluster-only +// narrowing. Admission rejects a namespaced scope and the shared compile path refuses a stored one; +// keying resolution on discovery-confirmed cluster scope means that even a pruned or absent value +// cannot widen a stream. Selections keep namespace "", now only for genuinely cluster-scoped types. func (m *Manager) collectClusterWatchRuleSelections( recordsFor func(clusterID string) []typeset.TypeRecord, get func(types.ResourceReference, string, string, string, string) *targetSelections, @@ -312,7 +335,8 @@ func (m *Manager) collectClusterWatchRuleSelections( records := recordsFor(m.clusterIDForGitTarget(targetRef)) ts := get(targetRef, rule.GitProviderNamespace, rule.GitProviderRef, rule.Branch, rule.Path) for _, rr := range rule.Rules { - matched := matchFollowableRecords(records, rr.APIGroups, rr.APIVersions, rr.Resources, rr.Scope) + matched := matchFollowableRecords( + records, rr.APIGroups, rr.APIVersions, rr.Resources, configv1alpha3.ResourceScopeCluster) for _, rec := range matched { ts.selections = append(ts.selections, watchSelection{ record: rec, namespace: "", ops: rr.Operations, @@ -475,28 +499,43 @@ func (m *Manager) rulesFingerprint() uint64 { return xxhash.Sum64String(strings.Join(parts, "\x00")) } +// watchRuleFingerprint hashes everything about a compiled WatchRule that can change what it +// watches. Each item's src= component MUST be that item's RESOLVED source-namespace SET, not the +// WatchRule object's own namespace and not the requested value. +// +// This is the silent one. A wildcard item's inputs — the GitTarget's allowedSourceNamespaces and +// the source cluster's Namespace labels — are NOT rule state, so a mapper that merely requeues the +// WatchRule is not sufficient: reconciliation runs, a spec-derived fingerprint is unchanged, the +// table rebuild is skipped, and the resident table keeps the old namespace set. Streams carry on at +// their old width and every diff looks correct, because the rule object genuinely did not change. +// Hashing the resolved set closes that, and costs nothing: compilation is what resolves the set, so +// the fingerprint sees it for free — provided compilation always precedes the rebuild. func watchRuleFingerprint(rule rulestore.CompiledRule) string { var b strings.Builder - fmt.Fprintf(&b, "wr|gt=%s/%s|src=%s|dest=%s", - rule.GitTargetNamespace, rule.GitTargetRef, rule.Source.Namespace, + fmt.Fprintf(&b, "wr|gt=%s/%s|dest=%s", + rule.GitTargetNamespace, rule.GitTargetRef, watchPlanDest(rule.GitProviderNamespace, rule.GitProviderRef, rule.Branch, rule.Path)) for _, rr := range rule.ResourceRules { - fmt.Fprintf(&b, "|rr[g=%s;v=%s;r=%s;op=%s]", + fmt.Fprintf(&b, "|rr[g=%s;v=%s;r=%s;op=%s;src=%s]", strings.Join(rr.APIGroups, ","), strings.Join(rr.APIVersions, ","), - strings.Join(rr.Resources, ","), operationsString(rr.Operations)) + strings.Join(rr.Resources, ","), operationsString(rr.Operations), + strings.Join(rr.SourceNamespaces, ",")) } return b.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. func clusterWatchRuleFingerprint(rule rulestore.CompiledClusterRule) string { var b strings.Builder fmt.Fprintf(&b, "cwr|gt=%s/%s|dest=%s", rule.GitTargetNamespace, rule.GitTargetRef, watchPlanDest(rule.GitProviderNamespace, rule.GitProviderRef, rule.Branch, rule.Path)) for _, rr := range rule.Rules { - fmt.Fprintf(&b, "|rr[g=%s;v=%s;r=%s;op=%s;scope=%s]", + fmt.Fprintf(&b, "|rr[g=%s;v=%s;r=%s;op=%s]", strings.Join(rr.APIGroups, ","), strings.Join(rr.APIVersions, ","), - strings.Join(rr.Resources, ","), operationsString(rr.Operations), rr.Scope) + strings.Join(rr.Resources, ","), operationsString(rr.Operations)) } return b.String() } diff --git a/internal/watch/watched_type_resolver_test.go b/internal/watch/watched_type_resolver_test.go index 75f67f5e..3e2a5eaf 100644 --- a/internal/watch/watched_type_resolver_test.go +++ b/internal/watch/watched_type_resolver_test.go @@ -37,7 +37,7 @@ func gitDestRef(name string) types.ResourceReference { func TestRefreshWatchedTypeTables_ClusterWatchRuleResolvesClusterWideType(t *testing.T) { manager, store := makeWatchedTypeManager(t) store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "configmaps"), + clusterRuleForResource("rule-1", "namespaces"), "test-target", "test-ns", "test-provider", "test-ns", "main", "test-path", ) @@ -47,8 +47,8 @@ func TestRefreshWatchedTypeTables_ClusterWatchRuleResolvesClusterWideType(t *tes require.True(t, ok) require.Len(t, table.Types, 1) wt := table.Types[0] - assert.Equal(t, "ConfigMap", wt.GVK.Kind) - assert.True(t, wt.ClusterWide(), "a ClusterWatchRule with Namespaced scope streams cluster-wide") + assert.Equal(t, "Namespace", wt.GVK.Kind) + assert.True(t, wt.ClusterWide(), "a ClusterWatchRule streams its cluster-scoped types cluster-wide") assert.Equal(t, []string{""}, wt.WatchScopes()) assert.Equal(t, `provider=test-ns/test-provider|branch="main"|path="test-path"`, table.Dest) } @@ -57,10 +57,12 @@ func TestRefreshWatchedTypeTables_WatchRuleScopesTypeToItsNamespace(t *testing.T manager, store := makeWatchedTypeManager(t) store.AddOrUpdateWatchRule( watchRuleForTarget("rule-a", "wt-ns-target", "ns-a"), + ownNamespaceScope(watchRuleForTarget("rule-a", "wt-ns-target", "ns-a")), "wt-ns-target", "test-ns", "test-provider", "test-ns", "main", "test-path", ) store.AddOrUpdateWatchRule( watchRuleForTarget("rule-b", "wt-ns-target", "ns-b"), + ownNamespaceScope(watchRuleForTarget("rule-b", "wt-ns-target", "ns-b")), "wt-ns-target", "test-ns", "test-provider", "test-ns", "main", "test-path", ) @@ -76,7 +78,7 @@ func TestRefreshWatchedTypeTables_WatchRuleScopesTypeToItsNamespace(t *testing.T func TestRefreshWatchedTypeTables_RuleChangeReResolves(t *testing.T) { manager, store := makeWatchedTypeManager(t) store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "configmaps"), + clusterRuleForResource("rule-1", "namespaces"), "test-target", "test-ns", "test-provider", "test-ns", "main", "test-path", ) manager.refreshWatchedTypeTables() @@ -85,14 +87,14 @@ func TestRefreshWatchedTypeTables_RuleChangeReResolves(t *testing.T) { // A second rule selecting a different resource is reflected on the next refresh. store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-2", "secrets"), + clusterRuleForResource("rule-2", "nodes"), "test-target", "test-ns", "test-provider", "test-ns", "main", "test-path", ) manager.refreshWatchedTypeTables() second, _ := manager.watchedTypeTableForGitDest(gitDestRef("test-target")) kinds := []string{second.Types[0].GVK.Kind, second.Types[1].GVK.Kind} - assert.ElementsMatch(t, []string{"ConfigMap", "Secret"}, kinds) + assert.ElementsMatch(t, []string{"Namespace", "Node"}, kinds) } func TestResolveWatchedTypeTables_NilRuleStoreIsEmpty(t *testing.T) { @@ -103,7 +105,7 @@ func TestResolveWatchedTypeTables_NilRuleStoreIsEmpty(t *testing.T) { func TestRefreshWatchedTypeTables_NoChangeReusesResolvedTables(t *testing.T) { manager, store := makeWatchedTypeManager(t) store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "configmaps"), + clusterRuleForResource("rule-1", "namespaces"), "test-target", "test-ns", "test-provider", "test-ns", "main", "test-path", ) manager.refreshWatchedTypeTables() @@ -123,14 +125,14 @@ func TestRefreshWatchedTypeTables_NoChangeReusesResolvedTables(t *testing.T) { func TestRulesFingerprint_StableUntilRuleChanges(t *testing.T) { manager, store := makeWatchedTypeManager(t) store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "configmaps"), + clusterRuleForResource("rule-1", "namespaces"), "test-target", "test-ns", "test-provider", "test-ns", "main", "test-path", ) fp1 := manager.rulesFingerprint() assert.Equal(t, fp1, manager.rulesFingerprint(), "fingerprint must be stable for unchanged rules") store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-2", "secrets"), + clusterRuleForResource("rule-2", "nodes"), "test-target", "test-ns", "test-provider", "test-ns", "main", "test-path", ) assert.NotEqual(t, fp1, manager.rulesFingerprint(), "a new rule must move the fingerprint") @@ -166,7 +168,6 @@ func TestRefreshWatchedTypeTables_ExcludesAmbiguousGVK(t *testing.T) { APIGroups: []string{"example.com"}, APIVersions: []string{"v1"}, Resources: []string{"*"}, - Scope: configv1alpha3.ResourceScopeNamespaced, }}, }, }, diff --git a/internal/watch/watchrule_compile.go b/internal/watch/watchrule_compile.go new file mode 100644 index 00000000..3b878c12 --- /dev/null +++ b/internal/watch/watchrule_compile.go @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: Apache-2.0 + +package watch + +import ( + "context" + "fmt" + + k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/authz" + "github.com/ConfigButler/gitops-reverser/internal/rulestore" +) + +// SourceScopeService is the source-scope service as its consumers need it: the policy resolution +// and enumeration authz calls, plus the per-rule resolved-scope memory that separates ESTABLISHING +// a grant from MAINTAINING one. *Manager implements it; a nil value is legitimate and means "not +// wired yet" (a zero-value manager in tests, or a controller running before the data plane is up), +// which degrades to name-only policy evaluation rather than to a denial. +type SourceScopeService interface { + authz.SourceNamespaceResolver + + // RetainedSourceScope reports the resolved scope last granted to a rule FOR A GIVEN SPEC, and + // whether any grant was ever established for that spec. + // + // It is keyed by the rule's spec hash rather than by item index on purpose: retention applies + // only while the spec is unchanged, so an edit discards the memory and re-establishes from + // scratch, and a reorder can never let one item inherit another item's grant. + RetainedSourceScope(rule k8stypes.NamespacedName, specHash string) ([][]string, bool) + // RecordSourceScopeGrant remembers a successful whole-rule resolution. + RecordSourceScopeGrant(rule k8stypes.NamespacedName, specHash string, namespaces [][]string) + // ForgetSourceScopeGrant drops a rule's resolved scope on a refusal or a deletion. + ForgetSourceScopeGrant(rule k8stypes.NamespacedName) +} + +// CompileWatchRule is THE ONLY PATH from a WatchRule to a compiled rule. It resolves the whole +// per-item source-namespace scope first and compiles only on an admitted verdict. +// +// It is one function, called by both the WatchRule reconciler and the watch manager's startup +// bootstrap, because two call sites that each remember to check is an arrangement this codebase +// has already got wrong once. Bootstrap lists every WatchRule and seeds the store BEFORE the first +// reconcile, then marks the store ready — so a gate the reconciler alone enforced would be +// bypassed for the whole startup window, on EVERY restart, which is exactly when nobody is +// watching. Routing compilation through here closes that by construction rather than by +// discipline: there is no second place that can call AddOrUpdateWatchRule for a WatchRule. +// +// Its three outcomes map onto the three things the caller must do: +// +// - ADMITTED — the rule is compiled with every item expanded to concrete namespaces, and the +// resolved scope is recorded. The caller publishes SourceNamespaceAuthorized=True. +// - TERMINAL (any item denied, or a permanently unevaluatable policy with no scope ever resolved +// for this spec) — any previously compiled rule is REMOVED here, before the caller publishes +// anything. A gate that only writes a condition is not a gate; the caller must still replan the +// watch manager and then publish the Failed trio, in that order. +// - CANNOT SAY YET (retryable), or a rule MAINTAINING an already-resolved scope through an +// unevaluatable policy — nothing is compiled and nothing is removed. The caller leaves status +// InProgress and retries. Never narrow to the empty set here: a narrowed set is the input to a +// sweep, so failing closed while maintaining would delete a tenant's Git content over a +// transient outage. +// +// Bootstrap cannot publish status (it runs before controllers start), so a rule denied there is +// simply not compiled and the first reconcile writes the terminal condition. That ordering — fail +// closed first, explain second — is correct, not a limitation. +func CompileWatchRule( + ctx context.Context, + reader client.Reader, + store *rulestore.RuleStore, + scope SourceScopeService, + rule configv1alpha3.WatchRule, + target configv1alpha3.GitTarget, + provider configv1alpha3.GitProvider, +) (authz.ResolvedSourceScope, error) { + key := k8stypes.NamespacedName{Name: rule.Name, Namespace: rule.Namespace} + specHash := SourceScopeSpecHash(&rule) + + resolved, err := authz.ResolveWatchRuleSourceScope(ctx, reader, &rule, &target, resolverOf(scope)) + if err != nil { + // Transient: leave whatever is compiled alone and let the caller requeue. Tearing down a + // running stream because the apiserver blipped is the failure this avoids. + return resolved, err + } + + if resolved.Admitted() { + namespaces := itemNamespaces(resolved) + store.AddOrUpdateWatchRule( + rule, + namespaces, + target.Name, target.Namespace, + provider.Name, provider.Namespace, + target.Spec.Branch, + target.Spec.Path, + ) + if scope != nil { + scope.RecordSourceScopeGrant(key, specHash, namespaces) + } + return resolved, nil + } + + // An unevaluatable policy on a rule that ALREADY has a resolved scope FOR THIS SPEC is the + // maintaining case: retain it. The rule keeps running on its last known-good grant and the + // caller reports Unknown, not Failed — "cannot re-read the policy" is not "the policy says no". + if resolved.Verdict == authz.SourceScopeUnavailable && retainsScope(scope, key, specHash) { + resolved.Verdict = authz.SourceScopeUnknown + return resolved, nil + } + + if resolved.Terminal() { + // Stop the data plane before the caller says anything about it. + store.Delete(key) + if scope != nil { + scope.ForgetSourceScopeGrant(key) + } + } + return resolved, nil +} + +// itemNamespaces projects the resolved scope into the per-item slice the store compiles from. +func itemNamespaces(resolved authz.ResolvedSourceScope) [][]string { + out := make([][]string, 0, len(resolved.Items)) + for _, item := range resolved.Items { + out = append(out, item.Namespaces) + } + return out +} + +// retainsScope reports whether a rule already holds a resolved grant for THIS spec. It is +// deliberately spec-specific: a rule whose items changed is establishing a new scope, not +// maintaining its old one, so a stale grant must not let an unevaluatable policy through. +func retainsScope(scope SourceScopeService, rule k8stypes.NamespacedName, specHash string) bool { + if scope == nil { + return false + } + _, ok := scope.RetainedSourceScope(rule, specHash) + return ok +} + +// resolverOf adapts a possibly-nil service to the resolver authz takes, preserving the nil so +// authz's own "no source-scope service is wired" path (which answers Unknown, never Denied) runs. +func resolverOf(scope SourceScopeService) authz.SourceNamespaceResolver { + if scope == nil { + return nil + } + return scope +} + +// 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. +// +// Two refusals, both terminal, in this order: +// +// 1. the referenced GitTarget's namespace must be admitted by that target's ClusterProvider — a +// ClusterWatchRule's targetRef 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. +// +// 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. +func CompileClusterWatchRule( + ctx context.Context, + reader client.Reader, + store *rulestore.RuleStore, + rule configv1alpha3.ClusterWatchRule, + target configv1alpha3.GitTarget, + provider configv1alpha3.GitProvider, +) (ClusterWatchRuleDecision, error) { + key := k8stypes.NamespacedName{Name: rule.Name} + + admitted, err := authz.GitTargetAdmitted(ctx, reader, &target) + if err != nil { + // Transient: leave whatever is compiled alone and let the caller requeue. + return ClusterWatchRuleDecision{}, err + } + if !admitted.Allowed { + store.DeleteClusterWatchRule(key) + return ClusterWatchRuleDecision{ + Reason: ClusterWatchRuleReasonGitTargetNamespaceNotAuthorized, + Message: fmt.Sprintf("ClusterWatchRule may not compile against GitTarget '%s/%s': %s", + target.Namespace, target.Name, admitted.Message), + }, nil + } + + if rule.Spec.DeclaresNamespacedScope() { + store.DeleteClusterWatchRule(key) + return ClusterWatchRuleDecision{ + Reason: ClusterWatchRuleReasonScopeNotSupported, + Message: ClusterWatchRuleNamespacedScopeMessage, + }, nil + } + + store.AddOrUpdateClusterWatchRule( + rule, + target.Name, target.Namespace, + provider.Name, provider.Namespace, + target.Spec.Branch, + target.Spec.Path, + ) + return ClusterWatchRuleDecision{Admitted: true}, nil +} + +// ClusterWatchRule compile refusal reasons. They live here rather than in the controller because +// bootstrap refuses on the same grounds without a controller in sight, and one vocabulary is what +// keeps the two from drifting. +const ( + // ClusterWatchRuleReasonGitTargetNamespaceNotAuthorized is the terminal reason when the + // referenced GitTarget's namespace is not admitted by that target's ClusterProvider — either + // because spec.allowedNamespaces excludes 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 + // target. The Message carries which of the two it was. + ClusterWatchRuleReasonGitTargetNamespaceNotAuthorized = "GitTargetNamespaceNotAuthorized" + + // ClusterWatchRuleReasonScopeNotSupported is the terminal reason for a STORED ClusterWatchRule + // that still selects namespaced resources through the removed scope choice. + ClusterWatchRuleReasonScopeNotSupported = "ClusterScopeOnly" +) + +// ClusterWatchRuleNamespacedScopeMessage is the operator-facing refusal for a stored +// scope: Namespaced. It names the replacement, because the migration is cross-kind and cannot be +// performed automatically. +const ClusterWatchRuleNamespacedScopeMessage = "ClusterWatchRule is cluster-scoped only; watch " + + "namespaced resources with a WatchRule and `rules[].sourceNamespace`." + +// ClusterWatchRuleDecision is the outcome of the shared ClusterWatchRule compile path. +type ClusterWatchRuleDecision struct { + // Admitted reports whether the rule compiled. + Admitted bool + // Reason is the terminal condition reason when it did not. + Reason string + // Message explains the refusal to an operator. + Message string +} diff --git a/test/e2e/restart_reconcile_e2e_test.go b/test/e2e/restart_reconcile_e2e_test.go index 08e62d4f..f7992bc8 100644 --- a/test/e2e/restart_reconcile_e2e_test.go +++ b/test/e2e/restart_reconcile_e2e_test.go @@ -39,9 +39,9 @@ var _ = Describe("Restart Reconcile Safety", Label("restart-reconcile"), Serial, ) const ( - providerName = "restart-reconcile-provider" - gitTargetName = "restart-reconcile-target" - clusterWatchRuleName = "restart-reconcile-wildcard" + providerName = "restart-reconcile-provider" + gitTargetName = "restart-reconcile-target" + watchRuleName = "restart-reconcile-wildcard" ) // orderNames are "quiet" resources: created once and never touched again. @@ -73,7 +73,7 @@ var _ = Describe("Restart Reconcile Safety", Label("restart-reconcile"), Serial, AfterAll(func() { dumpFailureDiagnostics() - cleanupClusterWatchRule(clusterWatchRuleName) + cleanupWatchRule(watchRuleName, testNs) cleanupNamespace(testNs) }) @@ -93,27 +93,27 @@ var _ = Describe("Restart Reconcile Safety", Label("restart-reconcile"), Serial, g.Expect(strings.TrimSpace(output)).To(Equal("True")) }).Should(Succeed()) - By("creating the GitProvider, GitTarget and wildcard ClusterWatchRule") + By("creating the GitProvider, GitTarget and wildcard WatchRule") createGitProviderWithURLInNamespace(providerName, testNs, restartRepo.GitSecretHTTP, restartRepo.RepoURLHTTP) createGitTarget(gitTargetName, testNs, providerName, gitTargetPath, "main") - cwrData := struct { + wrData := struct { Name string DestinationName string Namespace string Group string }{ - Name: clusterWatchRuleName, + Name: watchRuleName, DestinationName: gitTargetName, Namespace: testNs, Group: crdGroupRestartReconcile, } Expect(applyFromTemplate( - "test/e2e/templates/restart/clusterwatchrule-wildcard.tmpl", cwrData, "", - )).To(Succeed(), "failed to apply wildcard ClusterWatchRule") + "test/e2e/templates/restart/watchrule-wildcard.tmpl", wrData, testNs, + )).To(Succeed(), "failed to apply wildcard WatchRule") verifyResourceCondition("gittarget", gitTargetName, testNs, "Validated", "True", "OK", "") - verifyResourceStatus("clusterwatchrule", clusterWatchRuleName, "", "True", "Ready", "") + verifyResourceStatus("watchrule", watchRuleName, testNs, "True", "Ready", "") By("creating quiet IceCreamOrder resources to build up the git mirror") for _, name := range orderNames { diff --git a/test/e2e/source_namespace_e2e_test.go b/test/e2e/source_namespace_e2e_test.go new file mode 100644 index 00000000..c346b282 --- /dev/null +++ b/test/e2e/source_namespace_e2e_test.go @@ -0,0 +1,349 @@ +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "fmt" + "os" + "path" + "path/filepath" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// This spec covers WatchRule.spec.rules[].sourceNamespace end to end (see +// docs/design/watchrule-source-namespace/pr4-cluster-scope-only.md). +// +// The load-bearing assertion is the GIT PATH one. The whole design rests on a claim that is +// invisible from the API types: sourceNamespace changes which namespace is WATCHED and nothing +// about where objects are WRITTEN, because Git placement is rebuilt from each mirrored object's own +// metadata.namespace rather than from any config-plane namespace. If that ever regresses, a rule in +// tenant-* would start filing another namespace's objects under its own folder — silently, and in a +// tenant's repository. Nothing else in the suite would catch it. +// +// The refusal spec is its safety twin: an unauthorized override must publish a terminal condition +// AND write nothing at all. The wildcard spec is the third: "*" must resolve to exactly the +// GitTarget's admitted set — never to every namespace that exists. +var _ = Describe("WatchRule source namespace", Label("manager"), Ordered, func() { + const ( + providerName = "gitprovider-srcns" + // Two GitTargets: one whose provider delegates the override, one whose provider does not. + delegatingCP = "srcns-delegating" + nonDelegatingCP = "srcns-non-delegating" + grantedTarget = "srcns-granted" + refusedTarget = "srcns-refused" + grantedRule = "srcns-granted-rule" + refusedRule = "srcns-refused-rule" + wildcardRule = "srcns-wildcard-rule" + grantedPath = "e2e/srcns-granted" + refusedPath = "e2e/srcns-refused" + ) + + var ( + // configNS holds the WatchRules and GitTargets; sourceNS is the namespace they WATCH. The + // two differ on purpose — that separation is the entire feature. wildcardNS is admitted by + // the granted target's policy but named by NO rule item, so only a wildcard can reach it. + // outsideNS exists to prove the policy is a bound rather than a hint: it is never admitted + // by any target here. + configNS string + sourceNS string + wildcardNS string + outsideNS string + srcnsRepo *RepoArtifacts + grantedDir string + ) + + BeforeAll(func() { + By("creating separate config-plane, source, wildcard-only, and unadmitted namespaces") + configNS = testNamespaceFor("srcns-config") + sourceNS = testNamespaceFor("srcns-source") + wildcardNS = testNamespaceFor("srcns-wildcard") + outsideNS = testNamespaceFor("srcns-outside") + _, _ = kubectlRun("create", "namespace", configNS) + _, _ = kubectlRun("create", "namespace", sourceNS) + _, _ = kubectlRun("create", "namespace", wildcardNS) + _, _ = kubectlRun("create", "namespace", outsideNS) + + By("setting up Gitea repo and credentials") + srcnsRepo = SetupRepo( + resolveE2EContext(), + configNS, + fmt.Sprintf("e2e-srcns-%d", GinkgoRandomSeed()), + ) + grantedDir = filepath.Join(srcnsRepo.CheckoutDir, grantedPath) + _, err := kubectlRunInNamespace(configNS, "apply", "-f", srcnsRepo.SecretsYAML) + Expect(err).NotTo(HaveOccurred(), "failed to apply git secrets") + applySOPSAgeKeyToNamespace(configNS) + + createGitProviderWithURLInNamespace( + providerName, configNS, srcnsRepo.GitSecretHTTP, srcnsRepo.RepoURLHTTP) + verifyResourceStatus("gitprovider", providerName, configNS, + "True", "Ready", "Repository connectivity validated") + + By("declaring two in-cluster ClusterProviders that differ ONLY in the delegation flag") + // Both omit kubeConfig, so both name the operator's own cluster: this is deliberately the + // SHARPER of the two cases the design describes, where an authorized override bypasses live + // namespace RBAC. Dedicated providers keep the shared "default" one untouched. + Expect(applyInClusterClusterProvider(delegatingCP, configNS, true)).Error(). + NotTo(HaveOccurred(), "failed to apply delegating ClusterProvider") + Expect(applyInClusterClusterProvider(nonDelegatingCP, configNS, false)).Error(). + NotTo(HaveOccurred(), "failed to apply non-delegating ClusterProvider") + + By("creating a GitTarget whose policy admits the source namespaces, and one that is refused") + // The granted target admits TWO namespaces; only sourceNS is ever named by a rule item, so + // wildcardNS is reachable exclusively through `sourceNamespace: "*"`. + Expect(applyGitTargetWithSourceNamespaces( + configNS, grantedTarget, providerName, grantedPath, delegatingCP, + sourceNS, wildcardNS)).Error(). + NotTo(HaveOccurred(), "failed to apply granted GitTarget") + Expect(applyGitTargetWithSourceNamespaces( + configNS, refusedTarget, providerName, refusedPath, nonDelegatingCP, sourceNS)).Error(). + NotTo(HaveOccurred(), "failed to apply refused GitTarget") + + verifyResourceCondition("gittarget", grantedTarget, configNS, "Validated", "True", "OK", "") + verifyResourceCondition("gittarget", refusedTarget, configNS, "Validated", "True", "OK", "") + }) + + AfterAll(func() { + deleteClusterProvider(delegatingCP) + deleteClusterProvider(nonDelegatingCP) + cleanupNamespace(configNS) + cleanupNamespace(sourceNS) + cleanupNamespace(wildcardNS) + cleanupNamespace(outsideNS) + }) + + SetDefaultEventuallyTimeout(60 * time.Second) + SetDefaultEventuallyPollingInterval(time.Second) + + It("mirrors an authorized source namespace under THAT namespace's Git folder", func() { + By("creating a WatchRule whose rule item watches the source namespace") + Expect(applyWatchRuleWithSourceNamespace( + grantedRule, configNS, grantedTarget, sourceNS)).Error(). + NotTo(HaveOccurred(), "failed to apply granted WatchRule") + + By("asserting the override is authorized") + verifyResourceCondition("watchrule", grantedRule, configNS, + "SourceNamespaceAuthorized", "True", "SourceNamespaceAllowed", "") + verifyResourceStatus("watchrule", grantedRule, configNS, "True", "Ready", "") + waitForStreamsRunning(grantedTarget, configNS) + + By("creating a ConfigMap in the SOURCE namespace") + const cmName = "srcns-mirrored" + _, err := kubectlRunInNamespace(sourceNS, "create", "configmap", cmName, + "--from-literal=hello=world") + Expect(err).NotTo(HaveOccurred(), "failed to create ConfigMap in the source namespace") + + By("asserting it lands under the SOURCE namespace's folder, not the config namespace's") + // This is the appendix's claim, asserted: the write path never substitutes the WatchRule's + // control-plane namespace for the mirrored object's own. + wantPath := path.Join(grantedPath, fmt.Sprintf("%s/configmaps/%s.yaml", sourceNS, cmName)) + mustNotExist := path.Join(grantedPath, fmt.Sprintf("%s/configmaps/%s.yaml", configNS, cmName)) + + Eventually(func(g Gomega) { + pullLatestRepoState(g, srcnsRepo.CheckoutDir) + + g.Expect(filepath.Join(srcnsRepo.CheckoutDir, wantPath)).To(BeAnExistingFile(), + "a rule in %q watching %q must write under %q — Git placement follows the MIRRORED "+ + "OBJECT's namespace, never the WatchRule's. Recent commits:\n%s", + configNS, sourceNS, sourceNS, recentCommitDiagnostics(srcnsRepo.CheckoutDir, grantedPath)) + + g.Expect(filepath.Join(srcnsRepo.CheckoutDir, mustNotExist)).NotTo(BeAnExistingFile(), + "the config-plane namespace %q must never name a Git folder", configNS) + }).Should(Succeed()) + }) + + It("resolves a wildcard item to exactly the target's admitted set, and no further", func() { + By("creating a WatchRule whose item asks for every namespace the target admits") + Expect(applyWatchRuleWithSourceNamespace( + wildcardRule, configNS, grantedTarget, "*")).Error(). + NotTo(HaveOccurred(), "failed to apply wildcard WatchRule") + + By("asserting the wildcard is authorized") + verifyResourceCondition("watchrule", wildcardRule, configNS, + "SourceNamespaceAuthorized", "True", "SourceNamespaceAllowed", "") + verifyResourceStatus("watchrule", wildcardRule, configNS, "True", "Ready", "") + + By("creating one ConfigMap in the wildcard-only namespace and one in an unadmitted namespace") + // wildcardNS is admitted by the target's policy but named by NO rule item, so anything + // arriving from it is attributable to the wildcard expansion alone. sourceNS would prove + // nothing here: the granted rule above already watches it by exact name. + const admittedCM = "srcns-wildcard-admitted" + const outsideCM = "srcns-wildcard-outside" + _, err := kubectlRunInNamespace(wildcardNS, "create", "configmap", admittedCM, + "--from-literal=k=v") + Expect(err).NotTo(HaveOccurred()) + _, err = kubectlRunInNamespace(outsideNS, "create", "configmap", outsideCM, + "--from-literal=k=v") + Expect(err).NotTo(HaveOccurred()) + + By("asserting the wildcard-only namespace arrives, under its own folder") + wantPath := path.Join(grantedPath, fmt.Sprintf("%s/configmaps/%s.yaml", wildcardNS, admittedCM)) + Eventually(func(g Gomega) { + pullLatestRepoState(g, srcnsRepo.CheckoutDir) + g.Expect(filepath.Join(srcnsRepo.CheckoutDir, wantPath)).To(BeAnExistingFile(), + `"*" must expand to every namespace the target admits, including %q, which no rule `+ + "item names. Recent commits:\n%s", + wildcardNS, recentCommitDiagnostics(srcnsRepo.CheckoutDir, grantedPath)) + }).Should(Succeed()) + + By("asserting the UNADMITTED namespace never does, against a real commit") + // "*" is bounded by allowedSourceNamespaces, never by what exists. If this regresses, a + // wildcard silently mirrors every namespace on the cluster into a tenant's repository. + Consistently(func(g Gomega) { + pullLatestRepoState(g, srcnsRepo.CheckoutDir) + g.Expect(findFileByBasename(grantedDir, outsideCM+".yaml")).To(BeEmpty(), + "a target whose policy admits only %q and %q must never receive an object from %q", + sourceNS, wildcardNS, outsideNS) + entries, statErr := os.ReadDir(grantedDir) + g.Expect(statErr).NotTo(HaveOccurred()) + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + g.Expect(names).NotTo(ContainElement(outsideNS), + "and the unadmitted namespace must not even name a folder (saw %s)", + strings.Join(names, ", ")) + }, 20*time.Second, 4*time.Second).Should(Succeed()) + }) + + It("keeps two explicitly-named source namespaces in separate folders", func() { + // The same SHAPE a wildcard expands to — one GitTarget, one type, two source namespaces — + // reached without the wildcard. It separates a defect in wildcard EXPANSION from one in how + // a target handles more than one source namespace at all, which is reachable by two + // ordinary rules and does not need `"*"`. + const ruleA, ruleB = "srcns-two-a", "srcns-two-b" + const cmA, cmB = "srcns-two-cm-a", "srcns-two-cm-b" + + By("creating two rules on ONE target, each naming a different source namespace") + Expect(applyWatchRuleWithSourceNamespace(ruleA, configNS, grantedTarget, sourceNS)).Error(). + NotTo(HaveOccurred(), "failed to apply the first explicit rule") + Expect(applyWatchRuleWithSourceNamespace(ruleB, configNS, grantedTarget, wildcardNS)).Error(). + NotTo(HaveOccurred(), "failed to apply the second explicit rule") + verifyResourceStatus("watchrule", ruleA, configNS, "True", "Ready", "") + verifyResourceStatus("watchrule", ruleB, configNS, "True", "Ready", "") + + By("creating one ConfigMap in each watched namespace") + _, err := kubectlRunInNamespace(sourceNS, "create", "configmap", cmA, "--from-literal=k=v") + Expect(err).NotTo(HaveOccurred()) + _, err = kubectlRunInNamespace(wildcardNS, "create", "configmap", cmB, "--from-literal=k=v") + Expect(err).NotTo(HaveOccurred()) + + By("asserting each lands under ITS OWN namespace's folder") + wantA := path.Join(grantedPath, fmt.Sprintf("%s/configmaps/%s.yaml", sourceNS, cmA)) + wantB := path.Join(grantedPath, fmt.Sprintf("%s/configmaps/%s.yaml", wildcardNS, cmB)) + Eventually(func(g Gomega) { + pullLatestRepoState(g, srcnsRepo.CheckoutDir) + g.Expect(filepath.Join(srcnsRepo.CheckoutDir, wantA)).To(BeAnExistingFile(), + "the first source namespace must own its folder. Recent commits:\n%s", + recentCommitDiagnostics(srcnsRepo.CheckoutDir, grantedPath)) + g.Expect(filepath.Join(srcnsRepo.CheckoutDir, wantB)).To(BeAnExistingFile(), + "a SECOND source namespace on the same target must get its own folder too, not be "+ + "folded into the first's. Recent commits:\n%s", + recentCommitDiagnostics(srcnsRepo.CheckoutDir, grantedPath)) + }).Should(Succeed()) + }) + + It("refuses an override the ClusterProvider does not delegate, and writes nothing", func() { + By("recording the repository head before the refused rule exists") + var headBefore string + Eventually(func(g Gomega) { + headBefore = remoteBranchHead(g, srcnsRepo.CheckoutDir) + }).Should(Succeed()) + + By("creating a WatchRule whose override the provider does not delegate") + Expect(applyWatchRuleWithSourceNamespace( + refusedRule, configNS, refusedTarget, sourceNS)).Error(). + NotTo(HaveOccurred(), "the CR itself is well-formed; the REFUSAL is a reconciler verdict") + + By("asserting the terminal refusal is published with a fix-naming message") + verifyResourceCondition("watchrule", refusedRule, configNS, + "SourceNamespaceAuthorized", "False", "SourceNamespaceNotAllowed", + "allowSourceNamespaceOverride") + verifyResourceCondition("watchrule", refusedRule, configNS, + "Stalled", "True", "SourceNamespaceNotAllowed", "") + verifyResourceCondition("watchrule", refusedRule, configNS, + "Reconciling", "False", "SourceNamespaceNotAllowed", "") + + By("creating a ConfigMap the refused rule would have mirrored") + const cmName = "srcns-refused-cm" + _, err := kubectlRunInNamespace(sourceNS, "create", "configmap", cmName, + "--from-literal=should=not-be-mirrored") + Expect(err).NotTo(HaveOccurred()) + + By("asserting the refused target's folder stays empty") + // A gate that only writes a condition is not a gate: the stream must not exist at all. + Consistently(func(g Gomega) { + pullLatestRepoState(g, srcnsRepo.CheckoutDir) + refusedDir := filepath.Join(srcnsRepo.CheckoutDir, refusedPath) + if _, statErr := os.Stat(refusedDir); statErr == nil { + g.Expect(findFileByBasename(refusedDir, cmName+".yaml")).To(BeEmpty(), + "a refused WatchRule must write nothing; head before was %s", headBefore) + } + }, 20*time.Second, 4*time.Second).Should(Succeed()) + }) +}) + +// applyInClusterClusterProvider applies a ClusterProvider that OMITS kubeConfig — so it names the +// operator's own cluster — admitting one namespace and optionally delegating source-namespace +// selection to the GitTargets it admits. +func applyInClusterClusterProvider(name, allowedNS string, delegate bool) (string, error) { + manifest := fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 +kind: ClusterProvider +metadata: + name: %s +spec: + allowedNamespaces: + names: [%s] + allowSourceNamespaceOverride: %t +`, name, allowedNS, delegate) + return kubectlRunWithStdin("", manifest, "apply", "-f", "-") +} + +// applyGitTargetWithSourceNamespaces applies a GitTarget that declares an allowedSourceNamespaces +// policy naming one or more source namespaces by exact name. +func applyGitTargetWithSourceNamespaces( + ns, name, gitProvider, targetPath, clusterProvider string, sourceNSs ...string, +) (string, error) { + manifest := fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 +kind: GitTarget +metadata: + name: %s + namespace: %s +spec: + providerRef: + kind: GitProvider + name: %s + branch: main + path: %s + clusterProviderRef: + name: %s + allowedSourceNamespaces: + names: [%s] +`, name, ns, gitProvider, targetPath, clusterProvider, strings.Join(sourceNSs, ", ")) + return kubectlRunWithStdin(ns, manifest, "apply", "-f", "-") +} + +// applyWatchRuleWithSourceNamespace applies a WatchRule whose rule items watch a namespace OTHER +// than the rule's own. sourceNamespace may be an exact name or "*". +func applyWatchRuleWithSourceNamespace(name, ns, target, sourceNamespace string) (string, error) { + manifest := fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 +kind: WatchRule +metadata: + name: %s + namespace: %s +spec: + targetRef: + kind: GitTarget + name: %s + rules: + - resources: ["configmaps"] + sourceNamespace: %q + - resources: ["secrets"] + sourceNamespace: %q +`, name, ns, target, sourceNamespace, sourceNamespace) + return kubectlRunWithStdin(ns, manifest, "apply", "-f", "-") +} diff --git a/test/e2e/templates/demo/clusterwatchrule-demo.tmpl b/test/e2e/templates/demo/clusterwatchrule-demo.tmpl index 05a4b200..efcaa467 100644 --- a/test/e2e/templates/demo/clusterwatchrule-demo.tmpl +++ b/test/e2e/templates/demo/clusterwatchrule-demo.tmpl @@ -7,13 +7,11 @@ spec: name: {{ .DestinationName }} namespace: {{ .Namespace }} rules: - - scope: Cluster - operations: [CREATE, UPDATE, DELETE] + - operations: [CREATE, UPDATE, DELETE] apiGroups: [""] apiVersions: ["v1"] resources: ["namespaces"] - - scope: Cluster - operations: [CREATE, UPDATE, DELETE] + - operations: [CREATE, UPDATE, DELETE] apiGroups: ["configbutler.ai"] apiVersions: ["v1alpha1"] resources: ["clusterwatchrules"] diff --git a/test/e2e/templates/manager/clusterwatchrule-crd.tmpl b/test/e2e/templates/manager/clusterwatchrule-crd.tmpl index 21f44e1a..c857c3ee 100644 --- a/test/e2e/templates/manager/clusterwatchrule-crd.tmpl +++ b/test/e2e/templates/manager/clusterwatchrule-crd.tmpl @@ -7,8 +7,7 @@ spec: name: {{ .DestinationName }} namespace: {{ .Namespace }} rules: - - scope: Cluster - operations: [CREATE, UPDATE, DELETE] + - operations: [CREATE, UPDATE, DELETE] apiGroups: [apiextensions.k8s.io] apiVersions: [v1] resources: [customresourcedefinitions] diff --git a/test/e2e/templates/restart/clusterwatchrule-wildcard.tmpl b/test/e2e/templates/restart/clusterwatchrule-wildcard.tmpl deleted file mode 100644 index f9ada941..00000000 --- a/test/e2e/templates/restart/clusterwatchrule-wildcard.tmpl +++ /dev/null @@ -1,18 +0,0 @@ -apiVersion: configbutler.ai/v1alpha3 -kind: ClusterWatchRule -metadata: - name: {{ .Name }} -spec: - targetRef: - name: {{ .DestinationName }} - namespace: {{ .Namespace }} - rules: - # apiVersions: ["*"] is the documented "match all versions" form - # (see api/v1alpha3/clusterwatchrule_types.go). The live audit path honours - # it; the startup snapshot path must honour it too, otherwise a controller - # restart snapshots an empty cluster and deletes the whole tracked tree. - - scope: Namespaced - operations: [CREATE, UPDATE, DELETE] - apiGroups: ["{{ .Group }}"] - apiVersions: ["*"] - resources: ["icecreamorders"] diff --git a/test/e2e/templates/restart/watchrule-wildcard.tmpl b/test/e2e/templates/restart/watchrule-wildcard.tmpl new file mode 100644 index 00000000..15121228 --- /dev/null +++ b/test/e2e/templates/restart/watchrule-wildcard.tmpl @@ -0,0 +1,22 @@ +apiVersion: configbutler.ai/v1alpha3 +kind: WatchRule +metadata: + name: {{ .Name }} + namespace: {{ .Namespace }} +spec: + targetRef: + name: {{ .DestinationName }} + rules: + # apiVersions: ["*"] is the documented "match all versions" form + # (see api/v1alpha3/watchrule_types.go). The live audit path honours + # it; the startup snapshot path must honour it too, otherwise a controller + # restart snapshots an empty cluster and deletes the whole tracked tree. + # + # This was a ClusterWatchRule with `scope: Namespaced` until PR 4 made that kind + # cluster-scope-only. The conversion is the one UPGRADING.md prescribes: a WatchRule + # in the namespace whose objects are mirrored. sourceNamespace is omitted, so the rule + # watches its own namespace — which is where the fixture's orders live. + - operations: [CREATE, UPDATE, DELETE] + apiGroups: ["{{ .Group }}"] + apiVersions: ["*"] + resources: ["icecreamorders"] diff --git a/test/e2e/unsupported_folder_e2e_test.go b/test/e2e/unsupported_folder_e2e_test.go index d0acc83a..93dadcfb 100644 --- a/test/e2e/unsupported_folder_e2e_test.go +++ b/test/e2e/unsupported_folder_e2e_test.go @@ -165,6 +165,9 @@ func waitForRuleBlockedByGitPath(resourceType, name, namespace, expectedReason s func applyUnsupportedPathClusterWatchRule(name, targetNamespace, targetName string) { GinkgoHelper() + // A ClusterWatchRule is cluster-scope-only, so this selects a cluster-scoped type. The point of + // the fixture is that a rule of THIS KIND is also blocked by the target's Git-path refusal; what + // it selects is incidental, so it names a low-cardinality cluster type. manifest := fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 kind: ClusterWatchRule metadata: @@ -174,12 +177,11 @@ spec: name: %s namespace: %s rules: - - scope: Namespaced - apiGroups: [""] + - apiGroups: ["storage.k8s.io"] apiVersions: ["v1"] - resources: ["configmaps"] + resources: ["storageclasses"] `, name, targetName, targetNamespace) _, err := kubectlRunWithStdin("", manifest, "apply", "-f", "-") - Expect(err).NotTo(HaveOccurred(), "failed to apply ConfigMap ClusterWatchRule") + Expect(err).NotTo(HaveOccurred(), "failed to apply StorageClass ClusterWatchRule") }