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