From 39db8f5ea8d4da59ccd9d220715e7fb67ac1633f Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 20 Jul 2026 20:51:12 +0000 Subject: [PATCH 01/18] feat(watch): add WatchRule.spec.sourceNamespace and its authorization gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `WatchRule.spec.sourceNamespace` so a rule can watch a namespace other than its own on the GitTarget's source cluster — the shared-config-plane multi-tenancy case where a tenant's config namespace and source namespace cannot share a name. PR 4 of docs/design/watchrule-source-namespace/. API (v1alpha3): - `WatchRule.spec.sourceNamespace` (optional; omitted = the rule's own namespace), plus a priority-1 `SourceAuthorized` printer column. - `GitTarget.spec.allowedSourceNamespaces` — a deny-by-default ceiling on which source namespaces may be mirrored into a target, by any rule kind. Declared is exhaustive with no self-namespace exception; empty != omitted. - `ClusterProvider.spec.allowWatchRuleSourceNamespaceOverride` — false-by-default delegation flag; without it a WatchRule may watch only its own namespace. - Generalize `AllowedNamespaces` into a shared `NamespaceMatcher` (JSON field name unchanged); `ClusterProvider.AllowsNamespace` and `GitTarget.AllowsSourceNamespace` become thin wrappers so the control-cluster and source-cluster policies cannot drift. Gate and data plane: - The three-part gate (provider admits the target, provider delegates, target policy admits the namespace) lives in internal/authz with a three-valued result (admitted / denied / cannot-say-yet / permanently-unevaluatable), so a transient source-cluster outage never becomes a terminal Stalled=True. - Route every WatchRule compilation through one gated path (`watch.CompileWatchRule`) used by both the reconciler and the startup bootstrap, so the gate cannot be bypassed on restart. - Source-scope service (manager-owned per-source-cluster Namespace snapshot, refreshed on the existing reconcile cadence) evaluates selector policies and enqueues affected rules on a label change; exact-name policies need no source-cluster access. - Carry the effective source namespace through the compiled rule, the watch selection, the fingerprint, and the stream-readiness lookup, so an override watches — and reports Ready on — the right namespace. - `SourceNamespaceAuthorized` condition (kstatus-compatible; an additional prerequisite of Ready), a ClusterProvider→WatchRules mapper, and a source-cluster Namespace channel. Docs: configuration.md, status-conditions-guide.md, and the design pages. Co-Authored-By: Claude Opus 4.8 (1M context) --- .coverage-baseline | 2 +- api/v1alpha3/clusterprovider_types.go | 92 ++-- api/v1alpha3/gittarget_types.go | 54 +++ api/v1alpha3/helpers_test.go | 26 +- api/v1alpha3/namespace_matcher.go | 95 ++++ api/v1alpha3/namespace_matcher_test.go | 125 ++++++ api/v1alpha3/watchrule_types.go | 61 ++- api/v1alpha3/zz_generated.deepcopy.go | 57 +-- .../configbutler.ai_clusterproviders.yaml | 43 +- .../crd/bases/configbutler.ai_gittargets.yaml | 89 ++++ .../crd/bases/configbutler.ai_watchrules.yaml | 39 +- docs/configuration.md | 102 ++++- .../watchrule-source-namespace/README.md | 4 +- ...alt-clusterwatchrule-cluster-scope-only.md | 277 ++++++++++++ .../pr4-source-namespace-field.md | 26 +- docs/spec/status-conditions-guide.md | 22 + .../authz/clusterprovider_admission_test.go | 26 +- internal/authz/source_namespace.go | 359 +++++++++++++++ internal/authz/source_namespace_test.go | 416 ++++++++++++++++++ .../clusterprovider_controller_test.go | 2 +- .../clusterwatchrule_admission_test.go | 20 +- .../controller/clusterwatchrule_controller.go | 1 - internal/controller/constants.go | 29 ++ .../gittarget_source_cluster_test.go | 18 +- internal/controller/stream_status.go | 35 +- internal/controller/stream_status_test.go | 1 - internal/controller/suite_test.go | 2 +- internal/controller/watchrule_controller.go | 103 ++++- .../controller/watchrule_controller_test.go | 13 +- internal/controller/watchrule_kstatus_test.go | 208 +++++++++ .../controller/watchrule_source_namespace.go | 192 ++++++++ .../watchrule_source_namespace_test.go | 389 ++++++++++++++++ internal/rulestore/store.go | 12 + internal/watch/bootstrap.go | 22 +- internal/watch/bootstrap_admission_test.go | 8 +- internal/watch/manager.go | 19 + internal/watch/manager_startup_test.go | 2 +- .../watch/source_namespace_planning_test.go | 135 ++++++ internal/watch/source_namespace_scope.go | 360 +++++++++++++++ .../source_namespace_stream_summary_test.go | 119 +++++ internal/watch/source_namespace_test.go | 361 +++++++++++++++ internal/watch/stream_readiness.go | 7 +- internal/watch/watched_type_resolver.go | 16 +- internal/watch/watchrule_compile.go | 130 ++++++ test/e2e/source_namespace_e2e_test.go | 236 ++++++++++ 45 files changed, 4180 insertions(+), 175 deletions(-) create mode 100644 api/v1alpha3/namespace_matcher.go create mode 100644 api/v1alpha3/namespace_matcher_test.go create mode 100644 docs/design/watchrule-source-namespace/alt-clusterwatchrule-cluster-scope-only.md create mode 100644 internal/authz/source_namespace.go create mode 100644 internal/authz/source_namespace_test.go create mode 100644 internal/controller/watchrule_kstatus_test.go create mode 100644 internal/controller/watchrule_source_namespace.go create mode 100644 internal/controller/watchrule_source_namespace_test.go create mode 100644 internal/watch/source_namespace_planning_test.go create mode 100644 internal/watch/source_namespace_scope.go create mode 100644 internal/watch/source_namespace_stream_summary_test.go create mode 100644 internal/watch/source_namespace_test.go create mode 100644 internal/watch/watchrule_compile.go create mode 100644 test/e2e/source_namespace_e2e_test.go diff --git a/.coverage-baseline b/.coverage-baseline index 29179ad4..cd27a8f4 100644 --- a/.coverage-baseline +++ b/.coverage-baseline @@ -1 +1 @@ -77.0 +77.2 diff --git a/api/v1alpha3/clusterprovider_types.go b/api/v1alpha3/clusterprovider_types.go index 1621f714..ca33b915 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 @@ -86,10 +67,44 @@ type ClusterProviderSpec struct { // +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"` + + // AllowWatchRuleSourceNamespaceOverride 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. + // + // The flag does not grant access by itself — an admitted GitTarget must still list the + // namespace in spec.allowedSourceNamespaces. What it delegates is the AUTHORITY to choose: a + // target owner may then configure a broad allow-list, including one matching every source + // namespace, so the source credential's own RBAC remains the hard maximum. Set it only when + // the owners of admitted GitTargets are trusted to pick a subset of what that credential + // may read. + // + // It gates GRANTING only. spec.allowedSourceNamespaces plays two roles — widening a WatchRule + // beyond its own namespace, and 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. + // + // 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. + // + // Note that LOCALITY is not the switch: whether a provider is in-cluster follows from + // spec.kubeConfig, and neither that nor the provider's name decides this. Only this flag does. + // +optional + // +kubebuilder:default=false + AllowWatchRuleSourceNamespaceOverride bool `json:"allowWatchRuleSourceNamespaceOverride,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. @@ -188,26 +203,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) +} + +// AllowsWatchRuleSourceNamespaceOverride 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) AllowsWatchRuleSourceNamespaceOverride() bool { + return p.Spec.AllowWatchRuleSourceNamespaceOverride } func init() { diff --git a/api/v1alpha3/gittarget_types.go b/api/v1alpha3/gittarget_types.go index cfb8951f..d3e8b884 100644 --- a/api/v1alpha3/gittarget_types.go +++ b/api/v1alpha3/gittarget_types.go @@ -98,6 +98,40 @@ type GitTargetSpec struct { // +kubebuilder:default={name: "default"} // +optional ClusterProviderRef *ClusterProviderReference `json:"clusterProviderRef,omitempty"` + + // AllowedSourceNamespaces bounds which SOURCE-cluster namespaces may be mirrored INTO this + // target. It is a property of the DESTINATION, not of any requesting rule: when declared it is + // exhaustive for every rule that writes here, of every kind. + // + // The invariant everything else serves — a declared policy is exhaustive: + // + // | declared? | WatchRule | ClusterWatchRule (Namespaced rules) | + // | no | its own namespace only (legacy) | all source namespaces (legacy) | + // | yes | exactly what the policy admits | exactly what the policy admits | + // + // There is deliberately NO self-namespace exception. Once a policy is declared it must list + // every namespace that may reach this target, INCLUDING a co-resident legacy WatchRule's own + // namespace. 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. + // + // Its selector matches labels on Namespaces in the SOURCE cluster this target mirrors from — + // not the control cluster ClusterProvider.allowedNamespaces describes. Evaluating it therefore + // needs Namespace get/list/watch for the identity in that cluster's credential; an + // exact-NAMES entry stays usable without it, which is a deliberate degradation path. + // + // Empty or omitted are NOT the same: omitted declares no policy (each rule kind keeps its + // legacy scope), while a declared-but-empty policy admits nothing. Widening a WatchRule beyond + // its own namespace additionally requires the ClusterProvider to set + // spec.allowWatchRuleSourceNamespaceOverride; NARROWING never does. + // + // Note that a namespace allow-list cannot partition CLUSTER-SCOPED objects, which have no + // namespace: a ClusterWatchRule selecting cluster-scoped types receives every such object the + // source credential can read, and this field is neither consulted nor a bound for them. + // +optional + AllowedSourceNamespaces *NamespaceMatcher `json:"allowedSourceNamespaces,omitempty"` } // GitTargetPlacementSpec declares where NEW resources are written when no document @@ -243,6 +277,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 rule kind writing here, with no +// self-namespace exception — while an absent one leaves each rule kind its legacy scope. 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..bd617e10 --- /dev/null +++ b/api/v1alpha3/namespace_matcher.go @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha3 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" +) + +// NamespaceMatcher is the 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 { + // Names is an explicit allow-list of namespace names. + // +optional + // +listType=set + 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 +} + +// 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 +} + +// 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..637f15dd --- /dev/null +++ b/api/v1alpha3/namespace_matcher_test.go @@ -0,0 +1,125 @@ +// 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_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) +} + +// TestWatchRule_EffectiveSourceNamespace pins the defaulting every consumer keys on. Getting this +// wrong produces a stale watch, not a visible failure. +func TestWatchRule_EffectiveSourceNamespace(t *testing.T) { + rule := &WatchRule{ObjectMeta: metav1.ObjectMeta{Name: "r", Namespace: "tenant-acme"}} + + assert.Equal(t, "tenant-acme", rule.EffectiveSourceNamespace(), "omitted means the rule's own") + assert.False(t, rule.OverridesSourceNamespace()) + + rule.Spec.SourceNamespace = "repo-config" + assert.Equal(t, "repo-config", rule.EffectiveSourceNamespace()) + assert.True(t, rule.OverridesSourceNamespace()) + + // Restating the rule's own namespace is NOT an override: it needs no delegation flag. + rule.Spec.SourceNamespace = "tenant-acme" + assert.Equal(t, "tenant-acme", rule.EffectiveSourceNamespace()) + assert.False(t, rule.OverridesSourceNamespace(), + "naming your own namespace explicitly must behave exactly like omitting it") +} + +// 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.AllowsWatchRuleSourceNamespaceOverride(), + "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..04b5a7c2 100644 --- a/api/v1alpha3/watchrule_types.go +++ b/api/v1alpha3/watchrule_types.go @@ -42,13 +42,35 @@ type LocalTargetReference struct { } // WatchRuleSpec defines the desired state of WatchRule. -// WatchRule watches resources ONLY within its own namespace. +// WatchRule watches resources in ONE namespace of its GitTarget's source cluster: its own +// namespace by default, or the namespace spec.sourceNamespace names when authorized. type WatchRuleSpec struct { // TargetRef references the GitTarget to use. // Must be in the same namespace. // +required TargetRef LocalTargetReference `json:"targetRef"` + // SourceNamespace is the namespace to watch IN THE SOURCE CLUSTER the referenced GitTarget + // mirrors from. Omitted, it is this WatchRule's own namespace — the legacy behavior, which + // needs no authorization as long as the GitTarget declares no allowedSourceNamespaces policy. + // + // Naming a DIFFERENT namespace requires all three of: + // + // 1. the GitTarget's namespace is admitted by its ClusterProvider (spec.allowedNamespaces); + // 2. that ClusterProvider sets spec.allowWatchRuleSourceNamespaceOverride; and + // 3. the GitTarget's spec.allowedSourceNamespaces admits this namespace. + // + // The outcome is published as the SourceNamespaceAuthorized condition. Once the GitTarget + // declares a policy that policy is exhaustive, so even an OMITTED sourceNamespace is then + // checked against it — the rule's own namespace gets no implicit carve-out. + // + // This changes only which namespace is WATCHED. It never changes where objects are written: + // Git placement follows each mirrored object's OWN namespace, so a rule in "tenant-acme" + // watching "repo-config" writes under repo-config/…, not tenant-acme/…. + // +optional + // +kubebuilder:validation:MinLength=1 + SourceNamespace string `json:"sourceNamespace,omitempty"` + // Rules define which resources to watch within this namespace. // Multiple rules create a logical OR - a resource matching ANY rule is watched. // Each rule can specify operations, API groups, versions, and resource types. @@ -174,16 +196,23 @@ 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, +// WatchRule watches namespaced resources in ONE namespace of the source cluster its GitTarget +// mirrors from. It provides fine-grained control over which resources trigger Git commits, // with filtering by operation type, API group, version, and labels. // // Security model: -// - WatchRule is namespace-scoped and can only watch resources in its own namespace +// - WatchRule is namespace-scoped and watches its OWN namespace unless spec.sourceNamespace +// names another one AND that override passes the three-part gate described on that field — +// provider admission, an explicit provider-side delegation flag, and the GitTarget's +// allowedSourceNamespaces. The gate is deny-by-default and re-evaluated on every reconcile, +// so a policy tightened later revokes a running rule. // - Use ClusterWatchRule for watching cluster-scoped resources (Nodes, ClusterRoles, etc.) -// - RBAC controls who can create/modify WatchRules per namespace +// - RBAC controls who can create/modify WatchRules per namespace. Note that where the source is +// the operator's OWN cluster, an authorized override deliberately bypasses live namespace +// RBAC — which is why it takes an explicit platform-admin delegation to enable. type WatchRule struct { metav1.TypeMeta `json:",inline"` @@ -200,6 +229,28 @@ type WatchRule struct { Status WatchRuleStatus `json:"status,omitempty"` } +// EffectiveSourceNamespace is the source-cluster namespace this rule actually watches: +// spec.sourceNamespace when set, and the rule's OWN namespace otherwise. +// +// It is controller logic rather than an API-server default because an apiserver default cannot +// refer to metadata.namespace. Every consumer must call it instead of reading either field +// directly — the compiled rule, the watch selection, and the fingerprint that decides whether the +// watched-type table is re-projected all key on this value, and a site that reads +// metadata.namespace instead produces a STALE WATCH rather than a visible failure. +func (w *WatchRule) EffectiveSourceNamespace() string { + if w.Spec.SourceNamespace != "" { + return w.Spec.SourceNamespace + } + return w.Namespace +} + +// OverridesSourceNamespace reports whether this rule asks for a source namespace OTHER than its +// own — the case that needs the ClusterProvider's delegation flag. A spec.sourceNamespace that +// merely restates the rule's own namespace is not an override and is treated as the legacy case. +func (w *WatchRule) OverridesSourceNamespace() bool { + return w.EffectiveSourceNamespace() != w.Namespace +} + // +kubebuilder:object:root=true // WatchRuleList contains a list of WatchRule. 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..faeba7b1 100644 --- a/config/crd/bases/configbutler.ai_clusterproviders.yaml +++ b/config/crd/bases/configbutler.ai_clusterproviders.yaml @@ -70,14 +70,47 @@ spec: spec: description: spec defines the desired state of ClusterProvider. properties: + allowWatchRuleSourceNamespaceOverride: + default: false + description: |- + AllowWatchRuleSourceNamespaceOverride 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. + + The flag does not grant access by itself — an admitted GitTarget must still list the + namespace in spec.allowedSourceNamespaces. What it delegates is the AUTHORITY to choose: a + target owner may then configure a broad allow-list, including one matching every source + namespace, so the source credential's own RBAC remains the hard maximum. Set it only when + the owners of admitted GitTargets are trusted to pick a subset of what that credential + may read. + + It gates GRANTING only. spec.allowedSourceNamespaces plays two roles — widening a WatchRule + beyond its own namespace, and 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. + + 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. + + Note that LOCALITY is not the switch: whether a provider is in-cluster follows from + spec.kubeConfig, and neither that nor the provider's name decides this. Only this flag does. + 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. items: type: string type: array @@ -85,7 +118,7 @@ spec: 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 diff --git a/config/crd/bases/configbutler.ai_gittargets.yaml b/config/crd/bases/configbutler.ai_gittargets.yaml index eb1310be..ec1d1fc3 100644 --- a/config/crd/bases/configbutler.ai_gittargets.yaml +++ b/config/crd/bases/configbutler.ai_gittargets.yaml @@ -93,6 +93,95 @@ spec: spec: description: spec defines the desired state of GitTarget properties: + allowedSourceNamespaces: + description: "AllowedSourceNamespaces bounds which SOURCE-cluster + namespaces may be mirrored INTO this\ntarget. It is a property of + the DESTINATION, not of any requesting rule: when declared it is\nexhaustive + for every rule that writes here, of every kind.\n\nThe invariant + everything else serves — a declared policy is exhaustive:\n\n\t| + declared? | WatchRule | ClusterWatchRule (Namespaced + rules) |\n\t| no | its own namespace only (legacy) | all + source namespaces (legacy) |\n\t| yes | exactly what + the policy admits | exactly what the policy admits |\n\nThere + is deliberately NO self-namespace exception. Once a policy is declared + it must list\nevery namespace that may reach this target, INCLUDING + a co-resident legacy WatchRule's own\nnamespace. An implicit carve-out + would mean the field does not actually bound what arrives\nhere, + so a reader auditing it would be wrong about the target's contents + — which is the\nwhole reason the field exists. The resulting authoring + footgun (adding a policy for one\noverride silently denies co-resident + legacy rules) is mitigated by being LOUD:\nSourceNamespaceAuthorized=False, + Stalled=True, and a message naming the exact fix.\n\nIts selector + matches labels on Namespaces in the SOURCE cluster this target mirrors + from —\nnot the control cluster ClusterProvider.allowedNamespaces + describes. Evaluating it therefore\nneeds Namespace get/list/watch + for the identity in that cluster's credential; an\nexact-NAMES entry + stays usable without it, which is a deliberate degradation path.\n\nEmpty + or omitted are NOT the same: omitted declares no policy (each rule + kind keeps its\nlegacy scope), while a declared-but-empty policy + admits nothing. Widening a WatchRule beyond\nits own namespace additionally + requires the ClusterProvider to set\nspec.allowWatchRuleSourceNamespaceOverride; + NARROWING never does.\n\nNote that a namespace allow-list cannot + partition CLUSTER-SCOPED objects, which have no\nnamespace: a ClusterWatchRule + selecting cluster-scoped types receives every such object the\nsource + credential can read, and this field is neither consulted nor a bound + for them." + properties: + names: + description: Names is an explicit allow-list of namespace names. + items: + 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. diff --git a/config/crd/bases/configbutler.ai_watchrules.yaml b/config/crd/bases/configbutler.ai_watchrules.yaml index d47d05f4..005de540 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,20 @@ spec: schema: openAPIV3Schema: description: |- - WatchRule watches namespaced resources within its own namespace. - It provides fine-grained control over which resources trigger Git commits, + WatchRule watches namespaced resources in ONE namespace of the source cluster its GitTarget + mirrors from. It provides fine-grained control over which resources trigger Git commits, with filtering by operation type, API group, version, and labels. Security model: - - WatchRule is namespace-scoped and can only watch resources in its own namespace + - WatchRule is namespace-scoped and watches its OWN namespace unless spec.sourceNamespace + names another one AND that override passes the three-part gate described on that field — + provider admission, an explicit provider-side delegation flag, and the GitTarget's + allowedSourceNamespaces. The gate is deny-by-default and re-evaluated on every reconcile, + so a policy tightened later revokes a running rule. - Use ClusterWatchRule for watching cluster-scoped resources (Nodes, ClusterRoles, etc.) - - RBAC controls who can create/modify WatchRules per namespace + - RBAC controls who can create/modify WatchRules per namespace. Note that where the source is + the operator's OWN cluster, an authorized override deliberately bypasses live namespace + RBAC — which is why it takes an explicit platform-admin delegation to enable. properties: apiVersion: description: |- @@ -157,6 +167,27 @@ spec: type: object minItems: 1 type: array + sourceNamespace: + description: |- + SourceNamespace is the namespace to watch IN THE SOURCE CLUSTER the referenced GitTarget + mirrors from. Omitted, it is this WatchRule's own namespace — the legacy behavior, which + needs no authorization as long as the GitTarget declares no allowedSourceNamespaces policy. + + Naming a DIFFERENT namespace requires all three of: + + 1. the GitTarget's namespace is admitted by its ClusterProvider (spec.allowedNamespaces); + 2. that ClusterProvider sets spec.allowWatchRuleSourceNamespaceOverride; and + 3. the GitTarget's spec.allowedSourceNamespaces admits this namespace. + + The outcome is published as the SourceNamespaceAuthorized condition. Once the GitTarget + declares a policy that policy is exhaustive, so even an OMITTED sourceNamespace is then + checked against it — the rule's own namespace gets no implicit carve-out. + + This changes only which namespace is WATCHED. It never changes where objects are written: + Git placement follows each mirrored object's OWN namespace, so a rule in "tenant-acme" + watching "repo-config" writes under repo-config/…, not tenant-acme/…. + minLength: 1 + type: string targetRef: description: |- TargetRef references the GitTarget to use. diff --git a/docs/configuration.md b/docs/configuration.md index 8b1052a9..9b7ea4c2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -386,9 +386,27 @@ 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. + allowWatchRuleSourceNamespaceOverride: 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 list the namespace. 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 @@ -688,18 +706,86 @@ 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 normal namespaced watcher. It watches resources in **one namespace of its +`GitTarget`'s source cluster** — its own namespace by default — and writes them to that `GitTarget`. -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.sourceNamespace`: the source-cluster namespace to watch; omitted means the rule's own + namespace - `spec.rules`: one or more resource-match rules +### Watching a different source namespace + +Set `spec.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 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 `allowWatchRuleSourceNamespaceOverride: 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 + sourceNamespace: repo-config # watched in the SOURCE cluster + rules: + - resources: [configmaps] +``` + +The outcome is published as `SourceNamespaceAuthorized`, also shown by +`kubectl get watchrules -o wide`. A refusal is terminal — `Ready=False`, `Stalled=True`, and the +stream stopped — and its message names the fix. 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 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 any rule kind: + +```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. + +Two things about this field are easy to get wrong: + +- **Omitted and empty differ.** Omitted declares no policy and each rule kind keeps its legacy scope. + A declared-but-empty policy (`{}`) admits **nothing**. +- **A declared policy is exhaustive, with no self-namespace exception.** It must list 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 listed — 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` selecting cluster-scoped types receives every such object its source credential +can read, and this field is not consulted for them. 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: diff --git a/docs/design/watchrule-source-namespace/README.md b/docs/design/watchrule-source-namespace/README.md index d042e680..dbdeed2e 100644 --- a/docs/design/watchrule-source-namespace/README.md +++ b/docs/design/watchrule-source-namespace/README.md @@ -1,6 +1,6 @@ # 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 +> **Design in five PRs. PRs 1–4 have landed; PR 5 is 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). > @@ -118,7 +118,7 @@ are pre-existing defect fixes that carry no API change; the last two are the fea | 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 | +| 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 | **landed** | | 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 diff --git a/docs/design/watchrule-source-namespace/alt-clusterwatchrule-cluster-scope-only.md b/docs/design/watchrule-source-namespace/alt-clusterwatchrule-cluster-scope-only.md new file mode 100644 index 00000000..69d8423c --- /dev/null +++ b/docs/design/watchrule-source-namespace/alt-clusterwatchrule-cluster-scope-only.md @@ -0,0 +1,277 @@ +# Alternative — make ClusterWatchRule cluster-scoped only, and pluralize the WatchRule field + +> **Status: proposal, for review. Not agreed, not scheduled.** An alternative to +> [PR 5](pr5-clusterwatchrule-source-ceiling.md) and an amendment to +> [PR 4](pr4-source-namespace-field.md), inside the +> [source-namespace addressing](README.md) workstream. +> +> **PR 4 is being implemented as this is written** — +> [`WatchRule.spec.sourceNamespace`](../../../api/v1alpha3/watchrule_types.go#L71) already exists in +> the tree with its full doc comment. This page is therefore written as a *delta against work in +> flight*, not as a greenfield design. The migration section says exactly what it would cost to adopt +> late; the answer is "less than it looks", and the deciding factor is whether the field is plural +> before it ships in a release, not before it is written. +> +> Code references verified against the tree on 2026-07-20. + +## The proposal + +Two changes, one API-shaped and one field-shaped: + +1. **`ClusterWatchRule` selects cluster-scoped types only.** Narrow + [`ClusterResourceRule.Scope`](../../../api/v1alpha3/clusterwatchrule_types.go#L110) to + `Cluster`. Namespaced types are selected exclusively by `WatchRule`. +2. **`WatchRule.spec.sourceNamespace` becomes `sourceNamespaces`**, a list of names, since it is now + the only object that can address namespaced resources at all. + +`GitTarget.spec.allowedSourceNamespaces` and the ClusterProvider delegation flag are unchanged, and +so is [PR 4](pr4-source-namespace-field.md)'s gate. What changes is that they become the *only* path +into a target, rather than one of two with a runtime ceiling reconciling them. + +## Why: PR 5 re-imposes at runtime a boundary the API can make unrepresentable + +[PR 5](pr5-clusterwatchrule-source-ceiling.md) exists for exactly one reason: `ClusterWatchRule` +accepts `scope: Namespaced`, and its `targetRef` is cross-namespace by design +([clusterwatchrule_types.go:22-44](../../../api/v1alpha3/clusterwatchrule_types.go#L22-L44)), so a +rule can deliver every source namespace into a GitTarget that declared an allow-list. PR 5 answers +that by resolving the target's policy inside the watch resolver and expanding a cluster-wide +selection into one selection per admitted namespace. + +That is a correct design for the constraint as posed. The observation here is that the constraint is +self-imposed. Removing `Namespaced` from the enum makes the bypass **unrepresentable** rather than +**checked**, and the following all cease to exist: + +| PR 5 section | What it costs today | Under this proposal | +|---|---|---| +| §1 apply the ceiling in selection | expand `namespace: ""` per admitted namespace in `collectClusterWatchRuleSelections` ([watched_type_resolver.go:306-323](../../../internal/watch/watched_type_resolver.go#L306-L323)) | gone — cluster-scoped rules legitimately emit `""` | +| §2 resolved scope into table invalidation | `clusterWatchRuleFingerprint` must hash state that **is not rule state** ([watched_type_resolver.go:463-500](../../../internal/watch/watched_type_resolver.go#L463-L500)); an unchanged rule under a changed policy must still re-project the table | gone — a ClusterWatchRule's scope is spec-derived again | +| §2b unknown is not empty | the ClusterWatchRule half of the narrow-then-sweep hazard | gone — nothing narrows | +| §3 reactivity | GitTarget → ClusterWatchRules mapper; informer fan-out to ClusterWatchRules | gone | +| release gate | PR 4 must not ship without PR 5 | survives, but for a smaller reason — see [migration](#if-pr-4-has-already-shipped-singular) | + +§2 is the one worth dwelling on, because it is the subtlest defect in the folder and it is created +entirely by the ceiling: the ceiling's inputs (GitTarget policy, source-cluster Namespace labels) are +not rule state, so a mapper that requeues the rule is not sufficient — reconciliation runs, the +fingerprint is unchanged, the rebuild is skipped, and the resident table keeps the old namespace set +while every diff looks correct. Designing that away is worth more than the field it protects. + +### It also makes the documented caveat into a definition + +[README § what the ceiling does not do](README.md#what-the-ceiling-does-not-do) already concedes that +a namespace allow-list cannot partition cluster-scoped objects, and warns that "my allow-list bounds +what this tenant sees" is false for the cluster-scoped half. Under this proposal that stops being a +caveat attached to a field and becomes the definition of the object: + +> **ClusterWatchRule is the cluster-global surface. It is not namespace-bounded, by construction.** +> **WatchRule is the namespaced surface, and every namespace it reaches is named in its spec and +> admitted by its GitTarget.** + +Two objects, two sentences, no overlap. The operator's answer to *"will this stream objects from +namespaces outside my allow-list?"* is a property of the kind, not of an audit. + +The day-one multi-tenant use case ([README § driving use case](README.md#the-driving-use-case-multi-tenancy-with-crds-on-day-one), +item 4) is unaffected: a tenant still needs a ClusterWatchRule from day one to capture CRDs, and that +is precisely what the narrowed object does. `config/samples/clusterwatchrule.yaml` already uses only +`scope: Cluster`. + +## The cost: `scope: Namespaced` is the only expression of two capabilities + +This is the part a reviewer should push on, because the capability does not disappear — it +**relocates**, and the proposal is only a win if the new home is the right one. + +**Capability A — "watch this type in every namespace."** One object, no enumeration, and it covers +namespaces that do not exist yet. `sourceNamespaces` as a list of *names* does not replace this: +enumeration is not "all", and it goes stale on namespace creation. Expressing "all" again requires a +wildcard entry or a `sourceNamespaceSelector`. + +**Capability B — platform-authored namespaced watching for a tenant's target.** +`ClusterWatchRule.targetRef` is cross-namespace, so a platform team can configure a tenant's mirror +without owning an object in the tenant's namespace. `WatchRule.targetRef` is a `LocalTargetReference` +with no namespace field ([watchrule_types.go:24-42](../../../api/v1alpha3/watchrule_types.go#L24-L42)), +so the replacement must live in the tenant's namespace and be authored by whoever can write there. + +So the honest framing of this proposal is **not** "cluster-scoped resources belong on a cluster-scoped +object". It is: + +> Which object may say *"all namespaces"*, and under whose sign-off? + +Today: ClusterWatchRule, under nothing but cluster-admin's ability to create one. Under this proposal: +WatchRule, under `allowedSourceNamespaces` plus the delegation flag — the pair PR 4 already builds, +readable off the GitTarget. That is a better answer. It is not an elimination, and a review that +treats it as one will be surprised later. + +**Two questions decide whether the win is large or merely structural:** + +1. Does any real configuration need "all namespaces" as a live, self-updating scope? If yes, a + wildcard or selector must ship in v1 and the machinery does not fully disappear — it moves. +2. Must a platform team be able to author namespaced watching for a tenant target without an object + in the tenant namespace? If yes, this proposal removes a capability with no replacement, and + should be rejected or paired with one. + +## Names-only plural is cheap; a selector is not + +Split the plural question, because the two halves have very different cost. + +**`sourceNamespaces: [a, b]`, names only.** The resolved scope stays spec-derived. `watchRuleFingerprint` +([watched_type_resolver.go:478-482](../../../internal/watch/watched_type_resolver.go#L478-L482)) keeps +working with no new invalidation input; no source-cluster Namespace informer is needed for the *rule* +side; and *cannot say* collapses to a pure **establishing** question. That last point cuts into PR 4 +as well — the [source-scope service](pr4-source-namespace-field.md#the-source-scope-service--define-this-interface-before-writing-the-gate) +and the three-valued readiness result are dragged in mostly by **selector** policies, not by the field +itself. A names-only v1 on both sides (`sourceNamespaces` and `allowedSourceNamespaces`) would defer +the informer entirely. + +**`sourceNamespaceSelector`.** This pulls the whole *maintaining* column of +[establishing versus maintaining](pr4-source-namespace-field.md#establishing-versus-maintaining-a-scope) +onto WatchRule, where today it applies only to PR 5's ceiling. A WatchRule's resolved set can now +narrow, and a narrowed set is the input to a sweep — the destructive path +[PR 1](pr1-namespace-scoped-resync.md) landed first to make survivable. The machinery deleted from +ClusterWatchRule reappears on WatchRule the moment the field is dynamic. Net simplification is still +positive — one path instead of two — but it is not free, and it should not be sold as free. + +**Recommended split:** names-only in v1; selector as a follow-up whose entire subject is the +maintaining contract, rather than a subsection of a field addition. + +### One consequence of plural to check, not assume + +A single WatchRule fanning out over N namespaces needs an identity-complete placement template — +`{name}` plus `{namespace}` or `{namespaceOrCluster}` — or two source namespaces collapse onto one +Git path. That is enforced statically only for core Secrets today +([placement.go:550-552](../../../internal/manifestanalyzer/placement.go#L550-L552), +[gittarget_placement_validation.go:67](../../../internal/controller/gittarget_placement_validation.go#L67)); +operator-configured sensitive types rely on write-time guards. The hazard is not created by plural — +two singular WatchRules on one GitTarget already reach it — but plural makes it reachable with one +object and no second author. Worth a static check on any rule that can resolve to more than one +namespace. **Verify against the placement code; do not take this paragraph as settled.** + +## Narrowing the enum does not retract stored objects + +The step most likely to be skipped, and it is a security step, not a cleanup. + +CRD schema validation applies on **write**, not on read. A `ClusterWatchRule` already stored with +`scope: Namespaced` continues to be served, continues to be compiled by +[`bootstrapRuleStore`](../../../internal/watch/bootstrap.go#L49-L68), and continues to emit +`namespace: ""` — so the bypass survives the enum change for every object created before it. The +narrowed enum only prevents *new* ones. + +So the change is two-part: + +1. narrow the enum (blocks new and blocks updates to existing); **and** +2. make the compile path refuse a `Namespaced` cluster rule explicitly — in the same single gated + function [PR 4 step 7](pr4-source-namespace-field.md#implementation-steps) routes both the + reconciler and bootstrap through — with a terminal condition naming the replacement. + +Doing (1) without (2) is the same class of defect this folder was written to fix: a scope computed in +one place and not enforced in another. A test asserting that a *pre-existing* `Namespaced` cluster +rule is refused at bootstrap is the one that keeps it honest, because no manifest in the repo can +create the object once the enum lands. + +## If PR 4 has already shipped singular + +The cost of adopting late is bounded and depends only on whether a release has gone out. + +**Before any release carrying `sourceNamespace`** — the pluralization is a rename with no compatibility +surface. This is a preliminary v1alpha3 API and [README § compatibility](README.md#compatibility) +already accepts observable changes with no conversion webhook. The field is new, so it is unset +everywhere; nothing to migrate. + +**After a release** — accept both, or accept the churn. `sourceNamespace` and `sourceNamespaces` can +coexist with a CEL rule rejecting both-set and the controller reading singular as a one-element list, +which is ugly but small. Preferred instead: pluralize at the next API version with conversion, and do +not ship singular into a second release. + +Either way, **the rest of PR 4 is unaffected**. The gate, the `SourceNamespaceAuthorized` condition, +the printer column, the fingerprint change, the single gated compile path, and the reactivity mappers +all apply per-namespace-in-the-set exactly as written for one namespace. The work in flight is not +wasted by this proposal; the diff is the field's cardinality and the loop around the gate. + +The release gate survives in a weaker form: `Namespaced` must be removed from `ClusterWatchRule` +before, or in, the release that first ships `allowedSourceNamespaces`. Otherwise the allow-list is +enforced on the kind that cannot bypass it and unenforced on the kind that can — the same gap +[PR 5](pr5-clusterwatchrule-source-ceiling.md#why-this-is-launch-set-not-follow-up) describes, for the +same reason. + +## Labels: two axes, only one is affected + +These get conflated, and conflating them muddies both: + +- **Labels on namespaces** — `sourceNamespaceSelector`, or the selector half of + `allowedSourceNamespaces`. Shares the source-cluster Namespace informer, and carries the + narrow-then-sweep hazard above. Affected by this proposal. +- **Labels on resources** — "mirror only objects carrying `x=y`", a per-rule object filter on + [`ResourceRule`](../../../api/v1alpha3/watchrule_types.go#L83). Composes with whatever namespace + scoping exists, needs no authorization gate, has no sweep semantics beyond what a scope change + already has. **Unaffected by this proposal**, and it should be designed separately so it does not + get entangled with the authorization work. + +So "this fits the label vision better" is true for namespace selectors and neutral for resource +labels. + +## Known breakage + +Small, from a repo-wide sweep on 2026-07-20 (excluding `external-sources/`): + +- [config/samples/clusterwatchrule.yaml](../../../config/samples/clusterwatchrule.yaml) — already + `scope: Cluster`. No change. +- [test/e2e/unsupported_folder_e2e_test.go:177](../../../test/e2e/unsupported_folder_e2e_test.go#L177) + — uses `scope: Namespaced` with ConfigMaps, but only because the refusal test needs *some* rule + kind. Convert to `scope: Cluster` or to a WatchRule. +- [docs/configuration.md](../../configuration.md) — "namespaced resources across multiple namespaces" + in the `ClusterWatchRule` section becomes false and must change in the same PR. +- The `ResourceScopeNamespaced` constant + ([clusterwatchrule_types.go:17-18](../../../api/v1alpha3/clusterwatchrule_types.go#L17-L18)) stays: + WatchRule resolution uses it internally + ([watched_type_resolver.go:294](../../../internal/watch/watched_type_resolver.go#L294), + [stream_readiness.go:142](../../../internal/watch/stream_readiness.go#L142), + [manager_catalog.go:389](../../../internal/watch/manager_catalog.go#L389)). Only the + `ClusterResourceRule.Scope` **enum** narrows. +- Unit tests constructing `Namespaced` cluster rules across `internal/watch` and + `internal/controller` — several files; mechanical. + +This list is a sweep, not a proof. A reviewer who believes a real deployment depends on capability A +or B above should say so; that is the argument that defeats this page, and no amount of the above +outweighs it. + +## Test plan delta + +Relative to PR 4 and PR 5 as written: + +- **Deleted:** every PR 5 test named for the ceiling — narrowing, sparing cluster-scoped rules, + `clusterWatchRuleFingerprint` resolved-scope, table rebuild on policy-only change, retain-on-unknown, + and the establishing/maintaining pair for ClusterWatchRule. +- **Added:** `TestBootstrap_PreExistingNamespacedClusterRuleIsRefused` — a stored ClusterWatchRule with + `scope: Namespaced` is not compiled and starts no stream, asserted at bootstrap before any reconcile. + This is the enum-narrowing gap above and it is the single most important new test. +- **Added:** admission rejects `scope: Namespaced` on create and on update, with a message naming + WatchRule plus `sourceNamespaces` as the replacement. +- **Changed:** every PR 4 gate case becomes a set case. Specifically: an empty set is the legacy + own-namespace behavior; a partially-admitted set is **denied in whole, not silently trimmed** — a + rule that quietly watches two of the three namespaces it asked for is the plural-specific failure + mode, and it needs its own case. +- **Kept as-is:** the two must-have tests from + [PR 4](pr4-source-namespace-field.md#the-two-tests-that-must-exist), the fingerprint and selection + silent-failure guards, and the e2e proving Git paths follow the *source object's* namespace + ([Appendix A](pr4-source-namespace-field.md#appendix-a-the-source-objects-namespace-already-names-the-git-folder)). + +## Open questions for the reviewer + +1. Capability A — is a live "all namespaces" scope required at launch? If yes, does it come back as a + wildcard entry, a selector, or not at all? +2. Capability B — must a platform team author namespaced watching for a tenant target from outside the + tenant namespace? +3. Names-only in v1 on **both** `sourceNamespaces` and `allowedSourceNamespaces`, deferring the + source-scope service and the Namespace informer wholesale? That is a much larger cut to PR 4 than + the pluralization itself, and it should be decided on its own merits. +4. Partial admission of a set: deny in whole (recommended) or trim to the admitted subset? Trimming is + friendlier and is a silent narrowing, which is the failure class this folder exists to remove. +5. Is the enum narrowing acceptable churn on a preliminary v1alpha3, given + [README § compatibility](README.md#compatibility) already accepts observable changes without + conversion? + +## What this does not change + +PR 1, PR 2, and PR 3 are unaffected and remain landed. `GitTarget.spec.allowedSourceNamespaces`, the +delegation flag, and the whole authorization argument in +[PR 4](pr4-source-namespace-field.md#what-the-delegation-flag-means) — including the in-cluster +sign-off and the `IsLocalSource()` trap — stand exactly as written. Git placement still follows the +source object's own namespace, so the write path still needs no change. diff --git a/docs/design/watchrule-source-namespace/pr4-source-namespace-field.md b/docs/design/watchrule-source-namespace/pr4-source-namespace-field.md index 4cf71c6b..2ee1ca6c 100644 --- a/docs/design/watchrule-source-namespace/pr4-source-namespace-field.md +++ b/docs/design/watchrule-source-namespace/pr4-source-namespace-field.md @@ -200,10 +200,28 @@ 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 5](pr5-clusterwatchrule-source-ceiling.md) extends the same snapshot to ClusterWatchRules. + +> **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 diff --git a/docs/spec/status-conditions-guide.md b/docs/spec/status-conditions-guide.md index 6586e787..ae34409c 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 the rule's EFFECTIVE source namespace is authorized. + TypeSourceNamespaceAuthorized = "SourceNamespaceAuthorized" ) ``` @@ -69,6 +72,25 @@ 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. ### 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..dde7c598 --- /dev/null +++ b/internal/authz/source_namespace.go @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: Apache-2.0 + +package authz + +import ( + "context" + "fmt" + + 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 the rule watches its OWN namespace and + // the GitTarget declares no allowedSourceNamespaces policy. No authorization was needed. + ReasonLegacySourceNamespace = "LegacySourceNamespace" + + // ReasonSourceNamespaceAllowed is the True reason when the effective source namespace passed + // the policy — including an own-namespace rule that a DECLARED policy explicitly admits. + ReasonSourceNamespaceAllowed = "SourceNamespaceAllowed" + + // 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 a namespace 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( + ctx context.Context, + target *configv1alpha3.GitTarget, + namespace string, + ) SourceScopeResult +} + +// SourceNamespaceDecision is the outcome of the WatchRule source-namespace gate. +type SourceNamespaceDecision struct { + // Namespace is the effective source namespace the gate ruled on. + Namespace string + // Verdict is admitted / denied / cannot-say-yet / permanently-unevaluatable. + Verdict SourceScopeVerdict + // Reason is the SourceNamespaceAuthorized condition reason. + Reason string + // Message explains the verdict to an operator. + Message string +} + +// Admitted reports whether the rule may compile. +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 +} + +// WatchRuleSourceNamespaceAdmitted is the WatchRule source-namespace gate: may this rule watch its +// effective source namespace 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 ordering is the contract: +// +// 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 additionally requires the GitTarget's namespace to be admitted by its +// ClusterProvider, and that provider to set allowWatchRuleSourceNamespaceOverride. +// 3. Whenever a policy is declared it is EXHAUSTIVE — evaluated even for an own-namespace rule, +// 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 WatchRuleSourceNamespaceAdmitted( + ctx context.Context, + reader client.Reader, + rule *configv1alpha3.WatchRule, + target *configv1alpha3.GitTarget, + resolver SourceNamespaceResolver, +) (SourceNamespaceDecision, error) { + effective := rule.EffectiveSourceNamespace() + + // (1) The legacy case: own namespace, no policy. Free, and it must stay free. + if !rule.OverridesSourceNamespace() && !target.DeclaresSourceNamespacePolicy() { + return SourceNamespaceDecision{ + Namespace: effective, + Verdict: SourceScopeAdmitted, + Reason: ReasonLegacySourceNamespace, + Message: fmt.Sprintf( + "watching this WatchRule's own namespace %q; the GitTarget declares no "+ + "allowedSourceNamespaces policy, so no authorization is required", + effective), + }, nil + } + + // (2) An override needs provider admission of the target AND the explicit delegation. + if rule.OverridesSourceNamespace() { + refusal, refused, err := overrideDelegated(ctx, reader, rule, target, effective) + 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 !target.DeclaresSourceNamespacePolicy() { + return SourceNamespaceDecision{ + Namespace: effective, + Verdict: SourceScopeDenied, + Reason: ReasonSourceNamespaceNotAllowed, + Message: fmt.Sprintf( + "GitTarget %s/%s declares no spec.allowedSourceNamespaces, so no source namespace "+ + "other than a rule's own may be mirrored into it; add %q to that policy", + target.Namespace, target.Name, effective), + }, nil + } + + return evaluatePolicy(ctx, rule, target, effective, resolver), nil +} + +// overrideDelegated applies the two provider-side halves of the gate to an override: the provider +// must admit the GitTarget's own namespace, and it must set the delegation flag. +// +// It returns refused=true with the refusal to publish, 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 overrideDelegated( + ctx context.Context, + reader client.Reader, + rule *configv1alpha3.WatchRule, + target *configv1alpha3.GitTarget, + effective string, +) (SourceNamespaceDecision, bool, error) { + providerName := target.SourceCluster() + + var provider configv1alpha3.ClusterProvider + if err := reader.Get(ctx, k8stypes.NamespacedName{Name: providerName}, &provider); err != nil { + if apierrors.IsNotFound(err) { + return SourceNamespaceDecision{ + Namespace: effective, + 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.allowWatchRuleSourceNamespaceOverride", + providerName), + }, true, nil + } + // Transient: requeue rather than tear down a running stream. + return SourceNamespaceDecision{}, false, + 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, reader, target) + if err != nil { + return SourceNamespaceDecision{}, false, err + } + if !admitted.Allowed { + return SourceNamespaceDecision{ + Namespace: effective, + Verdict: SourceScopeDenied, + Reason: ReasonSourceNamespaceNotAllowed, + Message: fmt.Sprintf( + "GitTarget %s/%s may not mirror through ClusterProvider %q at all: %s", + target.Namespace, target.Name, providerName, admitted.Message), + }, true, nil + } + + if !provider.AllowsWatchRuleSourceNamespaceOverride() { + return SourceNamespaceDecision{ + Namespace: effective, + Verdict: SourceScopeDenied, + Reason: ReasonSourceNamespaceNotAllowed, + Message: fmt.Sprintf( + "WatchRule %s/%s requests source namespace %q, but ClusterProvider %q does not set "+ + "spec.allowWatchRuleSourceNamespaceOverride; a WatchRule may watch only its own "+ + "namespace %q until a platform admin delegates that choice", + rule.Namespace, rule.Name, effective, providerName, rule.Namespace), + }, true, nil + } + + return SourceNamespaceDecision{}, false, nil +} + +// evaluatePolicy runs the GitTarget's declared allowedSourceNamespaces through the resolver and +// maps its three-valued answer onto the condition's reasons. +func evaluatePolicy( + ctx context.Context, + rule *configv1alpha3.WatchRule, + target *configv1alpha3.GitTarget, + effective string, + resolver SourceNamespaceResolver, +) SourceNamespaceDecision { + result := resolveWith(ctx, resolver, target, effective) + + switch result.Verdict { + case SourceScopeAdmitted: + return SourceNamespaceDecision{ + Namespace: effective, + Verdict: SourceScopeAdmitted, + Reason: ReasonSourceNamespaceAllowed, + Message: fmt.Sprintf( + "source namespace %q is admitted by GitTarget %s/%s spec.allowedSourceNamespaces", + effective, target.Namespace, target.Name), + } + case SourceScopeDenied: + return SourceNamespaceDecision{ + Namespace: effective, + Verdict: SourceScopeDenied, + Reason: ReasonSourceNamespaceNotAllowed, + Message: deniedMessage(rule, target, effective, result.Message), + } + case SourceScopeUnavailable: + return SourceNamespaceDecision{ + Namespace: effective, + Verdict: SourceScopeUnavailable, + Reason: ReasonSourceNamespacePolicyUnavailable, + Message: fmt.Sprintf( + "GitTarget %s/%s spec.allowedSourceNamespaces cannot be evaluated for %q: %s", + target.Namespace, target.Name, effective, result.Message), + } + case SourceScopeUnknown: + fallthrough + default: + return SourceNamespaceDecision{ + Namespace: effective, + Verdict: SourceScopeUnknown, + Reason: ReasonCheckingSourceNamespacePolicy, + Message: fmt.Sprintf( + "still establishing whether source namespace %q is admitted by GitTarget %s/%s: %s", + effective, target.Namespace, target.Name, result.Message), + } + } +} + +// deniedMessage names the SPECIFIC fix, which matters most in the case the design calls a genuine +// authoring footgun: declaring a policy for one override silently denies a co-resident LEGACY rule +// 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 deniedMessage( + rule *configv1alpha3.WatchRule, + target *configv1alpha3.GitTarget, + effective string, + detail string, +) string { + if !rule.OverridesSourceNamespace() { + 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)", + effective, target.Namespace, target.Name) + } + msg := fmt.Sprintf( + "source namespace %q is not admitted by GitTarget %s/%s spec.allowedSourceNamespaces; "+ + "add it to that policy", + effective, target.Namespace, target.Name) + if detail != "" { + msg += ": " + detail + } + return msg +} + +// 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) +} diff --git a/internal/authz/source_namespace_test.go b/internal/authz/source_namespace_test.go new file mode 100644 index 00000000..42addc85 --- /dev/null +++ b/internal/authz/source_namespace_test.go @@ -0,0 +1,416 @@ +// 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" +) + +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, + }, + } +} + +func snRule(sourceNamespace string) *configv1alpha3.WatchRule { + return &configv1alpha3.WatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: snRuleName, Namespace: snTenantNS}, + Spec: configv1alpha3.WatchRuleSpec{ + TargetRef: configv1alpha3.LocalTargetReference{Name: snTargetName}, + SourceNamespace: sourceNamespace, + Rules: []configv1alpha3.ResourceRule{{Resources: []string{"configmaps"}}}, + }, + } +} + +// 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}}, + AllowWatchRuleSourceNamespaceOverride: 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 + calls int +} + +func (s *stubResolver) ResolveSourceNamespace( + context.Context, *configv1alpha3.GitTarget, string, +) authz.SourceScopeResult { + s.calls++ + return s.result +} + +func admitting() *stubResolver { + return &stubResolver{result: authz.SourceScopeResult{Verdict: authz.SourceScopeAdmitted}} +} + +func denying() *stubResolver { + return &stubResolver{result: authz.SourceScopeResult{Verdict: authz.SourceScopeDenied}} +} + +// TestWatchRuleSourceNamespaceAdmitted is the gate's truth table, 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 TestWatchRuleSourceNamespaceAdmitted(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 WatchRule.spec.sourceNamespace ("" = omitted). + sourceNamespace string + policy *configv1alpha3.NamespaceMatcher + delegate bool + resolver *stubResolver + wantVerdict authz.SourceScopeVerdict + wantReason string + }{{ + // THE test. A rule 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, + }, { + name: "explicitly equals its own namespace, no policy, flag false: allowed", + sourceNamespace: snTenantNS, + policy: nil, + delegate: false, + wantVerdict: authz.SourceScopeAdmitted, + wantReason: authz.ReasonLegacySourceNamespace, + }, { + // 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, + }, { + 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, + }, { + name: "differs, flag true, target selector matches: allowed", + sourceNamespace: snSourceNS, + policy: selectorPolicy, + delegate: true, + resolver: admitting(), + wantVerdict: authz.SourceScopeAdmitted, + wantReason: authz.ReasonSourceNamespaceAllowed, + }, { + 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, + }} + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + target := snTarget(tc.policy) + cl := fake.NewClientBuilder(). + WithScheme(snScheme(t)). + WithObjects( + target, + snClusterProvider(tc.delegate), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snTenantNS}}, + ). + Build() + + var resolver authz.SourceNamespaceResolver + if tc.resolver != nil { + resolver = tc.resolver + } + + decision, err := authz.WatchRuleSourceNamespaceAdmitted( + context.Background(), cl, snRule(tc.sourceNamespace), target, resolver) + + require.NoError(t, err) + assert.Equal(t, tc.wantVerdict, decision.Verdict, "verdict (message: %s)", decision.Message) + assert.Equal(t, tc.wantReason, decision.Reason) + assert.NotEmpty(t, decision.Message, "every verdict must carry an operator-legible message") + + wantNS := tc.sourceNamespace + if wantNS == "" { + wantNS = snTenantNS + } + assert.Equal(t, wantNS, decision.Namespace) + }) + } +} + +// TestWatchRuleSourceNamespaceAdmitted_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 TestWatchRuleSourceNamespaceAdmitted_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() + + decision, err := authz.WatchRuleSourceNamespaceAdmitted( + context.Background(), cl, snRule("shared"), acme, nil) + + require.NoError(t, err) + assert.Equal(t, authz.SourceScopeDenied, decision.Verdict, + "another target's policy must never widen this one") + assert.Equal(t, authz.ReasonSourceNamespaceNotAllowed, decision.Reason) +} + +// TestWatchRuleSourceNamespaceAdmitted_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 TestWatchRuleSourceNamespaceAdmitted_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() + + decision, err := authz.WatchRuleSourceNamespaceAdmitted( + context.Background(), cl, snRule(snSourceNS), target, nil) + + require.NoError(t, err) + assert.Equal(t, authz.SourceScopeDenied, decision.Verdict) + assert.Contains(t, decision.Message, "may not mirror through ClusterProvider") +} + +// TestWatchRuleSourceNamespaceAdmitted_MissingClusterProviderDeniesOverride: an absent provider +// delegates nothing. It must not be an implicit allow. +func TestWatchRuleSourceNamespaceAdmitted_MissingClusterProviderDeniesOverride(t *testing.T) { + target := snTarget(&configv1alpha3.NamespaceMatcher{Names: []string{snSourceNS}}) + + cl := fake.NewClientBuilder().WithScheme(snScheme(t)).WithObjects(target).Build() + + decision, err := authz.WatchRuleSourceNamespaceAdmitted( + context.Background(), cl, snRule(snSourceNS), target, nil) + + require.NoError(t, err) + assert.Equal(t, authz.SourceScopeDenied, decision.Verdict) + assert.Equal(t, authz.ReasonSourceNamespaceNotAllowed, decision.Reason) +} + +// TestWatchRuleSourceNamespaceAdmitted_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 TestWatchRuleSourceNamespaceAdmitted_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.WatchRuleSourceNamespaceAdmitted( + 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) +} + +// TestWatchRuleSourceNamespaceAdmitted_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 TestWatchRuleSourceNamespaceAdmitted_ExactNamesNeedNoResolver(t *testing.T) { + ctx := context.Background() + + t.Run("exact name still admits", func(t *testing.T) { + target := snTarget(&configv1alpha3.NamespaceMatcher{Names: []string{snSourceNS}}) + cl := fake.NewClientBuilder().WithScheme(snScheme(t)). + WithObjects(target, snClusterProvider(true), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snTenantNS}}).Build() + + decision, err := authz.WatchRuleSourceNamespaceAdmitted(ctx, cl, snRule(snSourceNS), target, nil) + + require.NoError(t, err) + assert.Equal(t, authz.SourceScopeAdmitted, decision.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) { + target := snTarget(&configv1alpha3.NamespaceMatcher{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"x": "y"}}, + }) + cl := fake.NewClientBuilder().WithScheme(snScheme(t)). + WithObjects(target, snClusterProvider(true), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snTenantNS}}).Build() + + decision, err := authz.WatchRuleSourceNamespaceAdmitted(ctx, cl, snRule(snSourceNS), target, nil) + + require.NoError(t, err) + assert.Equal(t, authz.SourceScopeUnknown, decision.Verdict) + assert.Equal(t, authz.ReasonCheckingSourceNamespacePolicy, decision.Reason) + }) +} + +// TestWatchRuleSourceNamespaceAdmitted_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 TestWatchRuleSourceNamespaceAdmitted_NameFastPathSkipsTheResolver(t *testing.T) { + target := snTarget(&configv1alpha3.NamespaceMatcher{ + Names: []string{snSourceNS}, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"never": "consulted"}}, + }) + cl := fake.NewClientBuilder().WithScheme(snScheme(t)). + WithObjects(target, snClusterProvider(true), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snTenantNS}}).Build() + + resolver := denying() + decision, err := authz.WatchRuleSourceNamespaceAdmitted( + context.Background(), cl, snRule(snSourceNS), target, resolver) + + require.NoError(t, err) + assert.Equal(t, authz.SourceScopeAdmitted, decision.Verdict) + assert.Zero(t, resolver.calls, "an exact-name match must never reach the source-scope service") +} + +// TestWatchRuleSourceNamespaceDecision_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 TestWatchRuleSourceNamespaceDecision_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) + } +} 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..235b651c 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" @@ -70,6 +71,13 @@ func (m *cwaWatchManager) StreamSummaryForClusterWatchRule( return cwaRunningSummary() } +// SourceScope returns nil: this double has no source cluster, so selector-based +// allowedSourceNamespaces degrades to "cannot say yet" while exact names stay fully answerable. +func (m *cwaWatchManager) SourceScope() watch.SourceScopeService { return nil } + +// 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 +111,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}, @@ -126,12 +134,12 @@ func cwaClusterWatchRule() *configbutleraiv1alpha3.ClusterWatchRule { } // 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 +345,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..1238c617 100644 --- a/internal/controller/clusterwatchrule_controller.go +++ b/internal/controller/clusterwatchrule_controller.go @@ -449,7 +449,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/watchrule_controller.go b/internal/controller/watchrule_controller.go index ad164249..c4052075 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 @@ -109,6 +111,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 +230,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 +340,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 +448,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 +464,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..5114c742 --- /dev/null +++ b/internal/controller/watchrule_source_namespace.go @@ -0,0 +1,192 @@ +// 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 for a rule watching its own + // namespace against a GitTarget that declares no allowedSourceNamespaces policy. + WatchRuleReasonLegacySourceNamespace = authz.ReasonLegacySourceNamespace + // WatchRuleReasonSourceNamespaceAllowed is the True reason for a namespace a declared policy + // admits — an authorized override, or an own-namespace rule the policy explicitly lists. + WatchRuleReasonSourceNamespaceAllowed = authz.ReasonSourceNamespaceAllowed + // 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. +// +// 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) { + decision, 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", + "sourceNamespace", watchRule.EffectiveSourceNamespace(), + "gitTargetName", target.Name, "gitTargetNamespace", target.Namespace) + return true, ctrl.Result{}, err + } + + switch { + case decision.Admitted(): + r.setTypedCondition( + watchRule, + ConditionTypeSourceNamespaceAuthorized, + metav1.ConditionTrue, + decision.Reason, + decision.Message, + ) + return false, ctrl.Result{}, nil + + case decision.Terminal(): + result, refuseErr := r.refuseSourceNamespace(ctx, watchRule, decision, 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, decision) + 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, + decision authz.SourceNamespaceDecision, + log logr.Logger, +) (ctrl.Result, error) { + log.Info("Refusing WatchRule: its effective source namespace is not authorized", + "name", watchRule.Name, + "namespace", watchRule.Namespace, + "sourceNamespace", decision.Namespace, + "reason", decision.Reason) + + // 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, + decision.Reason, + decision.Message, + ) + r.setTypedCondition( + watchRule, + ConditionTypeStreamsRunning, + metav1.ConditionFalse, + decision.Reason, + "No streams: the effective source namespace is not authorized", + ) + r.setRuleStalled(watchRule, decision.Reason, decision.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, + decision authz.SourceNamespaceDecision, +) (ctrl.Result, error) { + r.setTypedCondition( + watchRule, + ConditionTypeSourceNamespaceAuthorized, + metav1.ConditionUnknown, + decision.Reason, + decision.Message, + ) + r.setTypedCondition( + watchRule, + ConditionTypeStreamsRunning, + metav1.ConditionUnknown, + decision.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..48474f28 --- /dev/null +++ b/internal/controller/watchrule_source_namespace_test.go @@ -0,0 +1,389 @@ +// 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/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}, + }, + AllowWatchRuleSourceNamespaceOverride: delegate, + }, + } +} + +func wrsnWatchRule(sourceNamespace string) *configbutleraiv1alpha3.WatchRule { + return &configbutleraiv1alpha3.WatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: wrsnRule, Namespace: wrsnTenantNS, Generation: 1}, + Spec: configbutleraiv1alpha3.WatchRuleSpec{ + TargetRef: configbutleraiv1alpha3.LocalTargetReference{Name: wrsnTarget}, + SourceNamespace: sourceNamespace, + Rules: []configbutleraiv1alpha3.ResourceRule{{Resources: []string{"configmaps"}}}, + }, + } +} + +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, + sourceNamespace string, +) []client.Object { + return []client.Object{ + wrsnGitTarget(policy), + wrsnGitProvider(), + wrsnClusterProvider(delegate), + wrsnWatchRule(sourceNamespace), + &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, "allowWatchRuleSourceNamespaceOverride", + "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, wrsnSourceNS, compiled[0].SourceNamespace) + 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) +} + +// 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") +} diff --git a/internal/rulestore/store.go b/internal/rulestore/store.go index ead71758..7e5cfaa7 100644 --- a/internal/rulestore/store.go +++ b/internal/rulestore/store.go @@ -21,6 +21,17 @@ type CompiledRule struct { // Source is the NamespacedName of the WatchRule CR. Source types.NamespacedName + // SourceNamespace is the namespace this rule watches IN ITS SOURCE CLUSTER — the value of + // WatchRule.EffectiveSourceNamespace() at compile time. + // + // It is a field of its own rather than an overload of Source.Namespace because the two are + // genuinely different namespaces in (potentially) different clusters: Source names the + // WatchRule OBJECT in the control plane, while this names the namespace whose objects are + // mirrored. They coincide only for a legacy rule. Every watch-planning consumer — the + // watched-type selection 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. + SourceNamespace string + // GitTarget reference (for event routing) GitTargetRef string GitTargetNamespace string @@ -129,6 +140,7 @@ func (s *RuleStore) AddOrUpdateWatchRule( compiled := CompiledRule{ Source: key, + SourceNamespace: rule.EffectiveSourceNamespace(), GitTargetRef: gitTargetName, GitTargetNamespace: gitTargetNamespace, GitProviderRef: gitProviderName, diff --git a/internal/watch/bootstrap.go b/internal/watch/bootstrap.go index f024ebe5..578eb372 100644 --- a/internal/watch/bootstrap.go +++ b/internal/watch/bootstrap.go @@ -58,13 +58,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. + decision, 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 !decision.Admitted() { + return fmt.Errorf("WatchRule %s/%s may not watch source namespace %q: %s", + rule.Namespace, rule.Name, decision.Namespace, decision.Message) + } return nil } diff --git a/internal/watch/bootstrap_admission_test.go b/internal/watch/bootstrap_admission_test.go index c2c39050..434a9c22 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}, @@ -92,7 +92,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 +122,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 +161,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"}}, ) 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_startup_test.go b/internal/watch/manager_startup_test.go index c395bd8a..5d987aa5 100644 --- a/internal/watch/manager_startup_test.go +++ b/internal/watch/manager_startup_test.go @@ -129,7 +129,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..4105c430 --- /dev/null +++ b/internal/watch/source_namespace_planning_test.go @@ -0,0 +1,135 @@ +// 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. Both sites they cover produce a stale watch rather than a +// visible failure: nothing errors, nothing is unready, the operator just quietly mirrors the wrong +// namespace. Without these tests the omissions are invisible until someone notices in production. + +// watchRuleWithSource is watchRuleForTarget plus an explicit spec.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.SourceNamespace = sourceNamespace + return rule +} + +// watchRuleOwnNamespace is the control-plane namespace every rule in these tests lives in; the +// point of each case is to vary the SOURCE namespace against it. +const watchRuleOwnNamespace = "tenant-acme" + +// makeStoreWithRule compiles one WatchRule into a fresh store, for the tests that assert on the +// compiled form rather than on the resolved watch plan. +func makeStoreWithRule(t *testing.T, rule configv1alpha3.WatchRule) *rulestore.RuleStore { + t.Helper() + store := rulestore.NewStore() + store.AddOrUpdateWatchRule(rule, "target", "test-ns", "test-provider", "test-ns", "main", "test-path") + return store +} + +// TestWatchRuleFingerprint_DiffersBySourceNamespace guards the fingerprint step. The watched-type +// table is only re-projected when the rules fingerprint changes, so if the fingerprint hashed the +// rule OBJECT's namespace instead of its SOURCE namespace, editing spec.sourceNamespace would +// leave the old watch running forever. +func TestWatchRuleFingerprint_DiffersBySourceNamespace(t *testing.T) { + store := makeStoreWithRule(t, watchRuleWithSource("rule", "target", "repo-config")) + withOverride := store.SnapshotWatchRules()[0] + + store = makeStoreWithRule(t, watchRuleWithSource("rule", "target", "other-namespace")) + withDifferentOverride := store.SnapshotWatchRules()[0] + + store = makeStoreWithRule(t, watchRuleWithSource("rule", "target", "")) + legacy := store.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_UsesEffectiveSourceNamespace guards the selection step: the watch +// scope must be the rule's SOURCE namespace, not the namespace the WatchRule object lives in. +func TestCollectWatchRuleSelections_UsesEffectiveSourceNamespace(t *testing.T) { + manager, store := makeWatchedTypeManager(t) + store.AddOrUpdateWatchRule( + watchRuleWithSource("override-rule", "src-target", "repo-config"), + "src-target", "test-ns", "test-provider", "test-ns", "main", "test-path", + ) + + 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_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) + store.AddOrUpdateWatchRule( + watchRuleForTarget("legacy-rule", "legacy-target", "tenant-acme"), + "legacy-target", "test-ns", "test-provider", "test-ns", "main", "test-path", + ) + + 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()) +} + +// TestRefreshWatchedTypeTables_SourceNamespaceChangeReProjects is the end of the same chain: an +// edit to spec.sourceNamespace must actually move the resolved watch scope. This is what the +// fingerprint guard above exists to make possible, asserted through the real re-projection path. +func TestRefreshWatchedTypeTables_SourceNamespaceChangeReProjects(t *testing.T) { + manager, store := makeWatchedTypeManager(t) + store.AddOrUpdateWatchRule( + watchRuleWithSource("rule", "reproj-target", "repo-config"), + "reproj-target", "test-ns", "test-provider", "test-ns", "main", "test-path", + ) + 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. + store.AddOrUpdateWatchRule( + watchRuleWithSource("rule", "reproj-target", "moved-namespace"), + "reproj-target", "test-ns", "test-provider", "test-ns", "main", "test-path", + ) + 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_SourceNamespaceIsSeparateFromTheRuleObject pins the field's meaning: Source +// names the WatchRule OBJECT in the control plane and SourceNamespace names the namespace being +// mirrored. Collapsing them is the mistake this field exists to prevent. +func TestCompiledRule_SourceNamespaceIsSeparateFromTheRuleObject(t *testing.T) { + store := makeStoreWithRule(t, watchRuleWithSource("rule", "target", "repo-config")) + + compiled := store.SnapshotWatchRules()[0] + + assert.Equal(t, "tenant-acme", compiled.Source.Namespace, "the WatchRule object's namespace") + assert.Equal(t, "repo-config", compiled.SourceNamespace, "the namespace being mirrored") +} diff --git a/internal/watch/source_namespace_scope.go b/internal/watch/source_namespace_scope.go new file mode 100644 index 00000000..700693c2 --- /dev/null +++ b/internal/watch/source_namespace_scope.go @@ -0,0 +1,360 @@ +// SPDX-License-Identifier: Apache-2.0 + +package watch + +import ( + "context" + "fmt" + "maps" + "sync" + + 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 + +// 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 source namespace last successfully GRANTED to each WatchRule — the + // "previously resolved scope" the establishing/maintaining contract turns on. + grants map[k8stypes.NamespacedName]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]string{}, + } + }) + 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 } + +// 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 := m.clusterIDForGitTarget(types.NewResourceReference(target.Name, target.Namespace)) + 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) + 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)), + } + 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} + } + + 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", + } +} + +// RetainedSourceNamespace reports the source namespace last GRANTED to a rule, and whether any +// grant was ever established. 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. +func (m *Manager) RetainedSourceNamespace(rule k8stypes.NamespacedName) (string, bool) { + scope := m.sourceScope() + scope.mu.RLock() + defer scope.mu.RUnlock() + ns, ok := scope.grants[rule] + return ns, ok +} + +// RecordSourceNamespaceGrant remembers that a rule was granted a source namespace, establishing +// the scope that RetainedSourceNamespace will later report. +func (m *Manager) RecordSourceNamespaceGrant(rule k8stypes.NamespacedName, namespace string) { + scope := m.sourceScope() + scope.mu.Lock() + defer scope.mu.Unlock() + scope.grants[rule] = namespace +} + +// ForgetSourceNamespaceGrant 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) ForgetSourceNamespaceGrant(rule k8stypes.NamespacedName) { + scope := m.sourceScope() + scope.mu.Lock() + defer scope.mu.Unlock() + delete(scope.grants, rule) +} + +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. +func (m *Manager) refreshSourceNamespaceScopes(ctx context.Context) { + scope := m.sourceScope() + for _, clusterID := range scope.wantedClusters() { + next := m.listSourceNamespaces(ctx, clusterID) + if scope.store(clusterID, next) { + m.enqueueSourceNamespaceChange(clusterID) + } + } +} + +// 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..2617e0d3 --- /dev/null +++ b/internal/watch/source_namespace_stream_summary_test.go @@ -0,0 +1,119 @@ +// 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/types" +) + +// This is the regression test for the e2e-only bug PR 4 shipped with a first cut: 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 (effective source) namespace. The two +// differ exactly when spec.sourceNamespace 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. + +func srcnsSummaryManager(t *testing.T) *Manager { + t.Helper() + return &Manager{ + Log: logr.Discard(), + 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() configv1alpha3.WatchRule { + return configv1alpha3.WatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: "repo-config-rule", Namespace: "tenant-acme"}, + Spec: configv1alpha3.WatchRuleSpec{ + TargetRef: configv1alpha3.LocalTargetReference{Name: "acme"}, + SourceNamespace: "repo-config", + Rules: []configv1alpha3.ResourceRule{{Resources: []string{"configmaps"}}}, + }, + } +} + +// TestStreamSummaryForWatchRule_KeysOnEffectiveSourceNamespace is the fix asserted: a stream keyed +// on the WATCHED namespace is found, and the rule reports StreamsRunning. +func TestStreamSummaryForWatchRule_KeysOnEffectiveSourceNamespace(t *testing.T) { + m := srcnsSummaryManager(t) + rule := srcnsOverrideRule() + gitDest := types.NewResourceReference("acme", "tenant-acme") + configmaps := schema.GroupVersionResource{Version: "v1", Resource: "configmaps"} + + // The data plane keyed the live stream on the SOURCE namespace ("repo-config"). + m.seedStreamState(gitDest, + targetWatchKey{GVR: configmaps, 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() + gitDest := types.NewResourceReference("acme", "tenant-acme") + configmaps := schema.GroupVersionResource{Version: "v1", Resource: "configmaps"} + + // A stream keyed on the CONFIG-PLANE namespace is a different stream entirely. + m.seedStreamState(gitDest, + targetWatchKey{GVR: configmaps, 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_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() + rule.Spec.SourceNamespace = "" // legacy: watches its own namespace + gitDest := types.NewResourceReference("acme", "tenant-acme") + configmaps := schema.GroupVersionResource{Version: "v1", Resource: "configmaps"} + + m.seedStreamState(gitDest, + targetWatchKey{GVR: configmaps, 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..56fc9589 --- /dev/null +++ b/internal/watch/source_namespace_test.go @@ -0,0 +1,361 @@ +// SPDX-License-Identifier: Apache-2.0 + +package watch + +import ( + "context" + "testing" + + "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" + k8stypes "k8s.io/apimachinery/pkg/types" + "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}}, + AllowWatchRuleSourceNamespaceOverride: delegate, + }, + } +} + +func snbWatchRule(sourceNamespace string) *configv1alpha3.WatchRule { + return &configv1alpha3.WatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: snbRule, Namespace: snbTenantNS}, + Spec: configv1alpha3.WatchRuleSpec{ + TargetRef: configv1alpha3.LocalTargetReference{Name: snbTarget}, + SourceNamespace: sourceNamespace, + Rules: []configv1alpha3.ResourceRule{{Resources: []string{"configmaps"}}}, + }, + } +} + +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, snbTenantNS, compiled[0].SourceNamespace, + "a legacy rule's source namespace is its 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, snbSourceNS, compiled[0].SourceNamespace) + 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() + + decision, err := CompileWatchRule(ctx, m.Client, m.RuleStore, m, rule, target, provider) + require.NoError(t, err) + require.True(t, decision.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"}}) + + decision, err = CompileWatchRule(ctx, m.Client, m.RuleStore, m, rule, tightened, provider) + + require.NoError(t, err) + assert.Equal(t, authz.SourceScopeDenied, decision.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). + decision, err := CompileWatchRule(ctx, m.Client, m.RuleStore, m, rule, named, provider) + require.NoError(t, err) + require.True(t, decision.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(configPlaneClusterID, namespaceSnapshot{forbidden: true}) + + decision, err = CompileWatchRule(ctx, m.Client, m.RuleStore, m, rule, selector, provider) + + require.NoError(t, err) + assert.Equal(t, authz.SourceScopeUnknown, decision.Verdict, + "a retained scope is Unknown, never a terminal failure") + assert.Len(t, m.RuleStore.SnapshotWatchRules(), 1, + "the last known-good scope keeps running: no narrowing, no sweep") +} + +// 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(configPlaneClusterID, namespaceSnapshot{forbidden: true}) + + selector := *snbGitTarget(&configv1alpha3.NamespaceMatcher{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirrorable": "true"}}, + }) + + decision, err := CompileWatchRule( + ctx, m.Client, m.RuleStore, m, *snbWatchRule(snbSourceNS), selector, *snbGitProvider()) + + require.NoError(t, err) + assert.Equal(t, authz.SourceScopeUnavailable, decision.Verdict, + "with no scope ever resolved this is terminal, not a retained scope") + assert.Empty(t, m.RuleStore.SnapshotWatchRules()) +} + +// TestCompileWatchRule_RetentionIsNamespaceSpecific: a rule that EDITS spec.sourceNamespace is +// establishing a NEW grant, so a stale grant for the previous namespace must not let an +// unevaluatable policy through. +func TestCompileWatchRule_RetentionIsNamespaceSpecific(t *testing.T) { + ctx := context.Background() + m := snbManager(t, + snbGitTarget(nil), snbGitProvider(), snbClusterProvider(true), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snbTenantNS}}, + ) + m.RecordSourceNamespaceGrant( + k8stypes.NamespacedName{Name: snbRule, Namespace: snbTenantNS}, snbSourceNS) + m.sourceScope().store(configPlaneClusterID, namespaceSnapshot{forbidden: true}) + + selector := *snbGitTarget(&configv1alpha3.NamespaceMatcher{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirrorable": "true"}}, + }) + + // The rule now asks for a DIFFERENT namespace than the one it holds a grant for. + decision, err := CompileWatchRule( + ctx, m.Client, m.RuleStore, m, *snbWatchRule("some-other-namespace"), selector, *snbGitProvider()) + + require.NoError(t, err) + assert.Equal(t, authz.SourceScopeUnavailable, decision.Verdict, + "a grant for a different namespace must not be retained across an edit") +} + +// 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(configPlaneClusterID, 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(configPlaneClusterID, 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(configPlaneClusterID, 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(configPlaneClusterID, 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") + }) +} + +// 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]string{}, + } + 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..4566e882 100644 --- a/internal/watch/stream_readiness.go +++ b/internal/watch/stream_readiness.go @@ -131,7 +131,12 @@ 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. func (m *Manager) StreamSummaryForWatchRule(rule configv1alpha3.WatchRule) StreamSummary { + // The GitTarget is in the rule's OWN namespace (targetRef is a LocalTargetReference), but the + // stream is keyed on the namespace being WATCHED — the effective source namespace. Those + // differ whenever spec.sourceNamespace is set, so looking the stream up by rule.Namespace would + // miss it and report the rule perpetually not-ready even while its stream runs. gitDest := types.NewResourceReference(rule.Spec.TargetRef.Name, rule.Namespace) + sourceNamespace := rule.EffectiveSourceNamespace() reg := m.registryForGitTarget(gitDest) m.refreshClusterTypeRegistry(m.cluster(m.clusterIDForGitTarget(gitDest))) records := reg.Followable() @@ -141,7 +146,7 @@ func (m *Manager) StreamSummaryForWatchRule(rule configv1alpha3.WatchRule) Strea matched := matchFollowableRecords( records, rr.APIGroups, rr.APIVersions, rr.Resources, configv1alpha3.ResourceScopeNamespaced) for _, rec := range matched { - key := targetWatchKey{GVR: rec.Identity.GVR, Namespace: rule.Namespace} + key := targetWatchKey{GVR: rec.Identity.GVR, Namespace: sourceNamespace} keys = append(keys, key) names[rec.Identity.GVR] = streamDisplayName(rec.Identity.GVR) } diff --git a/internal/watch/watched_type_resolver.go b/internal/watch/watched_type_resolver.go index ffb3cd28..de912aff 100644 --- a/internal/watch/watched_type_resolver.go +++ b/internal/watch/watched_type_resolver.go @@ -293,8 +293,14 @@ func (m *Manager) collectWatchRuleSelections( matched := matchFollowableRecords( records, rr.APIGroups, rr.APIVersions, rr.Resources, configv1alpha3.ResourceScopeNamespaced) for _, rec := range matched { + // The rule's SOURCE namespace, NOT rule.Source.Namespace (which names the + // WatchRule object in the control plane). These differ whenever the rule sets + // spec.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. ts.selections = append(ts.selections, watchSelection{ - record: rec, namespace: rule.Source.Namespace, ops: rr.Operations, + record: rec, namespace: rule.SourceNamespace, ops: rr.Operations, }) } } @@ -475,10 +481,16 @@ 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. The src= component MUST be the rule's SOURCE namespace, not the WatchRule object's own +// namespace: those diverge as soon as spec.sourceNamespace is set, and hashing the wrong one means +// an edit to that field does not re-project the watched-type table. That failure is silent — the +// old watch simply keeps running — which is why it is called out here rather than left to the +// field name. 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, + rule.GitTargetNamespace, rule.GitTargetRef, rule.SourceNamespace, 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]", diff --git a/internal/watch/watchrule_compile.go b/internal/watch/watchrule_compile.go new file mode 100644 index 00000000..1f1f66ce --- /dev/null +++ b/internal/watch/watchrule_compile.go @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 + +package watch + +import ( + "context" + + 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 +// 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 + + // RetainedSourceNamespace reports the source namespace last granted to a rule, and whether + // any grant was ever established for it. + RetainedSourceNamespace(rule k8stypes.NamespacedName) (string, bool) + // RecordSourceNamespaceGrant remembers a successful grant. + RecordSourceNamespaceGrant(rule k8stypes.NamespacedName, namespace string) + // ForgetSourceNamespaceGrant drops a rule's resolved scope on a refusal or a deletion. + ForgetSourceNamespaceGrant(rule k8stypes.NamespacedName) +} + +// CompileWatchRule is THE ONLY PATH from a WatchRule to a compiled rule. It runs the +// source-namespace gate 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 and its grant recorded. The caller publishes +// SourceNamespaceAuthorized=True. +// - TERMINAL (denied, or a permanently unevaluatable policy with no scope ever resolved) — 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.SourceNamespaceDecision, error) { + key := k8stypes.NamespacedName{Name: rule.Name, Namespace: rule.Namespace} + + decision, err := authz.WatchRuleSourceNamespaceAdmitted(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 decision, err + } + + if decision.Admitted() { + store.AddOrUpdateWatchRule( + rule, + target.Name, target.Namespace, + provider.Name, provider.Namespace, + target.Spec.Branch, + target.Spec.Path, + ) + if scope != nil { + scope.RecordSourceNamespaceGrant(key, decision.Namespace) + } + return decision, nil + } + + // An unevaluatable policy on a rule that ALREADY has a resolved scope 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 decision.Verdict == authz.SourceScopeUnavailable && retainsScope(scope, key, decision.Namespace) { + decision.Verdict = authz.SourceScopeUnknown + return decision, nil + } + + if decision.Terminal() { + // Stop the data plane before the caller says anything about it. + store.Delete(key) + if scope != nil { + scope.ForgetSourceNamespaceGrant(key) + } + } + return decision, nil +} + +// retainsScope reports whether a rule already holds a resolved grant for this same namespace. It +// is deliberately namespace-specific: a rule that EDITS spec.sourceNamespace is establishing a new +// grant, not maintaining its old one, so a stale grant for a different namespace must not let an +// unevaluatable policy through. +func retainsScope(scope SourceScopeService, rule k8stypes.NamespacedName, namespace string) bool { + if scope == nil { + return false + } + granted, ok := scope.RetainedSourceNamespace(rule) + return ok && granted == namespace +} + +// 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 +} diff --git a/test/e2e/source_namespace_e2e_test.go b/test/e2e/source_namespace_e2e_test.go new file mode 100644 index 00000000..10383b3e --- /dev/null +++ b/test/e2e/source_namespace_e2e_test.go @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "fmt" + "os" + "path" + "path/filepath" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// This spec covers WatchRule.spec.sourceNamespace end to end (see +// docs/design/watchrule-source-namespace/pr4-source-namespace-field.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. +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" + 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. + configNS string + sourceNS string + srcnsRepo *RepoArtifacts + ) + + BeforeAll(func() { + By("creating separate config-plane and source namespaces") + configNS = testNamespaceFor("srcns-config") + sourceNS = testNamespaceFor("srcns-source") + _, _ = kubectlRun("create", "namespace", configNS) + _, _ = kubectlRun("create", "namespace", sourceNS) + + By("setting up Gitea repo and credentials") + srcnsRepo = SetupRepo( + resolveE2EContext(), + configNS, + fmt.Sprintf("e2e-srcns-%d", GinkgoRandomSeed()), + ) + _, 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 namespace, and one that is refused") + Expect(applyGitTargetWithSourceNamespaces( + configNS, grantedTarget, providerName, grantedPath, delegatingCP, sourceNS)).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) + }) + + SetDefaultEventuallyTimeout(60 * time.Second) + SetDefaultEventuallyPollingInterval(time.Second) + + It("mirrors an authorized source namespace under THAT namespace's Git folder", func() { + By("creating a WatchRule in the config namespace that 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("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", + "allowWatchRuleSourceNamespaceOverride") + 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] + allowWatchRuleSourceNamespaceOverride: %t +`, name, allowedNS, delegate) + return kubectlRunWithStdin("", manifest, "apply", "-f", "-") +} + +// applyGitTargetWithSourceNamespaces applies a GitTarget that declares an allowedSourceNamespaces +// policy naming one source namespace by exact name. +func applyGitTargetWithSourceNamespaces( + ns, name, gitProvider, targetPath, clusterProvider, sourceNS 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, sourceNS) + return kubectlRunWithStdin(ns, manifest, "apply", "-f", "-") +} + +// applyWatchRuleWithSourceNamespace applies a WatchRule that watches a namespace OTHER than its own. +func applyWatchRuleWithSourceNamespace(name, ns, target, sourceNS string) (string, error) { + manifest := fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 +kind: WatchRule +metadata: + name: %s + namespace: %s +spec: + targetRef: + kind: GitTarget + name: %s + sourceNamespace: %s + rules: + - resources: ["configmaps"] +`, name, ns, target, sourceNS) + return kubectlRunWithStdin(ns, manifest, "apply", "-f", "-") +} From a4ef0717addd36497932e3abfba9dc5adc56716e Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 21 Jul 2026 07:56:55 +0000 Subject: [PATCH 02/18] docs: starting to consider alternative approach --- .../watchrule-source-namespace/README.md | 11 +- ...alt-clusterwatchrule-cluster-scope-only.md | 522 +++++++++--------- .../alt-per-item-source-namespace.md | 253 +++++++++ .../pr5-cluster-scope-and-deletion-safety.md | 324 +++++++++++ .../pr5-clusterwatchrule-source-ceiling.md | 10 + 5 files changed, 858 insertions(+), 262 deletions(-) create mode 100644 docs/design/watchrule-source-namespace/alt-per-item-source-namespace.md create mode 100644 docs/design/watchrule-source-namespace/pr5-cluster-scope-and-deletion-safety.md diff --git a/docs/design/watchrule-source-namespace/README.md b/docs/design/watchrule-source-namespace/README.md index dbdeed2e..92b19d60 100644 --- a/docs/design/watchrule-source-namespace/README.md +++ b/docs/design/watchrule-source-namespace/README.md @@ -119,7 +119,7 @@ are pre-existing defect fixes that carry no API change; the last two are the fea | 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 | **landed** | -| 5 | [The ClusterWatchRule ceiling](pr5-clusterwatchrule-source-ceiling.md) | A declared `allowedSourceNamespaces` narrows a ClusterWatchRule's namespaced streams. | 1, 2, 4 | not started | +| 5 | [Cluster-scope + deletion safety](pr5-cluster-scope-and-deletion-safety.md) | ClusterWatchRule becomes cluster-scoped only (scope by kind, `scope: Namespaced` removed), WatchRule gains a per-rule-item `namespace` with `"*"` = all-admitted, and `GitTarget.spec.prune` (`never`/`onEvent`/`always`, default `onEvent`) + a volume guard make scope mistakes non-destructive. Breaking. Replaces the [abandoned runtime ceiling](pr5-clusterwatchrule-source-ceiling.md). | 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 @@ -128,10 +128,13 @@ Git. PRs 2, 4, and 5 each introduce exactly that fan-out, independently, so land 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 +**Release gate: do not cut a release between PR 4 and PR 5.** PR 4 shipped the field; until PR 5 lands, +`allowedSourceNamespaces` is enforced on the rule kind that cannot bypass it (WatchRule) and unenforced +on the one that can (ClusterWatchRule's `scope: Namespaced`), 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. +that hits the gap. The replacement PR 5 closes it by **removing** that scope rather than bounding it, so +the gate still holds: do not release with PR 4 in and PR 5 out. PR 5's `prune` default (`onEvent`) is +also what makes a rollback across PR 5 recoverable instead of a tenant-data-loss event. PR 3 is independent of the rest and can go at any point. diff --git a/docs/design/watchrule-source-namespace/alt-clusterwatchrule-cluster-scope-only.md b/docs/design/watchrule-source-namespace/alt-clusterwatchrule-cluster-scope-only.md index 69d8423c..d919b0a5 100644 --- a/docs/design/watchrule-source-namespace/alt-clusterwatchrule-cluster-scope-only.md +++ b/docs/design/watchrule-source-namespace/alt-clusterwatchrule-cluster-scope-only.md @@ -1,277 +1,283 @@ -# Alternative — make ClusterWatchRule cluster-scoped only, and pluralize the WatchRule field +# Alternative — the two-object model: scope is carried by the kind, and the enum is removed -> **Status: proposal, for review. Not agreed, not scheduled.** An alternative to -> [PR 5](pr5-clusterwatchrule-source-ceiling.md) and an amendment to -> [PR 4](pr4-source-namespace-field.md), inside the -> [source-namespace addressing](README.md) workstream. +> **Status: proposal, for review. Not agreed, not scheduled.** The crisp, breaking form of the +> WatchRule-side redesign, sibling to the non-breaking +> [alt-per-item-source-namespace.md](alt-per-item-source-namespace.md). Both share the same WatchRule +> interface (a per-rule-item `namespace`); they differ only on ClusterWatchRule, and that difference +> is the whole breaking/non-breaking axis this page exists to compare. > -> **PR 4 is being implemented as this is written** — -> [`WatchRule.spec.sourceNamespace`](../../../api/v1alpha3/watchrule_types.go#L71) already exists in -> the tree with its full doc comment. This page is therefore written as a *delta against work in -> flight*, not as a greenfield design. The migration section says exactly what it would cost to adopt -> late; the answer is "less than it looks", and the deciding factor is whether the field is plural -> before it ships in a release, not before it is written. +> Supersedes this file's earlier "narrow the enum to `Cluster`" framing: narrowing left a vestigial +> single-value `scope: Cluster` field on every rule. The crisper move is to **remove the field +> entirely** — scope is redundant with discovery, so the kind can carry it. See +> [why remove, not narrow](#why-remove-the-enum-not-narrow-it). > -> Code references verified against the tree on 2026-07-20. - -## The proposal - -Two changes, one API-shaped and one field-shaped: - -1. **`ClusterWatchRule` selects cluster-scoped types only.** Narrow - [`ClusterResourceRule.Scope`](../../../api/v1alpha3/clusterwatchrule_types.go#L110) to - `Cluster`. Namespaced types are selected exclusively by `WatchRule`. -2. **`WatchRule.spec.sourceNamespace` becomes `sourceNamespaces`**, a list of names, since it is now - the only object that can address namespaced resources at all. - -`GitTarget.spec.allowedSourceNamespaces` and the ClusterProvider delegation flag are unchanged, and -so is [PR 4](pr4-source-namespace-field.md)'s gate. What changes is that they become the *only* path -into a target, rather than one of two with a runtime ceiling reconciling them. - -## Why: PR 5 re-imposes at runtime a boundary the API can make unrepresentable - -[PR 5](pr5-clusterwatchrule-source-ceiling.md) exists for exactly one reason: `ClusterWatchRule` -accepts `scope: Namespaced`, and its `targetRef` is cross-namespace by design -([clusterwatchrule_types.go:22-44](../../../api/v1alpha3/clusterwatchrule_types.go#L22-L44)), so a -rule can deliver every source namespace into a GitTarget that declared an allow-list. PR 5 answers -that by resolving the target's policy inside the watch resolver and expanding a cluster-wide -selection into one selection per admitted namespace. - -That is a correct design for the constraint as posed. The observation here is that the constraint is -self-imposed. Removing `Namespaced` from the enum makes the bypass **unrepresentable** rather than -**checked**, and the following all cease to exist: - -| PR 5 section | What it costs today | Under this proposal | -|---|---|---| -| §1 apply the ceiling in selection | expand `namespace: ""` per admitted namespace in `collectClusterWatchRuleSelections` ([watched_type_resolver.go:306-323](../../../internal/watch/watched_type_resolver.go#L306-L323)) | gone — cluster-scoped rules legitimately emit `""` | -| §2 resolved scope into table invalidation | `clusterWatchRuleFingerprint` must hash state that **is not rule state** ([watched_type_resolver.go:463-500](../../../internal/watch/watched_type_resolver.go#L463-L500)); an unchanged rule under a changed policy must still re-project the table | gone — a ClusterWatchRule's scope is spec-derived again | -| §2b unknown is not empty | the ClusterWatchRule half of the narrow-then-sweep hazard | gone — nothing narrows | -| §3 reactivity | GitTarget → ClusterWatchRules mapper; informer fan-out to ClusterWatchRules | gone | -| release gate | PR 4 must not ship without PR 5 | survives, but for a smaller reason — see [migration](#if-pr-4-has-already-shipped-singular) | - -§2 is the one worth dwelling on, because it is the subtlest defect in the folder and it is created -entirely by the ceiling: the ceiling's inputs (GitTarget policy, source-cluster Namespace labels) are -not rule state, so a mapper that requeues the rule is not sufficient — reconciliation runs, the -fingerprint is unchanged, the rebuild is skipped, and the resident table keeps the old namespace set -while every diff looks correct. Designing that away is worth more than the field it protects. - -### It also makes the documented caveat into a definition - -[README § what the ceiling does not do](README.md#what-the-ceiling-does-not-do) already concedes that -a namespace allow-list cannot partition cluster-scoped objects, and warns that "my allow-list bounds -what this tenant sees" is false for the cluster-scoped half. Under this proposal that stops being a -caveat attached to a field and becomes the definition of the object: - -> **ClusterWatchRule is the cluster-global surface. It is not namespace-bounded, by construction.** -> **WatchRule is the namespaced surface, and every namespace it reaches is named in its spec and -> admitted by its GitTarget.** - -Two objects, two sentences, no overlap. The operator's answer to *"will this stream objects from -namespaces outside my allow-list?"* is a property of the kind, not of an audit. - -The day-one multi-tenant use case ([README § driving use case](README.md#the-driving-use-case-multi-tenancy-with-crds-on-day-one), -item 4) is unaffected: a tenant still needs a ClusterWatchRule from day one to capture CRDs, and that -is precisely what the narrowed object does. `config/samples/clusterwatchrule.yaml` already uses only -`scope: Cluster`. - -## The cost: `scope: Namespaced` is the only expression of two capabilities - -This is the part a reviewer should push on, because the capability does not disappear — it -**relocates**, and the proposal is only a win if the new home is the right one. - -**Capability A — "watch this type in every namespace."** One object, no enumeration, and it covers -namespaces that do not exist yet. `sourceNamespaces` as a list of *names* does not replace this: -enumeration is not "all", and it goes stale on namespace creation. Expressing "all" again requires a -wildcard entry or a `sourceNamespaceSelector`. - -**Capability B — platform-authored namespaced watching for a tenant's target.** -`ClusterWatchRule.targetRef` is cross-namespace, so a platform team can configure a tenant's mirror -without owning an object in the tenant's namespace. `WatchRule.targetRef` is a `LocalTargetReference` -with no namespace field ([watchrule_types.go:24-42](../../../api/v1alpha3/watchrule_types.go#L24-L42)), -so the replacement must live in the tenant's namespace and be authored by whoever can write there. - -So the honest framing of this proposal is **not** "cluster-scoped resources belong on a cluster-scoped -object". It is: - -> Which object may say *"all namespaces"*, and under whose sign-off? - -Today: ClusterWatchRule, under nothing but cluster-admin's ability to create one. Under this proposal: -WatchRule, under `allowedSourceNamespaces` plus the delegation flag — the pair PR 4 already builds, -readable off the GitTarget. That is a better answer. It is not an elimination, and a review that -treats it as one will be surprised later. - -**Two questions decide whether the win is large or merely structural:** - -1. Does any real configuration need "all namespaces" as a live, self-updating scope? If yes, a - wildcard or selector must ship in v1 and the machinery does not fully disappear — it moves. -2. Must a platform team be able to author namespaced watching for a tenant target without an object - in the tenant namespace? If yes, this proposal removes a capability with no replacement, and - should be rejected or paired with one. - -## Names-only plural is cheap; a selector is not - -Split the plural question, because the two halves have very different cost. - -**`sourceNamespaces: [a, b]`, names only.** The resolved scope stays spec-derived. `watchRuleFingerprint` -([watched_type_resolver.go:478-482](../../../internal/watch/watched_type_resolver.go#L478-L482)) keeps -working with no new invalidation input; no source-cluster Namespace informer is needed for the *rule* -side; and *cannot say* collapses to a pure **establishing** question. That last point cuts into PR 4 -as well — the [source-scope service](pr4-source-namespace-field.md#the-source-scope-service--define-this-interface-before-writing-the-gate) -and the three-valued readiness result are dragged in mostly by **selector** policies, not by the field -itself. A names-only v1 on both sides (`sourceNamespaces` and `allowedSourceNamespaces`) would defer -the informer entirely. - -**`sourceNamespaceSelector`.** This pulls the whole *maintaining* column of -[establishing versus maintaining](pr4-source-namespace-field.md#establishing-versus-maintaining-a-scope) -onto WatchRule, where today it applies only to PR 5's ceiling. A WatchRule's resolved set can now -narrow, and a narrowed set is the input to a sweep — the destructive path -[PR 1](pr1-namespace-scoped-resync.md) landed first to make survivable. The machinery deleted from -ClusterWatchRule reappears on WatchRule the moment the field is dynamic. Net simplification is still -positive — one path instead of two — but it is not free, and it should not be sold as free. - -**Recommended split:** names-only in v1; selector as a follow-up whose entire subject is the -maintaining contract, rather than a subsection of a field addition. - -### One consequence of plural to check, not assume - -A single WatchRule fanning out over N namespaces needs an identity-complete placement template — -`{name}` plus `{namespace}` or `{namespaceOrCluster}` — or two source namespaces collapse onto one -Git path. That is enforced statically only for core Secrets today -([placement.go:550-552](../../../internal/manifestanalyzer/placement.go#L550-L552), -[gittarget_placement_validation.go:67](../../../internal/controller/gittarget_placement_validation.go#L67)); -operator-configured sensitive types rely on write-time guards. The hazard is not created by plural — -two singular WatchRules on one GitTarget already reach it — but plural makes it reachable with one -object and no second author. Worth a static check on any rule that can resolve to more than one -namespace. **Verify against the placement code; do not take this paragraph as settled.** - -## Narrowing the enum does not retract stored objects - -The step most likely to be skipped, and it is a security step, not a cleanup. - -CRD schema validation applies on **write**, not on read. A `ClusterWatchRule` already stored with -`scope: Namespaced` continues to be served, continues to be compiled by -[`bootstrapRuleStore`](../../../internal/watch/bootstrap.go#L49-L68), and continues to emit -`namespace: ""` — so the bypass survives the enum change for every object created before it. The -narrowed enum only prevents *new* ones. - -So the change is two-part: - -1. narrow the enum (blocks new and blocks updates to existing); **and** -2. make the compile path refuse a `Namespaced` cluster rule explicitly — in the same single gated - function [PR 4 step 7](pr4-source-namespace-field.md#implementation-steps) routes both the - reconciler and bootstrap through — with a terminal condition naming the replacement. - -Doing (1) without (2) is the same class of defect this folder was written to fix: a scope computed in -one place and not enforced in another. A test asserting that a *pre-existing* `Namespaced` cluster -rule is refused at bootstrap is the one that keeps it honest, because no manifest in the repo can -create the object once the enum lands. - -## If PR 4 has already shipped singular - -The cost of adopting late is bounded and depends only on whether a release has gone out. - -**Before any release carrying `sourceNamespace`** — the pluralization is a rename with no compatibility -surface. This is a preliminary v1alpha3 API and [README § compatibility](README.md#compatibility) -already accepts observable changes with no conversion webhook. The field is new, so it is unset -everywhere; nothing to migrate. - -**After a release** — accept both, or accept the churn. `sourceNamespace` and `sourceNamespaces` can -coexist with a CEL rule rejecting both-set and the controller reading singular as a one-element list, -which is ugly but small. Preferred instead: pluralize at the next API version with conversion, and do -not ship singular into a second release. - -Either way, **the rest of PR 4 is unaffected**. The gate, the `SourceNamespaceAuthorized` condition, -the printer column, the fingerprint change, the single gated compile path, and the reactivity mappers -all apply per-namespace-in-the-set exactly as written for one namespace. The work in flight is not -wasted by this proposal; the diff is the field's cardinality and the loop around the gate. - -The release gate survives in a weaker form: `Namespaced` must be removed from `ClusterWatchRule` -before, or in, the release that first ships `allowedSourceNamespaces`. Otherwise the allow-list is -enforced on the kind that cannot bypass it and unenforced on the kind that can — the same gap -[PR 5](pr5-clusterwatchrule-source-ceiling.md#why-this-is-launch-set-not-follow-up) describes, for the -same reason. - -## Labels: two axes, only one is affected - -These get conflated, and conflating them muddies both: - -- **Labels on namespaces** — `sourceNamespaceSelector`, or the selector half of - `allowedSourceNamespaces`. Shares the source-cluster Namespace informer, and carries the - narrow-then-sweep hazard above. Affected by this proposal. -- **Labels on resources** — "mirror only objects carrying `x=y`", a per-rule object filter on - [`ResourceRule`](../../../api/v1alpha3/watchrule_types.go#L83). Composes with whatever namespace - scoping exists, needs no authorization gate, has no sweep semantics beyond what a scope change - already has. **Unaffected by this proposal**, and it should be designed separately so it does not - get entangled with the authorization work. - -So "this fits the label vision better" is true for namespace selectors and neutral for resource -labels. +> **PR 4 is being implemented as this is written, top-level singular.** The compiled rule already +> reads `SourceNamespace` at [watched_type_resolver.go:301](../../../internal/watch/watched_type_resolver.go#L301). +> This page is a delta against that; the [comparison](#comparing-the-approaches--breaking-change-consequences) +> is the point of the page. +> +> Code references verified against the tree on 2026-07-21. + +## The model + +Two objects, each monomorphic in scope, with scope carried by the **kind** rather than by a field: + +- **`WatchRule`** follows **namespaced** resources. Its `ResourceRule` list items gain a `namespace` + option — omitted means the rule's own namespace (legacy), `"*"` means every namespace the + GitTarget's `allowedSourceNamespaces` **admits**, an explicit name means that source namespace. This + is the interface designed in full in + [alt-per-item-source-namespace.md](alt-per-item-source-namespace.md); it is unchanged here. +- **`ClusterWatchRule`** follows **cluster-scoped** resources. Its `ClusterResourceRule` list items + **lose** the `Scope` field. Every rule is cluster-scoped, because that is what the kind means. + +The [`ResourceScope` enum](../../../api/v1alpha3/clusterwatchrule_types.go#L9-L19) is removed from the +API surface of both kinds — WatchRule never had it, ClusterWatchRule no longer needs it. (The internal +`typeset.Scope` used for discovery matching stays; only the user-facing spec field goes.) + +~~~yaml +# namespaced — WatchRule, per-item namespace +kind: WatchRule +metadata: { name: repo-config, namespace: tenant-acme } +spec: + targetRef: { name: acme } + rules: + - resources: [configmaps] # own namespace (legacy) + - resources: [secrets] + namespace: repo-config # one source namespace + - resources: [deployments] + namespace: "*" # every namespace the target's policy admits +--- +# cluster-scoped — ClusterWatchRule, no scope field at all +kind: ClusterWatchRule +metadata: { name: acme-crds } +spec: + targetRef: { name: acme, namespace: tenant-acme } + rules: + - resources: [customresourcedefinitions] # cluster-scoped, implicitly + apiGroups: [apiextensions.k8s.io] +~~~ + +The result is the sentence the whole folder was trying to make legible, now true by construction: + +> **ClusterWatchRule is the cluster-global surface. WatchRule is the namespaced surface, and every +> namespace it reaches is named in its spec and admitted by its GitTarget.** Which object watches what +> is answerable from the *kind*, never from an audit of a per-rule scope field. + +## Why remove the enum, not narrow it + +The [earlier revision of this page] narrowed `Scope` to a single legal value, `Cluster`. That leaves a +field every rule must still spell out (`scope: Cluster`) whose only legal value is the default — pure +boilerplate. Removal is crisper, and it is *safe*, because the field is **redundant with discovery**: + +- Discovery records already carry scope: [`TypeRecord.Identity.Scope`](../../../internal/typeset/model.go#L39) + is `Namespaced` / `ClusterScoped` / `Unknown`. +- [`matchFollowableRecords`](../../../internal/watch/watched_type_resolver.go#L342) already filters the + declared enum **against** that discovered truth via + [`matchesScope`](../../../internal/watch/watched_type_resolver.go#L397). + +So the operator never needed the user to tell it whether `configmaps` is namespaced — it knows. Under +the two-object model the *kind* supplies the scope filter (WatchRule keeps `ScopeNamespaced` records, +ClusterWatchRule keeps `ScopeCluster` records) and discovery supplies the fact. Nothing is lost by +deleting the field; a class of user error — declaring a scope that contradicts discovery — is deleted +with it. + +## This deletes PR 5 outright + +[PR 5](pr5-clusterwatchrule-source-ceiling.md) exists solely to make `allowedSourceNamespaces` bind +ClusterWatchRule's `scope: Namespaced` streams. If a ClusterWatchRule can only watch cluster-scoped +resources, a **namespace** allow-list is simply not applicable to it — exactly as the +[overview already concedes](README.md#what-the-ceiling-does-not-do) that the ceiling cannot partition +cluster-scoped objects. So the entire PR disappears, not just its API: + +| PR 5 mechanism | Fate under the two-object model | +|---|---| +| §1 expand `namespace: ""` per admitted namespace in `collectClusterWatchRuleSelections` | gone — ClusterWatchRule emits `""` for genuinely cluster-scoped streams, no expansion | +| §2 hash resolved scope into `clusterWatchRuleFingerprint` — state that **is not rule state** | gone — a ClusterWatchRule's scope is spec-derived again. This is the subtlest defect in the folder, and it evaporates | +| §2b "unknown is not empty" (the ClusterWatchRule half of the sweep hazard) | gone — nothing narrows on ClusterWatchRule | +| §3 GitTarget → ClusterWatchRules mapper + informer fan-out | gone | +| the release gate coupling PR 4 to PR 5 | gone — there is no second rule kind to leave unenforced | + +The dynamic-scope machinery is not *eliminated* — `WatchRule` still needs it for `"*"` against a +**selector** policy — but it is *consolidated onto one kind* instead of living on both. Under the +as-designed plan it exists twice: PR 4's selector `allowedSourceNamespaces` on WatchRule **and** PR 5's +ceiling on ClusterWatchRule. Here it exists once, on WatchRule, and only for rules that write `"*"`. + +## Comparing the approaches — breaking-change consequences + +Three approaches are now on the table. They agree on the WatchRule interface (per-item `namespace`, +`"*"` bounded by the allow-list); they differ **only** on ClusterWatchRule, and that is the entire +breaking/non-breaking split. + +| | **As-designed** (PR 4 + PR 5) | **Per-item, non-breaking** ([sibling](alt-per-item-source-namespace.md)) | **Two-object** (this page) | +|---|---|---|---| +| ClusterWatchRule namespaced watch | `scope: Namespaced`, bounded at runtime by PR 5 | `scope: Namespaced`, bounded at runtime by PR 5 | **removed** — use WatchRule `namespace: "*"` | +| Breaking change | no (additive) | no (additive) | **yes** | +| PR 5 runtime ceiling needed | yes | yes | **no — deleted** | +| §2 fingerprint-not-rule-state hazard | present (must be built) | present (must be built) | **absent** | +| Dynamic (`"*"`/selector) machinery | two kinds | two kinds | **one kind** (WatchRule) | +| Migration of existing objects | none | none | **manual, cross-kind** | +| Conversion webhook can automate it | n/a | n/a | **no** (cross-*kind*) | +| API legibility ("which object watches what") | audit each rule's scope | audit each rule's scope | **read the kind** | +| Enum boilerplate | `scope:` on every cluster rule | `scope:` on every cluster rule | **none** | + +The additive approaches cost **more permanent machinery**; the two-object approach costs a **one-time +breaking migration**. The consequences of that migration, in descending order of sharpness: + +### 1. No conversion webhook can migrate it — the move is cross-*kind* + +A conversion webhook translates one **kind** between API **versions**. Moving "watch namespaced +resources cluster-wide" from `ClusterWatchRule{scope: Namespaced}` to `WatchRule{namespace: "*"}` is a +move between **kinds**, which no conversion webhook can express. So migration is inherently +out-of-band: a one-shot migration tool, or a human editing manifests. The additive approaches need +**no** migration at all, because the old spelling keeps working. This is the single structural fact +that makes the two-object model "breaking" in a way pluralizing a field never is. + +### 2. Rollback is a data-plane hazard, not just a config revert + +This is the consequence most likely to be underweighted. After migrating a namespaced ClusterWatchRule +to a WatchRule with `namespace: "*"`, rolling the controller **back** to a version that predates the +per-item field means the old controller does not understand `namespace` — it silently watches the +WatchRule's **own** namespace only. That is a scope *collapse*, and a collapsed scope is the input to +the resync sweep ([PR 1](pr1-namespace-scoped-resync.md) is what makes the sweep namespace-safe, not +sweep-free). So a rollback can **delete a tenant's Git content** for every namespace the `"*"` used to +cover. The additive approaches roll back cleanly, because the old field is still present and still +understood. + +### 3. Rolling upgrade is non-atomic (HA skew) + +During an HA upgrade the old and new controllers run concurrently. The old one reads +`scope: Namespaced` and watches namespaced; the new one ignores/refuses it. A WatchRule written for the +new controller with `namespace: "*"` is invisible to the old one. So there is a window of divergent +behavior that no amount of care removes — the migration cannot be flipped atomically. The additive +approaches have no skew: both controller versions understand both spellings. + +### 4. Stored objects are reinterpreted silently unless explicitly refused + +CRD schema validation runs on **write**, not read. An existing `ClusterWatchRule{scope: Namespaced}` +stays stored and served after the field is removed from the schema; the controller simply stops reading +`scope` and now treats every rule as cluster-scoped. A rule selecting `configmaps` would resolve +against **cluster-scoped** discovery records, match nothing, and go quietly dead — or worse, a rule +selecting a name that exists in both scopes would flip meaning. **The field removal must be paired with +an explicit compile-path refusal** (see [next section](#removing-the-field-does-not-retract-stored-objects)). +The additive approaches never reinterpret anything. + +### 5. GitOps apply loops break loudly + +A user who manages the operator's own CRs through Flux/Argo has `ClusterWatchRule{scope: Namespaced}` +manifests in a Git repo. After the schema change their apply either fails validation (enum value gone) +or silently prunes the field, and their sync goes degraded until they rewrite the manifest as a +WatchRule. That is a visible outage of *their* reconciliation, triggered by upgrading the operator. The +additive approaches leave those manifests valid. + +### 6. Documentation, samples, e2e, and tests — mechanical but not zero + +Every `scope: Namespaced` in the tree changes, plus the docs sentence promising ClusterWatchRule +watches "namespaced resources across multiple namespaces". Surveyed in +[known breakage](#known-breakage). + +### What the breaking cost buys + +Set against all six: the additive approaches carry PR 5's runtime ceiling **forever** — including the +§2 fingerprint-not-rule-state hazard, which is the subtlest and most regression-prone thing in the +folder — and they answer "will this stream namespaces outside my allow-list?" only by audit. The +two-object model pays once, at a controlled upgrade, and thereafter the answer is the kind. Whether a +one-time cross-kind migration with a genuine rollback hazard is worth deleting a permanent class of +runtime machinery is the decision this page exists to frame. It is not obviously yes; it is a real +trade, and the rollback hazard (#2) is the reason it is not. + +## Removing the field does not retract stored objects + +The step most likely to be skipped, and it is a **security/correctness** step, not a cleanup — sharpened +from the enum-narrowing version because now the field is *gone*, not merely restricted: + +1. Remove the `Scope` field from the schema (blocks new namespaced cluster rules and blocks updates). +2. **In the single gated compile path** [PR 4 step 7](pr4-source-namespace-field.md#implementation-steps) + routes both the reconciler and `bootstrapRuleStore` through, refuse any `ClusterWatchRule` rule + whose resource resolves — **via discovery** — to a namespaced type, with a terminal condition naming + the migration ("watch namespaced resources with a WatchRule and `namespace`"). Discovery already + knows the scope ([`matchesScope`](../../../internal/watch/watched_type_resolver.go#L397)), so this + is a check the resolver can already make. + +Doing (1) without (2) is the exact defect this folder was written to remove: a scope decided in one +place and not enforced in another. The must-have test is `TestBootstrap_PreExistingNamespacedClusterRuleIsRefused` +— a stored ClusterWatchRule selecting a namespaced type is not compiled and starts no stream, asserted +at bootstrap before any reconcile. No manifest in the repo can create that object once the field is +gone, so the test is the only thing that can catch a regression in the refusal. + +## Capability A/B — the questions this model resolves by relocation + +The two capabilities `scope: Namespaced` uniquely expressed do not vanish; they move, and the move must +be deliberate: + +- **A — "watch this type in every namespace."** Relocates to WatchRule `namespace: "*"`, **bounded by + the GitTarget policy** — never "every namespace that exists". A tenant author cannot express + "follow the whole cluster"; the ceiling is the destination's. This is the safe form of A, and it is + why the model can ship without answering "should we allow live all-namespaces?" — the answer is + "yes, bounded, and its liveness equals the allow-list's". +- **B — platform-authored namespaced watching from *outside* the tenant namespace.** `WatchRule.targetRef` + is a `LocalTargetReference` with no namespace ([watchrule_types.go:24-42](../../../api/v1alpha3/watchrule_types.go#L24-L42)), + so the WatchRule must live in the namespace it watches from, authored by whoever can write there. + ClusterWatchRule's cross-namespace `targetRef` was the only way for a platform team to configure a + tenant's namespaced mirror without an object in the tenant namespace — **and the two-object model + removes that, with no replacement.** If a real deployment relies on B, this model is wrong for it, + or must add a WatchRule with a namespaced `targetRef` (itself a change). **This is the argument that + can defeat the whole page; a reviewer who needs B should say so.** ## Known breakage -Small, from a repo-wide sweep on 2026-07-20 (excluding `external-sources/`): +From a repo-wide sweep on 2026-07-21 (excluding `external-sources/`): - [config/samples/clusterwatchrule.yaml](../../../config/samples/clusterwatchrule.yaml) — already - `scope: Cluster`. No change. + `scope: Cluster`; drop the now-removed field. - [test/e2e/unsupported_folder_e2e_test.go:177](../../../test/e2e/unsupported_folder_e2e_test.go#L177) - — uses `scope: Namespaced` with ConfigMaps, but only because the refusal test needs *some* rule - kind. Convert to `scope: Cluster` or to a WatchRule. + — a `scope: Namespaced` ConfigMap ClusterWatchRule used only to exercise the refusal path; convert + to a WatchRule or a cluster-scoped rule. - [docs/configuration.md](../../configuration.md) — "namespaced resources across multiple namespaces" - in the `ClusterWatchRule` section becomes false and must change in the same PR. -- The `ResourceScopeNamespaced` constant - ([clusterwatchrule_types.go:17-18](../../../api/v1alpha3/clusterwatchrule_types.go#L17-L18)) stays: - WatchRule resolution uses it internally - ([watched_type_resolver.go:294](../../../internal/watch/watched_type_resolver.go#L294), - [stream_readiness.go:142](../../../internal/watch/stream_readiness.go#L142), - [manager_catalog.go:389](../../../internal/watch/manager_catalog.go#L389)). Only the - `ClusterResourceRule.Scope` **enum** narrows. -- Unit tests constructing `Namespaced` cluster rules across `internal/watch` and - `internal/controller` — several files; mechanical. - -This list is a sweep, not a proof. A reviewer who believes a real deployment depends on capability A -or B above should say so; that is the argument that defeats this page, and no amount of the above -outweighs it. + under `ClusterWatchRule` becomes false; the `ClusterWatchRule` example and the `scope` field docs go. +- The [`ResourceScope` enum + constants](../../../api/v1alpha3/clusterwatchrule_types.go#L9-L19) leave + the **API**; `typeset.Scope` and `matchesScope` stay (discovery still needs them). +- Unit tests across `internal/watch`, `internal/controller`, `internal/rulestore` that construct + `Namespaced` cluster rules — mechanical. + +This is a sweep, not a proof: a reviewer who believes a deployment depends on capability B above +outweighs all of it. ## Test plan delta Relative to PR 4 and PR 5 as written: -- **Deleted:** every PR 5 test named for the ceiling — narrowing, sparing cluster-scoped rules, - `clusterWatchRuleFingerprint` resolved-scope, table rebuild on policy-only change, retain-on-unknown, - and the establishing/maintaining pair for ClusterWatchRule. -- **Added:** `TestBootstrap_PreExistingNamespacedClusterRuleIsRefused` — a stored ClusterWatchRule with - `scope: Namespaced` is not compiled and starts no stream, asserted at bootstrap before any reconcile. - This is the enum-narrowing gap above and it is the single most important new test. -- **Added:** admission rejects `scope: Namespaced` on create and on update, with a message naming - WatchRule plus `sourceNamespaces` as the replacement. -- **Changed:** every PR 4 gate case becomes a set case. Specifically: an empty set is the legacy - own-namespace behavior; a partially-admitted set is **denied in whole, not silently trimmed** — a - rule that quietly watches two of the three namespaces it asked for is the plural-specific failure - mode, and it needs its own case. -- **Kept as-is:** the two must-have tests from - [PR 4](pr4-source-namespace-field.md#the-two-tests-that-must-exist), the fingerprint and selection - silent-failure guards, and the e2e proving Git paths follow the *source object's* namespace - ([Appendix A](pr4-source-namespace-field.md#appendix-a-the-source-objects-namespace-already-names-the-git-folder)). +- **Deleted:** every PR 5 ceiling test — narrowing, sparing cluster-scoped rules, the + `clusterWatchRuleFingerprint` resolved-scope test, the table-rebuild-on-policy-change test, + retain-on-unknown, and the establishing/maintaining pair for ClusterWatchRule. The mechanisms they + guard no longer exist. +- **Added (the critical one):** `TestBootstrap_PreExistingNamespacedClusterRuleIsRefused` — §4 above. +- **Added:** admission/compile refuses a namespaced-resolving ClusterWatchRule rule, message naming the + WatchRule + `namespace` migration. +- **Inherited from the sibling doc:** the whole WatchRule per-item namespace test plan + ([alt-per-item § test plan delta](alt-per-item-source-namespace.md#test-plan-delta-relative-to-pr-4-as-written)), + including the partial-object deny-in-whole case and the `"*"` establishing/maintaining cases. +- **Kept:** PR 4's two must-have tests, the fingerprint/selection silent-failure guards, and the + Appendix-A Git-path e2e. ## Open questions for the reviewer -1. Capability A — is a live "all namespaces" scope required at launch? If yes, does it come back as a - wildcard entry, a selector, or not at all? -2. Capability B — must a platform team author namespaced watching for a tenant target from outside the - tenant namespace? -3. Names-only in v1 on **both** `sourceNamespaces` and `allowedSourceNamespaces`, deferring the - source-scope service and the Namespace informer wholesale? That is a much larger cut to PR 4 than - the pluralization itself, and it should be decided on its own merits. -4. Partial admission of a set: deny in whole (recommended) or trim to the admitted subset? Trimming is - friendlier and is a silent narrowing, which is the failure class this folder exists to remove. -5. Is the enum narrowing acceptable churn on a preliminary v1alpha3, given - [README § compatibility](README.md#compatibility) already accepts observable changes without +1. **Capability B** — must a platform team author namespaced watching for a tenant target from outside + the tenant namespace? If yes, the two-object model removes it with no replacement and should be + rejected or paired with a namespaced-`targetRef` WatchRule. +2. **Is the one-time cross-kind migration, with the rollback data-plane hazard (#2), an acceptable + price** for deleting PR 5's permanent machinery — on a preliminary v1alpha3 whose + [compatibility policy](README.md#compatibility) already accepts observable changes without conversion? - -## What this does not change - -PR 1, PR 2, and PR 3 are unaffected and remain landed. `GitTarget.spec.allowedSourceNamespaces`, the -delegation flag, and the whole authorization argument in -[PR 4](pr4-source-namespace-field.md#what-the-delegation-flag-means) — including the in-cluster -sign-off and the `IsLocalSource()` trap — stand exactly as written. Git placement still follows the -source object's own namespace, so the write path still needs no change. +3. **Migration tooling** — ship a one-shot converter (ClusterWatchRule `scope: Namespaced` → WatchRule + `namespace: "*"`), or document the manual rewrite? A converter cannot be a conversion webhook (#1), + so it is a standalone tool either way. +4. **Singular `namespace` vs plural `namespaces`** on the WatchRule rule item, and **name-only `"*"` + first vs selector-backed `"*"`** — both inherited from the + [sibling doc's open questions](alt-per-item-source-namespace.md#open-questions-for-the-reviewer); + the selector choice is what determines whether the one remaining dynamic machinery ships now or later. + +## What does not change + +PR 1, PR 2, PR 3 are unaffected and remain landed. `GitTarget.allowedSourceNamespaces`, the delegation +flag, the in-cluster sign-off argument, and the `IsLocalSource()` trap all stand verbatim. Git +placement still follows each mirrored object's own namespace, so the write path needs no change. diff --git a/docs/design/watchrule-source-namespace/alt-per-item-source-namespace.md b/docs/design/watchrule-source-namespace/alt-per-item-source-namespace.md new file mode 100644 index 00000000..78382706 --- /dev/null +++ b/docs/design/watchrule-source-namespace/alt-per-item-source-namespace.md @@ -0,0 +1,253 @@ +# Alternative — a per-rule-item namespace, with `*` meaning "all *allowed* namespaces" + +> **Status: proposal, for review. Not agreed, not scheduled.** A shape for the WatchRule side of +> [source-namespace addressing](README.md), sibling to the +> [two-object model](alt-clusterwatchrule-cluster-scope-only.md). The two pages share **this exact +> WatchRule interface** (a per-rule-item `namespace` with `"*"` bounded by the allow-list) and differ +> only on ClusterWatchRule: the two-object page *removes* `scope: Namespaced` from it (breaking, deletes +> [PR 5](pr5-clusterwatchrule-source-ceiling.md)), whereas this page *keeps* ClusterWatchRule and PR 5 +> exactly as designed (additive, non-breaking) and reshapes only the +> [PR 4](pr4-source-namespace-field.md) field. Pick this page's ClusterWatchRule treatment to avoid a +> breaking change; pick the two-object page's to delete PR 5's permanent machinery. The +> [breaking-change comparison](alt-clusterwatchrule-cluster-scope-only.md#comparing-the-approaches--breaking-change-consequences) +> lives on that page. +> +> **PR 4 is being implemented as this is written, top-level singular.** The compiled rule already +> carries `SourceNamespace` and [watched_type_resolver.go:301](../../../internal/watch/watched_type_resolver.go#L301) +> already reads it for the watch selection. So this page is a delta against work in flight, and the +> [migration section](#relationship-to-the-in-flight-pr-4) states exactly what it costs to adopt late. +> +> Code references verified against the tree on 2026-07-21. + +## The proposal + +Move the namespace from the WatchRule spec's top level onto each **rule list item** +([`ResourceRule`](../../../api/v1alpha3/watchrule_types.go#L83)), and give it the same three-valued +shape the other rule-item fields already have: + +~~~yaml +apiVersion: configbutler.ai/v1alpha3 +kind: WatchRule +metadata: + name: repo-config + namespace: tenant-acme +spec: + targetRef: + name: acme + rules: + - resources: [configmaps] # namespace omitted → tenant-acme (legacy, unchanged) + - resources: [secrets] + namespace: repo-config # one explicit source namespace + - resources: [deployments] + namespace: "*" # every namespace the GitTarget's policy ADMITS +~~~ + +Three meanings, mirroring how `resources`, `apiGroups`, and `apiVersions` already read on the same +item: + +| `namespace` on a rule item | Meaning | +|---|---| +| omitted | The WatchRule's own namespace. **Legacy behavior, byte-for-byte.** | +| `"*"` | Every namespace the GitTarget's `allowedSourceNamespaces` **admits** — *not* every namespace that exists. Bounded by policy, deny-by-default when no policy is declared. | +| an explicit name | That one source namespace, subject to the same three-part gate PR 4 already defines. | + +`GitTarget.spec.allowedSourceNamespaces` and the ClusterProvider delegation flag are unchanged, and +so is the authorization argument in [PR 4](pr4-source-namespace-field.md#what-the-delegation-flag-means). +This changes the *interface* to the gate, not the gate. + +> **Open: singular or plural per item.** `namespace: string` (repeat the item for a second namespace) +> or `namespaces: []string` (one item, several names). Singular keeps the shape identical to the +> current in-flight field and reads cleanest for the common one-namespace case; plural is terser for +> a hand-listed set. This page assumes **singular** and treats `"*"` as the way to say "many", +> because a hand-listed multi-namespace set is the rarer need and `"*"`-bounded-by-policy covers it. +> See [open questions](#open-questions-for-the-reviewer). + +## The load-bearing idea: `*` is bounded by the allow-list + +The reason this defers the two hard questions the +[cluster-scope-only alternative](alt-clusterwatchrule-cluster-scope-only.md#the-cost-scope-namespaced-is-the-only-expression-of-two-capabilities) +had to answer is a single semantic choice: + +> `namespace: "*"` resolves to the **admitted** set — exactly what +> `GitTarget.allowedSourceNamespaces` permits — never to the raw set of namespaces the source +> credential can read. + +That has three consequences worth stating separately: + +1. **"Follow the whole cluster" is never expressible by a WatchRule author.** A tenant writing `"*"` + gets what their target's policy admits and nothing more. The catastrophic fail-open reading — a + rule author quietly watching every namespace — is not a reachable state, because the ceiling is + the GitTarget's, set by whoever owns the destination. +2. **No policy declared ⇒ `"*"` admits nothing beyond the legacy own-namespace.** `"*"` is not a + backdoor around deny-by-default. With no `allowedSourceNamespaces` and the delegation flag false, + an explicit `"*"` is denied with the same terminal condition PR 4 gives a denied name — the + message naming the fix (declare a policy, set the flag). The omitted case still means own-namespace + and still needs neither. +3. **Capability A comes back without deleting capability B.** The + [cluster-scope-only page](alt-clusterwatchrule-cluster-scope-only.md#the-cost-scope-namespaced-is-the-only-expression-of-two-capabilities) + named two things `scope: Namespaced` uniquely expressed: (A) "watch this type in every namespace" + and (B) "platform-authored namespaced watching from outside the tenant namespace". `"*"` gives A, + bounded. B is untouched, because ClusterWatchRule and its cross-namespace `targetRef` remain. So + neither question has to be answered to ship this. + +## Honest accounting: this is additive, not a simplification + +The [cluster-scope-only alternative](alt-clusterwatchrule-cluster-scope-only.md) removed PR 5's +runtime ceiling, its fingerprint hazard, and its informer fan-out. **This proposal removes none of +that.** ClusterWatchRule keeps `scope: Namespaced`; [PR 5](pr5-clusterwatchrule-source-ceiling.md) +ships as written; and WatchRule *gains* a namespace-expansion path that looks a lot like PR 5's. + +So the end state has two ways to watch namespaced resources across namespaces: + +| | Authored by | Lives in | Ceiling | +|---|---|---|---| +| WatchRule, `namespace: "*"` or names | the tenant (targetRef is namespace-local) | the tenant's namespace | `GitTarget.allowedSourceNamespaces` | +| ClusterWatchRule, `scope: Namespaced` | the platform (targetRef is cross-namespace) | any namespace | `GitTarget.allowedSourceNamespaces` (PR 5) | + +They are not redundant — the authoring locality genuinely differs — but the machinery is doubled, not +shared-down. **The trade this page offers is: keep both capabilities and break nothing, at the cost of +more surface, versus the sibling page's cut both the surface and a capability, and break.** That is the +decision for review; neither is strictly better. + +| | [cluster-scope-only](alt-clusterwatchrule-cluster-scope-only.md) | **per-item `*` (this)** | top-level plural `sourceNamespaces` | +|---|---|---|---| +| Breaking change | yes (enum narrows) | no | no | +| Forces the A/B answers | yes | no | no | +| Effect on PR 5 | deletes its guts | keeps it | keeps it | +| Delta vs in-flight PR 4 | pluralize field | move field into rule item + expansion loop | `string` → `[]string` at one existing point | +| "all allowed" available | via WatchRule wildcard/selector | via `"*"` | via `"*"` | +| Per-type namespace granularity | no | **yes** | no | + +## What it costs inside PR 4 + +### 1. Partial authorization within one object + +Top-level singular authorizes a WatchRule as a unit: one effective namespace, one gate call, one +`SourceNamespaceAuthorized` condition. Per-item, the gate runs per `(rule item, namespace)`, so one +object can hold an admitted item and a denied item at once — a state the singular field cannot +produce. The `SourceNamespaceAuthorized` condition is per-object and cannot cleanly say "item 2 is +denied". Two semantics, and they must not be blended: + +- **A named namespace is *establishing*.** If any named namespace on any item is denied, the **whole + WatchRule** is `SourceNamespaceAuthorized=False`, `Stalled=True`, no streams — deny-in-whole. The + message names the offending item and namespace. A rule that silently watches two of the three + namespaces it asked for is the plural-specific failure this design must refuse; partial success is + worse than loud failure here. +- **`"*"` is *maintaining* / narrowing.** It never denies. It expands to the admitted set, which may be + empty, which is a correct outcome and **not** `Stalled` — the same rule + [PR 5 §2b](pr5-clusterwatchrule-source-ceiling.md#2b-unknown-is-not-empty) already states for the + ceiling. + +So a single WatchRule can carry both a *refusal* semantic (a denied name) and a *narrowing* semantic +(`"*"`). When both are present the refusal dominates the object-level condition. This is the existing +[establishing-versus-maintaining contract](pr4-source-namespace-field.md#establishing-versus-maintaining-a-scope) +applied within one object rather than across two rule kinds — implementable, but it is the part most +likely to be gotten subtly wrong, and it needs its own tests. + +### 2. `"*"` reintroduces the informer and the sweep hazard — but only opt-in + +A name-based item resolves statically: `watchRuleFingerprint` +([watched_type_resolver.go:478-482](../../../internal/watch/watched_type_resolver.go#L478-L482)) keeps +working from spec alone, no source-cluster Namespace informer needed. A `"*"` item against a +**selector** `allowedSourceNamespaces` makes the resolved set depend on source-cluster Namespace +labels — which is the maintaining/sweep hazard [PR 1](pr1-namespace-scoped-resync.md) landed first to +survive, and it drags PR 4's [source-scope service](pr4-source-namespace-field.md#the-source-scope-service--define-this-interface-before-writing-the-gate) +and the source-cluster Namespace informer onto the WatchRule path. + +The good news is that this is **opt-in per item**: a WatchRule that never writes `"*"` (or uses `"*"` +against a name-only policy) is fully static and needs none of it. So the dynamic machinery is paid for +only by the rules that ask for dynamism — which is the right place to pay it. A names-only `"*"` +policy is the recommended first cut; the selector-backed `"*"` can follow with the informer. + +### 3. The fingerprint must include the resolved set for `"*"` items + +Same shape as [PR 5 §2](pr5-clusterwatchrule-source-ceiling.md#2-put-the-resolved-scope-into-table-invalidation), +now on WatchRule: for a `"*"` item the resolved namespace set is **not rule state**, so a policy or +Namespace-label change that alters the set must still re-project the watched-type table. Hash the +resolved set into the rule's fingerprint (or carry a source-scope generation into the rebuild +trigger). A name-only item needs nothing new. The silent-failure test from PR 5 applies verbatim: an +unchanged rule object under a changed policy must re-project. + +### 4. Expansion in selection + +[`collectWatchRuleSelections`](../../../internal/watch/watched_type_resolver.go#L283-L307) currently +appends one selection per matched record at `namespace: rule.SourceNamespace`. Per-item, it appends +one selection **per (matched record × resolved namespace)** — the same expansion PR 5 adds to +`collectClusterWatchRuleSelections`, so the two collectors converge on one shape. Prefer expansion at +this site over filtering at the read site, for exactly the reason +[PR 5 §1](pr5-clusterwatchrule-source-ceiling.md#1-apply-the-ceiling-in-selection) gives: an expanded +selection carries the scope through the plan hash, the informers, and the resync path for free, while +a read-site filter must be repeated at each and is silently wrong if one is missed. + +## Relationship to the in-flight PR 4 + +The delta depends on whether a release has shipped `sourceNamespace`: + +- **Before any release** — pure churn on a preliminary v1alpha3 API that + [README § compatibility](README.md#compatibility) already accepts without conversion. The field is + unset everywhere, so nothing migrates. The interface moves from `spec.sourceNamespace` (top level) + to `spec.rules[].namespace` (per item); the gate, the `SourceNamespaceAuthorized` condition, the + printer column, and the single gated compile path are all **reused as-is**, just called per item + instead of per object. +- **After a release** — pluralize/relocate at the next API version with conversion; do not ship the + singular field into a second release. + +The work in flight is not wasted either way. The parts that survive unchanged are the whole authorization +model and status contract; the parts that move are the field's location (spec → rule item) and the +compiled representation (`CompiledRule.SourceNamespace string` → a per-`ResourceRule` namespace plus an +expansion loop). That is a larger rework of the collector and the compiled rule than **top-level plural +`sourceNamespaces []string`** would be — plural keeps `SourceNamespace` on the compiled rule, just as a +slice, and expands at the one point that already exists. If minimizing disruption to the in-flight PR is +the priority, top-level plural delivers the same `"*"`=all-allowed insight at a smaller delta, losing +only the per-type granularity and the visual symmetry with the other rule-item fields. + +## What does NOT change + +- ClusterWatchRule keeps `scope: Namespaced`; [PR 3](pr3-clusterwatchrule-target-admission.md) and + [PR 5](pr5-clusterwatchrule-source-ceiling.md) are unaffected. This proposal is orthogonal to them. +- `GitTarget.allowedSourceNamespaces`, the delegation flag, and the in-cluster sign-off argument stand + verbatim. +- Git placement still follows each mirrored object's **own** namespace, so the write path needs no + change — a rule item watching `repo-config` still writes under `repo-config/…` + ([PR 4 Appendix A](pr4-source-namespace-field.md#appendix-a-the-source-objects-namespace-already-names-the-git-folder)). +- The `namespace: ""` collapse rules ([PR 2](pr2-stream-scope-collapse.md)) are unaffected: an omitted + item namespace resolves to a concrete own-namespace, and `"*"` expands to concrete names — neither + emits a raw `""` key. Only ClusterWatchRule's cluster-scoped rules still emit `""`. + +## Test plan delta (relative to PR 4 as written) + +- **Kept:** the two must-have tests + ([PR 4 § the two tests that must exist](pr4-source-namespace-field.md#the-two-tests-that-must-exist)), + the fingerprint and selection silent-failure guards, and the Appendix-A Git-path e2e. +- **Changed to per-item:** the gate table becomes per `(item, namespace)`; add the **partial-object** + case explicitly — one item allowed, one item denied ⇒ whole rule `Stalled`, message names the denied + item. +- **Added — `"*"` semantics:** + - `"*"` with no policy and flag false ⇒ denied (deny-by-default), not "all namespaces". + - `"*"` against a name-only policy ⇒ expands to exactly those names, statically; fingerprint changes + when the policy changes though the rule object does not. + - `"*"` against a selector policy, source Namespace access denied, **scope already resolved** ⇒ + `Unknown` / `SourceNamespacePolicyUnavailable`, retained, **not** `Stalled`, **no sweep** — the + maintaining path. + - `"*"` against a selector policy that admits nothing ⇒ zero selections for that item, not a failure. +- **Added — mixed within one object:** a rule with one omitted item, one named item, and one `"*"` + item resolves each independently and the object condition is the correct aggregate (refusal dominates). + +## Open questions for the reviewer + +1. **Singular `namespace` or plural `namespaces` per item?** Singular + `"*"` is the smaller shape and + the assumption here. Plural adds a hand-listed multi-namespace set that `"*"`-bounded-by-policy + largely subsumes. +2. **`"*"` against a *selector* policy in v1, or name-only first?** Name-only defers the source-cluster + Namespace informer and the whole maintaining/sweep path entirely, exactly as the + [cluster-scope-only page](alt-clusterwatchrule-cluster-scope-only.md#names-only-plural-is-cheap-a-selector-is-not) + argues. Recommended: ship name-only `"*"`, add selector `"*"` with the informer as a follow-up. +3. **Additive vs subtractive.** This keeps ClusterWatchRule's namespaced scope and PR 5; the + [sibling page](alt-clusterwatchrule-cluster-scope-only.md) deletes both but is breaking and forces + the capability questions. Which cost is the right one for this project is the decision these two + pages exist to frame. +4. **Deny-in-whole for a denied named item** — confirm this over trim-to-admitted-subset. Trimming is + friendlier and is a silent narrowing, the failure class this whole folder exists to remove. +5. **Is per-type namespace granularity worth the partial-authorization surface?** If not, top-level + plural `sourceNamespaces` gives the `"*"` insight without per-item auth complexity, at a smaller + delta to the in-flight PR. diff --git a/docs/design/watchrule-source-namespace/pr5-cluster-scope-and-deletion-safety.md b/docs/design/watchrule-source-namespace/pr5-cluster-scope-and-deletion-safety.md new file mode 100644 index 00000000..e5f0bfda --- /dev/null +++ b/docs/design/watchrule-source-namespace/pr5-cluster-scope-and-deletion-safety.md @@ -0,0 +1,324 @@ +# PR 5 (replacement) — ClusterWatchRule becomes cluster-scoped, made safe by GitTarget deletion controls + +> **Replaces [pr5-clusterwatchrule-source-ceiling.md](pr5-clusterwatchrule-source-ceiling.md).** That +> page bound `allowedSourceNamespaces` to ClusterWatchRule's `scope: Namespaced` streams *at runtime* +> (a ceiling). This page removes the need for a ceiling by removing `scope: Namespaced` from +> ClusterWatchRule entirely — the [two-object model](alt-clusterwatchrule-cluster-scope-only.md) — and +> pairs it with a GitTarget-wide deletion control that makes the breaking change, and every scope +> mistake in this folder, **non-destructive**. +> +> Phase 5 of [source-namespace addressing](README.md). **Depends on:** +> [PR 4](pr4-source-namespace-field.md) (the field, the gate, the source-scope service), +> [PR 1](pr1-namespace-scoped-resync.md) (namespace-scoped resync), and +> [PR 2](pr2-stream-scope-collapse.md). **Breaking change**, accepted on this preliminary v1alpha3 API +> per [README § compatibility](README.md#compatibility). **Still bound by the +> [release gate](README.md#implementation-phases):** until this lands, ClusterWatchRule's namespaced +> streams bypass `allowedSourceNamespaces`, so PR 4's boundary is incomplete without it — do not cut a +> release between PR 4 and this PR. +> +> Code references verified against the tree on 2026-07-21. + +## Two moves, in this order + +1. **Deletion safety first** — `GitTarget.spec.prune` (`never` | `onEvent` | `always`, default + `onEvent`) plus a volume guard. This is the foundation: it makes a wrong scope stop *writing* + without *deleting*, so the breaking change below (and a rollback across it) is a stale-Git + annoyance rather than tenant data loss. +2. **Scope simplification** — remove the `scope` field from ClusterWatchRule (cluster-scoped by kind), + refuse any pre-existing namespaced cluster rule, and generalize the WatchRule field from PR 4's + single `sourceNamespace` to a per-rule-item `namespace` with `"*"` meaning "every namespace the + GitTarget admits". The `"*"` is the replacement path for the capability being removed from + ClusterWatchRule. + +They are one plan because move 2 is only safe to ship on top of move 1. They may land as two commits; +move 1 must precede move 2 **within the same release**. + +--- + +## Part A — deletion safety + +### The two deletion triggers already exist as separate code paths + +The write path already draws the boundary this control needs — "Two Paths, One Plan Type" +([reconcile-via-watchlist-mark-and-sweep.md](../../spec/reconcile-via-watchlist-mark-and-sweep.md)): + +- **Per-event delete** — [`PlanDelete`](../../../internal/manifestanalyzer/delete_plan.go#L36) resolves + one real DELETE watch event to one delete-document action and, in its own words, "targets exactly + ONE identity and **NEVER sweeps**". Applied by + [`writeBatch.applyDelete`](../../../internal/git/plan_flush.go#L1140). +- **Mark-and-sweep** — [`BuildScopedPlan`](../../../internal/manifestanalyzer/plan.go#L197) (and its + whole-folder special case [`BuildPlan`](../../../internal/manifestanalyzer/plan.go#L170)) diffs the + full desired set against Git and emits a **managed drop** for every in-scope document not in desired. + Applied on resync/heal by [`applyResync`](../../../internal/git/resync_flush.go). This is the path + that turns a scope collapse into deletion, because "desired" is computed from the (possibly wrong) + scope. + +`prune` gates these two paths independently: + +| `prune` | per-event delete (`PlanDelete`) | mark-and-sweep drop (`BuildScopedPlan`) | Git after a genuine delete / after a scope collapse | +|---|---|---|---| +| `never` | suppressed | suppressed | never deleted / never deleted — append-and-tombstone archive | +| **`onEvent`** *(default)* | **applied** | **suppressed** | deleted / **not deleted** — real deletions mirrored, collapse is inert | +| `always` | applied | applied | deleted / **deleted** — today's behavior, faithful and dangerous | + +### Why the default is `onEvent`, and why it must not be `always` + +The catastrophic deletion is the *inference*, not the *event*. A real DELETE event is a strong signal — +the object left the cluster. The sweep is an inference — "not in my current desired set, so it must be +gone" — and that inference is exactly what is wrong when scope collapsed (config error, a transient +source outage narrowing to empty, or a **rollback** across the scope simplification below). `onEvent` +keeps deletion accuracy for the common case and removes the entire scope-collapse data-loss class. + +The default **must** be a sweep-suppressing mode for the safety argument to hold. Defaulting to +`always` — today's implicit behavior — would forfeit the "rollback is survivable" guarantee that is the +whole reason move 1 precedes move 2. Changing the effective default from `always` to `onEvent` is itself +a behavior change for existing GitTargets (they stop pruning stale files on resync); on this +preliminary API with few users it is the intended, safe-direction change, and it is called out in +release notes. + +### The volume guard — closing the vector `onEvent` leaves open + +`onEvent` blocks the *inference* vector; it does **not** block the *volume* vector. A real +`kubectl delete ns tenant-acme` cascades into a genuine DELETE per object, and `onEvent` faithfully +deletes every file — correct mirroring, but also a whole-folder wipe from real events. So add a guard, +independent of `prune` mode: + +> A single commit/flush that would remove more than **N** managed documents (absolute, and/or a ratio +> of the target's tracked documents) refuses the removals, keeps the creates/updates, surfaces a +> `PruneGuardTripped` condition + event on the GitTarget, and requires an explicit override to proceed. + +The trichotomy controls *whether* to infer deletions; the guard controls *how many* any one flush may +make, from either path. The guard is the specific answer to "prune a complete folder by mistake" — the +concern that has kept pruning unbuilt until now. Prior art: Flux `Kustomization.spec.prune` and Argo +CD's automated prune are both explicit and treated as dangerous-by-default for this reason (semantics +verifiable in `external-sources/`). + +### The reversibility caveat that raises the stakes + +"A wrong prune is reversible in Git" is true at the history level but weaker than it looks when the +destination repo is itself a **deploy source** — which is this product's own vision (merge = deploy). A +spurious prune commit can propagate to a live cluster through the downstream Flux/Argo sync *before* a +human reverts it, turning "annoying diff" into "deleted live resources". This is why the control lives +on **GitTarget**, not globally: an audit-mirror destination legitimately wants `always`; a +deploy-source destination wants `onEvent` + guard. The safety/accuracy trade is per-destination. + +### Part A implementation + +1. **API.** `GitTarget.spec.prune` — a `+kubebuilder:validation:Enum=never;onEvent;always`, + `+kubebuilder:default=onEvent` string, next to + [`AllowedSourceNamespaces`](../../../api/v1alpha3/gittarget_types.go#L134). Optional guard fields + (`pruneGuard.maxDeletions`, `pruneGuard.maxDeletionRatio`). `task generate` + `task manifests`. +2. **Thread the mode into the planner.** `BuildScopedPlan` already takes a + [`Policy`](../../../internal/manifestanalyzer/plan.go#L197); add the prune mode to `Policy` and + **suppress the managed-drop emission** when the mode is not `always`. One choke point, because every + sweep — full, per-type (M12), and resync — funnels through this function. Do **not** filter at + apply time; a suppressed drop must never enter the plan, so the plan hash, the commit, and the + resync path all agree. +3. **Gate the per-event path.** `applyDelete` ([plan_flush.go:1140](../../../internal/git/plan_flush.go#L1140)) + skips the removal when the mode is `never`. `onEvent` and `always` apply it. +4. **Volume guard at the flush boundary.** Count `Removed` actions in the batch before commit + ([plan_flush.go](../../../internal/git/plan_flush.go)); over threshold ⇒ drop the removals from the + batch, keep the rest, set `PruneGuardTripped`. The guard counts drops from **either** path. +5. **Status/surface.** A `GitTarget` condition/event when the guard trips, and a one-time info log when + a resync's sweep is suppressed by `onEvent`/`never` (so an operator can see *why* a stale file + persists). Do not make suppression an error state — it is the configured, healthy behavior. + +### Part A test plan + +- **Scope-collapse is inert under `onEvent`.** Build a desired set that has narrowed to empty for a + namespace, run the resync plan, assert **zero** managed-drop actions and that the files remain. This + is the test that would have caught the rollback data-loss. +- **`onEvent` still mirrors a real delete.** A DELETE event removes exactly its one document. +- **`never` removes nothing** from either path; **`always` reproduces today's sweep** byte-for-byte + (regression pin: `always` must equal current `BuildPlan` behavior). +- **Volume guard** trips at the threshold from a sweep **and** from an event cascade, keeps + non-deletions, sets the condition, and honors an override. +- **Default is `onEvent`** on a GitTarget that omits the field (defaulting test). + +--- + +## Part B — scope simplification (the two-object model) + +Full rationale and the breaking-change comparison are in +[alt-clusterwatchrule-cluster-scope-only.md](alt-clusterwatchrule-cluster-scope-only.md); this section +is the implementable subset. The end state: + +> **ClusterWatchRule is the cluster-global surface. WatchRule is the namespaced surface, and every +> namespace it reaches is named in its spec and admitted by its GitTarget.** Scope is carried by the +> *kind*, never by a per-rule field. + +### B0. Why removal deletes the original PR 5 wholesale + +`allowedSourceNamespaces` is a *namespace* allow-list; a cluster-only ClusterWatchRule has no namespace +to bound, exactly as [the overview concedes](README.md#what-the-ceiling-does-not-do). So removing the +namespaced scope makes the ceiling inapplicable, and with it go the three hardest mechanisms the +original PR 5 had to build — the per-namespace expansion of `collectClusterWatchRuleSelections`, the +`clusterWatchRuleFingerprint` that had to hash **non-rule state** (the subtlest defect in the folder), +and the "unknown is not empty" retain-on-outage logic for ClusterWatchRule. None of them is written. + +### B1. Remove the scope field + +Delete `Scope` from [`ClusterResourceRule`](../../../api/v1alpha3/clusterwatchrule_types.go#L110) and +remove the [`ResourceScope` enum](../../../api/v1alpha3/clusterwatchrule_types.go#L9-L19) from the API +surface. The internal `typeset.Scope` stays — discovery still needs it. `task generate` + `task +manifests`. + +`collectClusterWatchRuleSelections` +([watched_type_resolver.go:306-323](../../../internal/watch/watched_type_resolver.go#L306-L323)) drops +its `rr.Scope` argument and always matches with `ScopeCluster`; it keeps emitting `namespace: ""`, +which is now correct — those are genuinely cluster-scoped streams. +`clusterWatchRuleFingerprint` drops its `rr.Scope` component +([watched_type_resolver.go:511](../../../internal/watch/watched_type_resolver.go#L511)); it needs no +`src=` component, because a cluster-only rule's scope is fully spec-derived. + +### B2. Removing the field does not retract stored objects — refuse them explicitly + +CRD validation runs on **write**, not read. A `ClusterWatchRule` already stored with a namespaced rule +stays served and is still compiled by +[`bootstrapRuleStore`](../../../internal/watch/bootstrap.go#L49-L68); once the controller stops reading +`scope`, it would silently reinterpret that rule as cluster-scoped and either match nothing or flip +meaning. So the field removal is paired with a refusal: + +> In the single gated compile path [PR 4 step 7](pr4-source-namespace-field.md#implementation-steps) +> routes both the reconciler and bootstrap through, refuse a ClusterWatchRule rule whose resource +> resolves **via discovery** ([`matchesScope`](../../../internal/watch/watched_type_resolver.go#L397)) +> to a namespaced type. Terminal condition, message naming the migration: *"watch namespaced resources +> with a WatchRule and rules[].namespace; ClusterWatchRule is cluster-scoped only."* + +The must-have test is `TestBootstrap_PreExistingNamespacedClusterRuleIsRefused` — a stored namespaced +cluster rule is not compiled and starts no stream, asserted before any reconcile. No manifest in the +repo can create that object once the field is gone, so this test is the only guard against a regression +in the refusal. + +### B3. WatchRule per-item namespace — the replacement for the removed capability + +Generalize PR 4's `WatchRule.spec.sourceNamespace` (top-level, singular) to +`WatchRule.spec.rules[].namespace` on [`ResourceRule`](../../../api/v1alpha3/watchrule_types.go#L83): + +| `namespace` on a rule item | Meaning | +|---|---| +| omitted | the WatchRule's own namespace (legacy, unchanged) | +| `"*"` | every namespace the GitTarget's `allowedSourceNamespaces` **admits** — not every namespace that exists; deny-by-default with no policy | +| a name | that one source namespace, through PR 4's three-part gate | + +This is a **pre-release evolution of PR 4's field**, not a new authorization model — the gate, the +`SourceNamespaceAuthorized` condition, and the source-scope service are reused verbatim, called per +`(rule item, namespace)` instead of per object. The full interface, the partial-authorization handling +(deny-in-whole for a denied *name*, narrow-for-`"*"`), and the `"*"`-selector maintaining path are +specified in [alt-per-item-source-namespace.md](alt-per-item-source-namespace.md); implement that page +here. PR 4 shipped singular into no release, so this is a rename with no conversion surface +([README § compatibility](README.md#compatibility)). + +Mechanical deltas from PR 4's singular field: +- `CompiledRule.SourceNamespace` (one value, read at + [watched_type_resolver.go:301](../../../internal/watch/watched_type_resolver.go#L301)) becomes a + per-`ResourceRule` namespace; `collectWatchRuleSelections` expands one selection **per (matched + record × resolved namespace)** — the same expansion the original PR 5 added to the *cluster* rule, + now on the WatchRule where it belongs. +- `watchRuleFingerprint` + ([watched_type_resolver.go:478-482](../../../internal/watch/watched_type_resolver.go#L478-L482)) + hashes the per-item namespace, and for a `"*"` item the **resolved** set — the one place the + fingerprint-not-rule-state care from the original PR 5 survives, now scoped to `"*"` items only. +- Name-only items stay fully static; only `"*"` against a *selector* policy needs the source-cluster + Namespace informer, so that machinery is opt-in per rule. + +### B4. Capability removed with no in-kind replacement — flag for review + +ClusterWatchRule's cross-namespace `targetRef` was the only way a **platform** team could author a +tenant's *namespaced* mirror without an object in the tenant namespace. `WatchRule.targetRef` is a +`LocalTargetReference` with no namespace +([watchrule_types.go:24-42](../../../api/v1alpha3/watchrule_types.go#L24-L42)), so the WatchRule must +live in the namespace it watches from. **The two-object model removes platform-external namespaced +authoring with no replacement.** If a real deployment relies on it, this plan is wrong for that +deployment and must be paired with a namespaced-`targetRef` WatchRule (a separate change). This is the +open question most able to defeat Part B — see [open questions](#open-questions). + +### B5. Reactivity + +- **GitTarget → ClusterWatchRules mapper:** the original PR 5 needed it to re-resolve a runtime + ceiling. With the ceiling gone, ClusterWatchRule no longer re-resolves on GitTarget policy changes — + **the mapper is not needed** for ClusterWatchRule. PR 3's ClusterProvider→ClusterWatchRule admission + mapper is unaffected. +- **WatchRule reactivity:** PR 4's edges carry per-item changes unchanged. A `"*"` item against a + selector policy needs the source-cluster Namespace informer from PR 4 to map to WatchRules — reused, + not new. + +### Part B test plan + +- **`TestBootstrap_PreExistingNamespacedClusterRuleIsRefused`** (B2) — the critical one. +- **Admission/compile refuses** a namespaced-resolving ClusterWatchRule rule with the migration + message; **cluster-scoped rules are unaffected** (the CRD day-one case still works). +- **`collectClusterWatchRuleSelections`** emits cluster-scoped selections only, still keyed `""`. +- **`clusterWatchRuleFingerprint`** no longer varies with a (removed) scope; two cluster rules + differing only in resources still fingerprint differently. +- **WatchRule per-item** — the whole + [alt-per-item test plan](alt-per-item-source-namespace.md#test-plan-delta-relative-to-pr-4-as-written): + the partial-object deny-in-whole case, `"*"` = deny-by-default with no policy, `"*"` static under a + name policy, `"*"` retain-on-unknown under a selector policy (with **no sweep** — now doubly assured + by Part A), and the mixed omitted/name/`"*"` aggregate condition. +- **Deleted:** every original-PR-5 ceiling test (narrowing, spare-cluster-scoped, resolved-scope + fingerprint, table-rebuild-on-policy-change, retain-on-unknown-for-ClusterWatchRule, the + establishing/maintaining ClusterWatchRule pair). The mechanisms are gone. + +--- + +## The payoff: the rollback that made this scary is now benign + +The [breaking-change comparison](alt-clusterwatchrule-cluster-scope-only.md#comparing-the-approaches--breaking-change-consequences) +named the rollback as the sharpest consequence: roll the controller back past the per-item field and +`namespace: "*"` collapses to own-namespace — a narrowed scope, which is the input to a sweep, which +deletes a tenant's Git content. **Part A removes exactly that edge.** With `prune: onEvent` (the +default), the collapsed scope stops *updating* the dropped namespaces and never *sweeps* them; the +rollback is stale Git, recoverable by rolling forward again, not data loss. The migration mechanics +(cross-kind, no conversion webhook, HA skew) are unchanged — Part A does not make the change less +breaking, it makes getting scope wrong, in either direction, recoverable. + +Part A also **de-delicates PR 4**: the [establishing-vs-maintaining +contract](pr4-source-namespace-field.md#establishing-versus-maintaining-a-scope) exists because +narrowing-to-empty feeds a sweep. Under `onEvent` a narrowed set never sweeps, so the three-valued +retain-on-unknown logic drops from "prevents data loss" to "avoids a stopped stream" — still worth +having, no longer load-bearing for correctness. + +## Done when + +- `GitTarget.spec.prune` defaults to `onEvent`; a resync under a collapsed/empty scope removes nothing; + a real DELETE event still removes its one document; the volume guard trips and is overridable. +- ClusterWatchRule rejects namespaced rules — new and **pre-existing at bootstrap** — with a message + naming the WatchRule migration; cluster-scoped rules are unchanged. +- A WatchRule reaches every admitted namespace via `rules[].namespace: "*"`, and a denied *name* fails + the whole rule loudly; Git paths still follow each object's own namespace. +- The original PR 5's ceiling code and tests are gone, not disabled. +- `task lint`, `task test`, `task test-e2e` pass. + +## Open questions + +1. **Capability B (B4)** — must a platform team author namespaced watching for a tenant target from + outside the tenant namespace? If yes, Part B needs a namespaced-`targetRef` WatchRule or must not + remove the scope. +2. **`prune` default `onEvent` vs `never`** — `onEvent` keeps genuine-delete accuracy; `never` is + maximally safe but lets Git accumulate tombstones for every real deletion. `onEvent` recommended. +3. **Volume guard shape** — absolute `maxDeletions`, a ratio, or both; and the override mechanism + (annotation vs a spec field vs a one-shot). Ship the guard with Part A or immediately after. +4. **Singular `namespace` vs plural `namespaces`** per rule item, and **name-only `"*"` first vs + selector-backed `"*"`** — inherited from + [alt-per-item open questions](alt-per-item-source-namespace.md#open-questions-for-the-reviewer). + +## End-to-end + +The day-one multi-tenant shape, re-expressed in the two-object model and proving the deletion safety: + +- A **ClusterWatchRule** selecting CRDs (cluster-scoped) and a **WatchRule** in `tenant-acme` selecting + ConfigMaps with `rules[].namespace: "*"`, against acme's GitTarget declaring + `allowedSourceNamespaces: [repo-config]` and `prune: onEvent`. +- Create a ConfigMap in `repo-config` and one in `tenant-zen`, and a CRD. Assert: `repo-config/…` and + the CRD appear; `tenant-zen/…` is **never** written (allow-list holds). +- Then narrow the policy to admit nothing and force a resync. Assert `repo-config/…` is **not deleted** + (prune `onEvent` suppresses the sweep) — the negative that proves a scope change cannot wipe a + tenant. Flip the GitTarget to `prune: always` and assert the same narrowing now does remove it, so + the control is real in both directions. + +## What does not change + +PR 1, PR 2, PR 3 remain landed and unaffected. `GitTarget.allowedSourceNamespaces`, the delegation +flag, the in-cluster sign-off argument, and the `IsLocalSource()` trap all stand. Git placement still +follows each mirrored object's own namespace — the write path needs no change beyond the `prune` gate. diff --git a/docs/design/watchrule-source-namespace/pr5-clusterwatchrule-source-ceiling.md b/docs/design/watchrule-source-namespace/pr5-clusterwatchrule-source-ceiling.md index fae1c60f..830d690f 100644 --- a/docs/design/watchrule-source-namespace/pr5-clusterwatchrule-source-ceiling.md +++ b/docs/design/watchrule-source-namespace/pr5-clusterwatchrule-source-ceiling.md @@ -1,5 +1,15 @@ # PR 5 — a declared allowedSourceNamespaces bounds ClusterWatchRule too +> **⛔ SUPERSEDED — do not implement.** Replaced by +> [pr5-cluster-scope-and-deletion-safety.md](pr5-cluster-scope-and-deletion-safety.md), which removes +> the need for this runtime ceiling by making ClusterWatchRule cluster-scoped only (the +> [two-object model](alt-clusterwatchrule-cluster-scope-only.md)) and adds a GitTarget-wide deletion +> control (`prune: never|onEvent|always`) that makes scope mistakes non-destructive. This page is kept +> for the design history and for the mechanisms the replacement deliberately deletes — the per-namespace +> expansion, the resolved-scope fingerprint, and the "unknown is not empty" retain logic — which the +> replacement's §B0 references as the machinery it avoids. Everything below describes the abandoned +> ceiling approach. + > 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 From fbac6975d0220c11ae33bb66dbdc4dadd5324bbb Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 21 Jul 2026 07:57:17 +0000 Subject: [PATCH 03/18] docs: include the deletion modes --- .../watchrule-source-namespace/README.md | 318 ++++++----------- ...alt-clusterwatchrule-cluster-scope-only.md | 10 +- .../alt-per-item-source-namespace.md | 35 +- .../pr1-namespace-scoped-resync.md | 18 +- .../pr2-stream-scope-collapse.md | 11 +- .../pr3-clusterwatchrule-target-admission.md | 2 +- .../pr4-source-namespace-field.md | 58 ++-- .../pr5-cluster-scope-and-deletion-safety.md | 324 ------------------ .../pr5-clusterwatchrule-source-ceiling.md | 260 -------------- .../pr5-gittarget-deletion-safety.md | 113 ++++++ .../pr6-cluster-scope-only.md | 150 ++++++++ 11 files changed, 428 insertions(+), 871 deletions(-) delete mode 100644 docs/design/watchrule-source-namespace/pr5-cluster-scope-and-deletion-safety.md delete mode 100644 docs/design/watchrule-source-namespace/pr5-clusterwatchrule-source-ceiling.md create mode 100644 docs/design/watchrule-source-namespace/pr5-gittarget-deletion-safety.md create mode 100644 docs/design/watchrule-source-namespace/pr6-cluster-scope-only.md diff --git a/docs/design/watchrule-source-namespace/README.md b/docs/design/watchrule-source-namespace/README.md index 92b19d60..e195d9a8 100644 --- a/docs/design/watchrule-source-namespace/README.md +++ b/docs/design/watchrule-source-namespace/README.md @@ -1,242 +1,124 @@ # Source-namespace addressing and per-target source scope -> **Design in five PRs. PRs 1–4 have landed; PR 5 is 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 six PRs.** PRs 1–3 are landed. PR 4 is implemented and green in an open pull request, +> but its top-level `sourceNamespace` API is superseded and must not be released. PR 5 establishes +> deletion safety as a separate rollback-floor release; PR 6 reshapes the unshipped PR-4 work and +> makes ClusterWatchRule cluster-only. Index: [INDEX.md](../../INDEX.md). + +## Decision + +The selected end state is the two-object model: + +- **WatchRule** is the namespaced source-resource surface. `spec.rules[].namespace` is omitted for + the rule's own namespace, names one authorized source namespace, or is `"*"` for every namespace + its GitTarget admits. +- **ClusterWatchRule** is the cluster-scoped source-resource surface. It has no `scope` field. +- **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 6 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**: - -> 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. - -| `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 | +- `GitTarget.spec.allowedSourceNamespaces` is a destination-owned, exhaustive allow-list for + WatchRule source namespaces. A declared policy has no self-namespace exception. +- `ClusterProvider.spec.allowWatchRuleSourceNamespaceOverride` 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`. -Nothing changes on upgrade: the field is new, so it is undeclared everywhere until a target owner -opts in. +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. -### No self-namespace exception +## Why the scope lives on GitTarget -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. +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. -So a policy lists everything, including the target's own namespace when a legacy WatchRule needs it: +The policy does not apply to ClusterWatchRule after PR 6, 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. -~~~yaml -allowedSourceNamespaces: - names: [tenant-acme, repo-config] # own namespace listed explicitly -~~~ +## Implementation phases -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). +| # | 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 | [Original sourceNamespace implementation](pr4-source-namespace-field.md) | Open, green implementation of the authorization model and source-scope service. Its top-level API is superseded; do not release it unchanged. | open, superseded API | +| 5 | [GitTarget deletion safety](pr5-gittarget-deletion-safety.md) | Add `prune.mode` and make resync sweep opt-in (`always`). Release independently as the rollback floor. | planned | +| 6 | [Scope by kind](pr6-cluster-scope-only.md) | Rework PR-4 authorization for `rules[].namespace`, remove ClusterWatchRule `scope`, refuse stored namespaced rules, and migrate cross-kind manifests. | planned, breaking | + +## Release order and the open PR 4 + +**Do not merge or release the open PR 4 unchanged just because it is green.** Its reviewed work is +valuable, but the only user-visible field it adds is known to be the wrong shape and the allow-list is +not a complete boundary while ClusterWatchRule can still select namespaced resources. + +Recommended handling: + +1. Preserve the current PR 4 branch and test history as the implementation baseline for its gate, + conditions, bootstrap enforcement, source-scope snapshot, and reactivity wiring. +2. Land and release PR 5 from main independently. Upgrade every controller instance to it; this is + the minimum safe rollback version for the later API migration. +3. Close the present PR 4 as superseded, or retarget it explicitly as the PR-6 branch. Rebase or + cherry-pick its reusable implementation after PR 5, then replace the top-level field with rule-item + namespace resolution and remove ClusterWatchRule `scope` in the same PR. +4. Do not cut a release containing the original top-level `sourceNamespace` field. Release PR 6 only + after its migration preflight and stored-object refusal are complete. + +Merging the current PR 4 only as an unreleased development checkpoint is technically possible, but it +creates a dead public field and makes the eventual review harder. Closing or retargeting it is the +cleaner path. + +## 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`. -## Implementation phases +## Compatibility -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 | **landed** | -| 5 | [Cluster-scope + deletion safety](pr5-cluster-scope-and-deletion-safety.md) | ClusterWatchRule becomes cluster-scoped only (scope by kind, `scope: Namespaced` removed), WatchRule gains a per-rule-item `namespace` with `"*"` = all-admitted, and `GitTarget.spec.prune` (`never`/`onEvent`/`always`, default `onEvent`) + a volume guard make scope mistakes non-destructive. Breaking. Replaces the [abandoned runtime ceiling](pr5-clusterwatchrule-source-ceiling.md). | 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 shipped the field; until PR 5 lands, -`allowedSourceNamespaces` is enforced on the rule kind that cannot bypass it (WatchRule) and unenforced -on the one that can (ClusterWatchRule's `scope: Namespaced`), and -[use case 4](#the-driving-use-case-multi-tenancy-with-crds-on-day-one) is exactly the configuration -that hits the gap. The replacement PR 5 closes it by **removing** that scope rather than bounding it, so -the gate still holds: do not release with PR 4 in and PR 5 out. PR 5's `prune` default (`onEvent`) is -also what makes a rollback across PR 5 recoverable instead of a tenant-data-loss event. - -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. +This is preliminary v1alpha3 API. PR 5 changes the default effective sweep behavior in the safe +direction. PR 6 is deliberately breaking: -## Why the scope lives on GitTarget +- `WatchRule.spec.sourceNamespace` moves to `WatchRule.spec.rules[].namespace` before it has reached + a release. +- `ClusterResourceRule.scope` and the public `ResourceScope` enum are removed. +- A legacy namespaced ClusterWatchRule cannot be converted automatically into a WatchRule: the move is + cross-kind and `namespace: "*"` requires an explicit target policy where legacy cluster rules did + not. -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 6 supplies a dry-run migration preflight. It must fail rather than silently narrow a target that +has no compatible `allowedSourceNamespaces` policy. A controller rollback is supported only to the +released PR-5 version while PR-6 manifests exist; rolling back farther is unsupported. -## Compatibility +## Deferred work -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. +- Selector-backed `rules[].namespace: "*"` is deferred. The first wildcard release supports + names-only allow-lists; selector fan-out needs independent invalidation and retaining semantics. +- 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/alt-clusterwatchrule-cluster-scope-only.md b/docs/design/watchrule-source-namespace/alt-clusterwatchrule-cluster-scope-only.md index d919b0a5..7354eb99 100644 --- a/docs/design/watchrule-source-namespace/alt-clusterwatchrule-cluster-scope-only.md +++ b/docs/design/watchrule-source-namespace/alt-clusterwatchrule-cluster-scope-only.md @@ -1,10 +1,14 @@ # Alternative — the two-object model: scope is carried by the kind, and the enum is removed -> **Status: proposal, for review. Not agreed, not scheduled.** The crisp, breaking form of the +> **Status: selected design rationale; implementation is split into +> [PR 5 deletion safety](pr5-gittarget-deletion-safety.md) and +> [PR 6 scope by kind](pr6-cluster-scope-only.md).** This is the crisp, breaking form of the > WatchRule-side redesign, sibling to the non-breaking > [alt-per-item-source-namespace.md](alt-per-item-source-namespace.md). Both share the same WatchRule > interface (a per-rule-item `namespace`); they differ only on ClusterWatchRule, and that difference > is the whole breaking/non-breaking axis this page exists to compare. +> References below to a “PR 5 runtime ceiling” are historical: that proposal was abandoned and must +> not be confused with the current PR 5 deletion-safety release. > > Supersedes this file's earlier "narrow the enum to `Cluster`" framing: narrowing left a vestigial > single-value `scope: Cluster` field on every rule. The crisper move is to **remove the field @@ -81,9 +85,9 @@ ClusterWatchRule keeps `ScopeCluster` records) and discovery supplies the fact. deleting the field; a class of user error — declaring a scope that contradicts discovery — is deleted with it. -## This deletes PR 5 outright +## Why PR 6 needs no runtime ceiling -[PR 5](pr5-clusterwatchrule-source-ceiling.md) exists solely to make `allowedSourceNamespaces` bind +The abandoned runtime-ceiling proposal existed solely to make `allowedSourceNamespaces` bind ClusterWatchRule's `scope: Namespaced` streams. If a ClusterWatchRule can only watch cluster-scoped resources, a **namespace** allow-list is simply not applicable to it — exactly as the [overview already concedes](README.md#what-the-ceiling-does-not-do) that the ceiling cannot partition diff --git a/docs/design/watchrule-source-namespace/alt-per-item-source-namespace.md b/docs/design/watchrule-source-namespace/alt-per-item-source-namespace.md index 78382706..b73b4412 100644 --- a/docs/design/watchrule-source-namespace/alt-per-item-source-namespace.md +++ b/docs/design/watchrule-source-namespace/alt-per-item-source-namespace.md @@ -1,16 +1,11 @@ # Alternative — a per-rule-item namespace, with `*` meaning "all *allowed* namespaces" -> **Status: proposal, for review. Not agreed, not scheduled.** A shape for the WatchRule side of -> [source-namespace addressing](README.md), sibling to the -> [two-object model](alt-clusterwatchrule-cluster-scope-only.md). The two pages share **this exact -> WatchRule interface** (a per-rule-item `namespace` with `"*"` bounded by the allow-list) and differ -> only on ClusterWatchRule: the two-object page *removes* `scope: Namespaced` from it (breaking, deletes -> [PR 5](pr5-clusterwatchrule-source-ceiling.md)), whereas this page *keeps* ClusterWatchRule and PR 5 -> exactly as designed (additive, non-breaking) and reshapes only the -> [PR 4](pr4-source-namespace-field.md) field. Pick this page's ClusterWatchRule treatment to avoid a -> breaking change; pick the two-object page's to delete PR 5's permanent machinery. The -> [breaking-change comparison](alt-clusterwatchrule-cluster-scope-only.md#comparing-the-approaches--breaking-change-consequences) -> lives on that page. +> **Status: rejected alternative, retained for design history.** The selected plan is +> [PR 5 deletion safety](pr5-gittarget-deletion-safety.md) followed by +> [PR 6 scope by kind](pr6-cluster-scope-only.md). This alternative would keep +> `ClusterWatchRule.scope: Namespaced` and the former runtime-ceiling machinery; that machinery is +> abandoned. References below to the former “PR 5” describe that historical alternative, not the +> current deletion-safety PR number. > > **PR 4 is being implemented as this is written, top-level singular.** The compiled rule already > carries `SourceNamespace` and [watched_type_resolver.go:301](../../../internal/watch/watched_type_resolver.go#L301) @@ -94,8 +89,8 @@ That has three consequences worth stating separately: The [cluster-scope-only alternative](alt-clusterwatchrule-cluster-scope-only.md) removed PR 5's runtime ceiling, its fingerprint hazard, and its informer fan-out. **This proposal removes none of -that.** ClusterWatchRule keeps `scope: Namespaced`; [PR 5](pr5-clusterwatchrule-source-ceiling.md) -ships as written; and WatchRule *gains* a namespace-expansion path that looks a lot like PR 5's. +that.** ClusterWatchRule keeps `scope: Namespaced`; the former runtime-ceiling proposal would ship; +and WatchRule *gains* a namespace-expansion path that looks a lot like that proposal's. So the end state has two ways to watch namespaced resources across namespaces: @@ -135,8 +130,7 @@ denied". Two semantics, and they must not be blended: worse than loud failure here. - **`"*"` is *maintaining* / narrowing.** It never denies. It expands to the admitted set, which may be empty, which is a correct outcome and **not** `Stalled` — the same rule - [PR 5 §2b](pr5-clusterwatchrule-source-ceiling.md#2b-unknown-is-not-empty) already states for the - ceiling. + the former runtime-ceiling design stated for the ceiling. So a single WatchRule can carry both a *refusal* semantic (a denied name) and a *narrowing* semantic (`"*"`). When both are present the refusal dominates the object-level condition. This is the existing @@ -161,8 +155,8 @@ policy is the recommended first cut; the selector-backed `"*"` can follow with t ### 3. The fingerprint must include the resolved set for `"*"` items -Same shape as [PR 5 §2](pr5-clusterwatchrule-source-ceiling.md#2-put-the-resolved-scope-into-table-invalidation), -now on WatchRule: for a `"*"` item the resolved namespace set is **not rule state**, so a policy or +Same shape as the former runtime-ceiling design's invalidation rule, now on WatchRule: for a `"*"` +item the resolved namespace set is **not rule state**, so a policy or Namespace-label change that alters the set must still re-project the watched-type table. Hash the resolved set into the rule's fingerprint (or carry a source-scope generation into the rebuild trigger). A name-only item needs nothing new. The silent-failure test from PR 5 applies verbatim: an @@ -174,9 +168,8 @@ unchanged rule object under a changed policy must re-project. appends one selection per matched record at `namespace: rule.SourceNamespace`. Per-item, it appends one selection **per (matched record × resolved namespace)** — the same expansion PR 5 adds to `collectClusterWatchRuleSelections`, so the two collectors converge on one shape. Prefer expansion at -this site over filtering at the read site, for exactly the reason -[PR 5 §1](pr5-clusterwatchrule-source-ceiling.md#1-apply-the-ceiling-in-selection) gives: an expanded -selection carries the scope through the plan hash, the informers, and the resync path for free, while +this site over filtering at the read site: an expanded selection carries the scope through the plan +hash, the informers, and the resync path for free, while a read-site filter must be repeated at each and is silently wrong if one is missed. ## Relationship to the in-flight PR 4 @@ -204,7 +197,7 @@ only the per-type granularity and the visual symmetry with the other rule-item f ## What does NOT change - ClusterWatchRule keeps `scope: Namespaced`; [PR 3](pr3-clusterwatchrule-target-admission.md) and - [PR 5](pr5-clusterwatchrule-source-ceiling.md) are unaffected. This proposal is orthogonal to them. + the former runtime-ceiling proposal are unaffected. This proposal is orthogonal to them. - `GitTarget.allowedSourceNamespaces`, the delegation flag, and the in-cluster sign-off argument stand verbatim. - Git placement still follows each mirrored object's **own** namespace, so the write path needs no 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..f9a16456 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,8 @@ 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-source-namespace-field.md) | The original implementation baseline proves source-namespace authorization and its gate. | +| [PR 6](pr6-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 +110,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 +153,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 6 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..70a525e4 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 implementation and PR 6's rule-item namespace expansion. > Bug fix — no API change, no CRD regeneration. > > **Status: landed.** `SnapshotNamespaces` is now `WatchedType.WatchScopes`, which returns every @@ -35,12 +35,9 @@ co-resident WatchRule must not silently inherit that cluster-wide stream. Otherw 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 6](pr6-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-source-namespace-field.md b/docs/design/watchrule-source-namespace/pr4-source-namespace-field.md index 2ee1ca6c..46bab3c5 100644 --- a/docs/design/watchrule-source-namespace/pr4-source-namespace-field.md +++ b/docs/design/watchrule-source-namespace/pr4-source-namespace-field.md @@ -1,16 +1,19 @@ -# PR 4 — the sourceNamespace field and its authorization gate +# PR 4 (superseded implementation baseline) — the top-level sourceNamespace 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. +> Phase 4 of [source-namespace addressing](README.md). **Status: implemented and green in an open +> pull request; do not merge or release unchanged.** Its authorization model, conditions, +> bootstrap gate, source-scope snapshot, and reactivity work are the implementation baseline for +> [PR 6](pr6-cluster-scope-only.md). Its public top-level `sourceNamespace` field is superseded by +> `rules[].namespace` before reaching a release. +> +> Keep this document as evidence for the reusable work. The selected release sequence is +> [PR 5 deletion safety](pr5-gittarget-deletion-safety.md), released first, followed by PR 6. Do not +> cut a release with the API described below. -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). +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 6. ## Example @@ -46,7 +49,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 +81,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 6 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 @@ -203,7 +205,8 @@ Half of this is already wired: 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 5](pr5-clusterwatchrule-source-ceiling.md) extends the same snapshot to ClusterWatchRules. +[PR 6](pr6-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 @@ -251,8 +254,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 6 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 @@ -272,7 +276,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 6 defers selector-backed wildcards, but preserves this exact-name selector contract. | | 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`**. | @@ -282,11 +286,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 @@ -519,7 +522,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-6 + rework after PR 5 has established the rollback floor. --- diff --git a/docs/design/watchrule-source-namespace/pr5-cluster-scope-and-deletion-safety.md b/docs/design/watchrule-source-namespace/pr5-cluster-scope-and-deletion-safety.md deleted file mode 100644 index e5f0bfda..00000000 --- a/docs/design/watchrule-source-namespace/pr5-cluster-scope-and-deletion-safety.md +++ /dev/null @@ -1,324 +0,0 @@ -# PR 5 (replacement) — ClusterWatchRule becomes cluster-scoped, made safe by GitTarget deletion controls - -> **Replaces [pr5-clusterwatchrule-source-ceiling.md](pr5-clusterwatchrule-source-ceiling.md).** That -> page bound `allowedSourceNamespaces` to ClusterWatchRule's `scope: Namespaced` streams *at runtime* -> (a ceiling). This page removes the need for a ceiling by removing `scope: Namespaced` from -> ClusterWatchRule entirely — the [two-object model](alt-clusterwatchrule-cluster-scope-only.md) — and -> pairs it with a GitTarget-wide deletion control that makes the breaking change, and every scope -> mistake in this folder, **non-destructive**. -> -> Phase 5 of [source-namespace addressing](README.md). **Depends on:** -> [PR 4](pr4-source-namespace-field.md) (the field, the gate, the source-scope service), -> [PR 1](pr1-namespace-scoped-resync.md) (namespace-scoped resync), and -> [PR 2](pr2-stream-scope-collapse.md). **Breaking change**, accepted on this preliminary v1alpha3 API -> per [README § compatibility](README.md#compatibility). **Still bound by the -> [release gate](README.md#implementation-phases):** until this lands, ClusterWatchRule's namespaced -> streams bypass `allowedSourceNamespaces`, so PR 4's boundary is incomplete without it — do not cut a -> release between PR 4 and this PR. -> -> Code references verified against the tree on 2026-07-21. - -## Two moves, in this order - -1. **Deletion safety first** — `GitTarget.spec.prune` (`never` | `onEvent` | `always`, default - `onEvent`) plus a volume guard. This is the foundation: it makes a wrong scope stop *writing* - without *deleting*, so the breaking change below (and a rollback across it) is a stale-Git - annoyance rather than tenant data loss. -2. **Scope simplification** — remove the `scope` field from ClusterWatchRule (cluster-scoped by kind), - refuse any pre-existing namespaced cluster rule, and generalize the WatchRule field from PR 4's - single `sourceNamespace` to a per-rule-item `namespace` with `"*"` meaning "every namespace the - GitTarget admits". The `"*"` is the replacement path for the capability being removed from - ClusterWatchRule. - -They are one plan because move 2 is only safe to ship on top of move 1. They may land as two commits; -move 1 must precede move 2 **within the same release**. - ---- - -## Part A — deletion safety - -### The two deletion triggers already exist as separate code paths - -The write path already draws the boundary this control needs — "Two Paths, One Plan Type" -([reconcile-via-watchlist-mark-and-sweep.md](../../spec/reconcile-via-watchlist-mark-and-sweep.md)): - -- **Per-event delete** — [`PlanDelete`](../../../internal/manifestanalyzer/delete_plan.go#L36) resolves - one real DELETE watch event to one delete-document action and, in its own words, "targets exactly - ONE identity and **NEVER sweeps**". Applied by - [`writeBatch.applyDelete`](../../../internal/git/plan_flush.go#L1140). -- **Mark-and-sweep** — [`BuildScopedPlan`](../../../internal/manifestanalyzer/plan.go#L197) (and its - whole-folder special case [`BuildPlan`](../../../internal/manifestanalyzer/plan.go#L170)) diffs the - full desired set against Git and emits a **managed drop** for every in-scope document not in desired. - Applied on resync/heal by [`applyResync`](../../../internal/git/resync_flush.go). This is the path - that turns a scope collapse into deletion, because "desired" is computed from the (possibly wrong) - scope. - -`prune` gates these two paths independently: - -| `prune` | per-event delete (`PlanDelete`) | mark-and-sweep drop (`BuildScopedPlan`) | Git after a genuine delete / after a scope collapse | -|---|---|---|---| -| `never` | suppressed | suppressed | never deleted / never deleted — append-and-tombstone archive | -| **`onEvent`** *(default)* | **applied** | **suppressed** | deleted / **not deleted** — real deletions mirrored, collapse is inert | -| `always` | applied | applied | deleted / **deleted** — today's behavior, faithful and dangerous | - -### Why the default is `onEvent`, and why it must not be `always` - -The catastrophic deletion is the *inference*, not the *event*. A real DELETE event is a strong signal — -the object left the cluster. The sweep is an inference — "not in my current desired set, so it must be -gone" — and that inference is exactly what is wrong when scope collapsed (config error, a transient -source outage narrowing to empty, or a **rollback** across the scope simplification below). `onEvent` -keeps deletion accuracy for the common case and removes the entire scope-collapse data-loss class. - -The default **must** be a sweep-suppressing mode for the safety argument to hold. Defaulting to -`always` — today's implicit behavior — would forfeit the "rollback is survivable" guarantee that is the -whole reason move 1 precedes move 2. Changing the effective default from `always` to `onEvent` is itself -a behavior change for existing GitTargets (they stop pruning stale files on resync); on this -preliminary API with few users it is the intended, safe-direction change, and it is called out in -release notes. - -### The volume guard — closing the vector `onEvent` leaves open - -`onEvent` blocks the *inference* vector; it does **not** block the *volume* vector. A real -`kubectl delete ns tenant-acme` cascades into a genuine DELETE per object, and `onEvent` faithfully -deletes every file — correct mirroring, but also a whole-folder wipe from real events. So add a guard, -independent of `prune` mode: - -> A single commit/flush that would remove more than **N** managed documents (absolute, and/or a ratio -> of the target's tracked documents) refuses the removals, keeps the creates/updates, surfaces a -> `PruneGuardTripped` condition + event on the GitTarget, and requires an explicit override to proceed. - -The trichotomy controls *whether* to infer deletions; the guard controls *how many* any one flush may -make, from either path. The guard is the specific answer to "prune a complete folder by mistake" — the -concern that has kept pruning unbuilt until now. Prior art: Flux `Kustomization.spec.prune` and Argo -CD's automated prune are both explicit and treated as dangerous-by-default for this reason (semantics -verifiable in `external-sources/`). - -### The reversibility caveat that raises the stakes - -"A wrong prune is reversible in Git" is true at the history level but weaker than it looks when the -destination repo is itself a **deploy source** — which is this product's own vision (merge = deploy). A -spurious prune commit can propagate to a live cluster through the downstream Flux/Argo sync *before* a -human reverts it, turning "annoying diff" into "deleted live resources". This is why the control lives -on **GitTarget**, not globally: an audit-mirror destination legitimately wants `always`; a -deploy-source destination wants `onEvent` + guard. The safety/accuracy trade is per-destination. - -### Part A implementation - -1. **API.** `GitTarget.spec.prune` — a `+kubebuilder:validation:Enum=never;onEvent;always`, - `+kubebuilder:default=onEvent` string, next to - [`AllowedSourceNamespaces`](../../../api/v1alpha3/gittarget_types.go#L134). Optional guard fields - (`pruneGuard.maxDeletions`, `pruneGuard.maxDeletionRatio`). `task generate` + `task manifests`. -2. **Thread the mode into the planner.** `BuildScopedPlan` already takes a - [`Policy`](../../../internal/manifestanalyzer/plan.go#L197); add the prune mode to `Policy` and - **suppress the managed-drop emission** when the mode is not `always`. One choke point, because every - sweep — full, per-type (M12), and resync — funnels through this function. Do **not** filter at - apply time; a suppressed drop must never enter the plan, so the plan hash, the commit, and the - resync path all agree. -3. **Gate the per-event path.** `applyDelete` ([plan_flush.go:1140](../../../internal/git/plan_flush.go#L1140)) - skips the removal when the mode is `never`. `onEvent` and `always` apply it. -4. **Volume guard at the flush boundary.** Count `Removed` actions in the batch before commit - ([plan_flush.go](../../../internal/git/plan_flush.go)); over threshold ⇒ drop the removals from the - batch, keep the rest, set `PruneGuardTripped`. The guard counts drops from **either** path. -5. **Status/surface.** A `GitTarget` condition/event when the guard trips, and a one-time info log when - a resync's sweep is suppressed by `onEvent`/`never` (so an operator can see *why* a stale file - persists). Do not make suppression an error state — it is the configured, healthy behavior. - -### Part A test plan - -- **Scope-collapse is inert under `onEvent`.** Build a desired set that has narrowed to empty for a - namespace, run the resync plan, assert **zero** managed-drop actions and that the files remain. This - is the test that would have caught the rollback data-loss. -- **`onEvent` still mirrors a real delete.** A DELETE event removes exactly its one document. -- **`never` removes nothing** from either path; **`always` reproduces today's sweep** byte-for-byte - (regression pin: `always` must equal current `BuildPlan` behavior). -- **Volume guard** trips at the threshold from a sweep **and** from an event cascade, keeps - non-deletions, sets the condition, and honors an override. -- **Default is `onEvent`** on a GitTarget that omits the field (defaulting test). - ---- - -## Part B — scope simplification (the two-object model) - -Full rationale and the breaking-change comparison are in -[alt-clusterwatchrule-cluster-scope-only.md](alt-clusterwatchrule-cluster-scope-only.md); this section -is the implementable subset. The end state: - -> **ClusterWatchRule is the cluster-global surface. WatchRule is the namespaced surface, and every -> namespace it reaches is named in its spec and admitted by its GitTarget.** Scope is carried by the -> *kind*, never by a per-rule field. - -### B0. Why removal deletes the original PR 5 wholesale - -`allowedSourceNamespaces` is a *namespace* allow-list; a cluster-only ClusterWatchRule has no namespace -to bound, exactly as [the overview concedes](README.md#what-the-ceiling-does-not-do). So removing the -namespaced scope makes the ceiling inapplicable, and with it go the three hardest mechanisms the -original PR 5 had to build — the per-namespace expansion of `collectClusterWatchRuleSelections`, the -`clusterWatchRuleFingerprint` that had to hash **non-rule state** (the subtlest defect in the folder), -and the "unknown is not empty" retain-on-outage logic for ClusterWatchRule. None of them is written. - -### B1. Remove the scope field - -Delete `Scope` from [`ClusterResourceRule`](../../../api/v1alpha3/clusterwatchrule_types.go#L110) and -remove the [`ResourceScope` enum](../../../api/v1alpha3/clusterwatchrule_types.go#L9-L19) from the API -surface. The internal `typeset.Scope` stays — discovery still needs it. `task generate` + `task -manifests`. - -`collectClusterWatchRuleSelections` -([watched_type_resolver.go:306-323](../../../internal/watch/watched_type_resolver.go#L306-L323)) drops -its `rr.Scope` argument and always matches with `ScopeCluster`; it keeps emitting `namespace: ""`, -which is now correct — those are genuinely cluster-scoped streams. -`clusterWatchRuleFingerprint` drops its `rr.Scope` component -([watched_type_resolver.go:511](../../../internal/watch/watched_type_resolver.go#L511)); it needs no -`src=` component, because a cluster-only rule's scope is fully spec-derived. - -### B2. Removing the field does not retract stored objects — refuse them explicitly - -CRD validation runs on **write**, not read. A `ClusterWatchRule` already stored with a namespaced rule -stays served and is still compiled by -[`bootstrapRuleStore`](../../../internal/watch/bootstrap.go#L49-L68); once the controller stops reading -`scope`, it would silently reinterpret that rule as cluster-scoped and either match nothing or flip -meaning. So the field removal is paired with a refusal: - -> In the single gated compile path [PR 4 step 7](pr4-source-namespace-field.md#implementation-steps) -> routes both the reconciler and bootstrap through, refuse a ClusterWatchRule rule whose resource -> resolves **via discovery** ([`matchesScope`](../../../internal/watch/watched_type_resolver.go#L397)) -> to a namespaced type. Terminal condition, message naming the migration: *"watch namespaced resources -> with a WatchRule and rules[].namespace; ClusterWatchRule is cluster-scoped only."* - -The must-have test is `TestBootstrap_PreExistingNamespacedClusterRuleIsRefused` — a stored namespaced -cluster rule is not compiled and starts no stream, asserted before any reconcile. No manifest in the -repo can create that object once the field is gone, so this test is the only guard against a regression -in the refusal. - -### B3. WatchRule per-item namespace — the replacement for the removed capability - -Generalize PR 4's `WatchRule.spec.sourceNamespace` (top-level, singular) to -`WatchRule.spec.rules[].namespace` on [`ResourceRule`](../../../api/v1alpha3/watchrule_types.go#L83): - -| `namespace` on a rule item | Meaning | -|---|---| -| omitted | the WatchRule's own namespace (legacy, unchanged) | -| `"*"` | every namespace the GitTarget's `allowedSourceNamespaces` **admits** — not every namespace that exists; deny-by-default with no policy | -| a name | that one source namespace, through PR 4's three-part gate | - -This is a **pre-release evolution of PR 4's field**, not a new authorization model — the gate, the -`SourceNamespaceAuthorized` condition, and the source-scope service are reused verbatim, called per -`(rule item, namespace)` instead of per object. The full interface, the partial-authorization handling -(deny-in-whole for a denied *name*, narrow-for-`"*"`), and the `"*"`-selector maintaining path are -specified in [alt-per-item-source-namespace.md](alt-per-item-source-namespace.md); implement that page -here. PR 4 shipped singular into no release, so this is a rename with no conversion surface -([README § compatibility](README.md#compatibility)). - -Mechanical deltas from PR 4's singular field: -- `CompiledRule.SourceNamespace` (one value, read at - [watched_type_resolver.go:301](../../../internal/watch/watched_type_resolver.go#L301)) becomes a - per-`ResourceRule` namespace; `collectWatchRuleSelections` expands one selection **per (matched - record × resolved namespace)** — the same expansion the original PR 5 added to the *cluster* rule, - now on the WatchRule where it belongs. -- `watchRuleFingerprint` - ([watched_type_resolver.go:478-482](../../../internal/watch/watched_type_resolver.go#L478-L482)) - hashes the per-item namespace, and for a `"*"` item the **resolved** set — the one place the - fingerprint-not-rule-state care from the original PR 5 survives, now scoped to `"*"` items only. -- Name-only items stay fully static; only `"*"` against a *selector* policy needs the source-cluster - Namespace informer, so that machinery is opt-in per rule. - -### B4. Capability removed with no in-kind replacement — flag for review - -ClusterWatchRule's cross-namespace `targetRef` was the only way a **platform** team could author a -tenant's *namespaced* mirror without an object in the tenant namespace. `WatchRule.targetRef` is a -`LocalTargetReference` with no namespace -([watchrule_types.go:24-42](../../../api/v1alpha3/watchrule_types.go#L24-L42)), so the WatchRule must -live in the namespace it watches from. **The two-object model removes platform-external namespaced -authoring with no replacement.** If a real deployment relies on it, this plan is wrong for that -deployment and must be paired with a namespaced-`targetRef` WatchRule (a separate change). This is the -open question most able to defeat Part B — see [open questions](#open-questions). - -### B5. Reactivity - -- **GitTarget → ClusterWatchRules mapper:** the original PR 5 needed it to re-resolve a runtime - ceiling. With the ceiling gone, ClusterWatchRule no longer re-resolves on GitTarget policy changes — - **the mapper is not needed** for ClusterWatchRule. PR 3's ClusterProvider→ClusterWatchRule admission - mapper is unaffected. -- **WatchRule reactivity:** PR 4's edges carry per-item changes unchanged. A `"*"` item against a - selector policy needs the source-cluster Namespace informer from PR 4 to map to WatchRules — reused, - not new. - -### Part B test plan - -- **`TestBootstrap_PreExistingNamespacedClusterRuleIsRefused`** (B2) — the critical one. -- **Admission/compile refuses** a namespaced-resolving ClusterWatchRule rule with the migration - message; **cluster-scoped rules are unaffected** (the CRD day-one case still works). -- **`collectClusterWatchRuleSelections`** emits cluster-scoped selections only, still keyed `""`. -- **`clusterWatchRuleFingerprint`** no longer varies with a (removed) scope; two cluster rules - differing only in resources still fingerprint differently. -- **WatchRule per-item** — the whole - [alt-per-item test plan](alt-per-item-source-namespace.md#test-plan-delta-relative-to-pr-4-as-written): - the partial-object deny-in-whole case, `"*"` = deny-by-default with no policy, `"*"` static under a - name policy, `"*"` retain-on-unknown under a selector policy (with **no sweep** — now doubly assured - by Part A), and the mixed omitted/name/`"*"` aggregate condition. -- **Deleted:** every original-PR-5 ceiling test (narrowing, spare-cluster-scoped, resolved-scope - fingerprint, table-rebuild-on-policy-change, retain-on-unknown-for-ClusterWatchRule, the - establishing/maintaining ClusterWatchRule pair). The mechanisms are gone. - ---- - -## The payoff: the rollback that made this scary is now benign - -The [breaking-change comparison](alt-clusterwatchrule-cluster-scope-only.md#comparing-the-approaches--breaking-change-consequences) -named the rollback as the sharpest consequence: roll the controller back past the per-item field and -`namespace: "*"` collapses to own-namespace — a narrowed scope, which is the input to a sweep, which -deletes a tenant's Git content. **Part A removes exactly that edge.** With `prune: onEvent` (the -default), the collapsed scope stops *updating* the dropped namespaces and never *sweeps* them; the -rollback is stale Git, recoverable by rolling forward again, not data loss. The migration mechanics -(cross-kind, no conversion webhook, HA skew) are unchanged — Part A does not make the change less -breaking, it makes getting scope wrong, in either direction, recoverable. - -Part A also **de-delicates PR 4**: the [establishing-vs-maintaining -contract](pr4-source-namespace-field.md#establishing-versus-maintaining-a-scope) exists because -narrowing-to-empty feeds a sweep. Under `onEvent` a narrowed set never sweeps, so the three-valued -retain-on-unknown logic drops from "prevents data loss" to "avoids a stopped stream" — still worth -having, no longer load-bearing for correctness. - -## Done when - -- `GitTarget.spec.prune` defaults to `onEvent`; a resync under a collapsed/empty scope removes nothing; - a real DELETE event still removes its one document; the volume guard trips and is overridable. -- ClusterWatchRule rejects namespaced rules — new and **pre-existing at bootstrap** — with a message - naming the WatchRule migration; cluster-scoped rules are unchanged. -- A WatchRule reaches every admitted namespace via `rules[].namespace: "*"`, and a denied *name* fails - the whole rule loudly; Git paths still follow each object's own namespace. -- The original PR 5's ceiling code and tests are gone, not disabled. -- `task lint`, `task test`, `task test-e2e` pass. - -## Open questions - -1. **Capability B (B4)** — must a platform team author namespaced watching for a tenant target from - outside the tenant namespace? If yes, Part B needs a namespaced-`targetRef` WatchRule or must not - remove the scope. -2. **`prune` default `onEvent` vs `never`** — `onEvent` keeps genuine-delete accuracy; `never` is - maximally safe but lets Git accumulate tombstones for every real deletion. `onEvent` recommended. -3. **Volume guard shape** — absolute `maxDeletions`, a ratio, or both; and the override mechanism - (annotation vs a spec field vs a one-shot). Ship the guard with Part A or immediately after. -4. **Singular `namespace` vs plural `namespaces`** per rule item, and **name-only `"*"` first vs - selector-backed `"*"`** — inherited from - [alt-per-item open questions](alt-per-item-source-namespace.md#open-questions-for-the-reviewer). - -## End-to-end - -The day-one multi-tenant shape, re-expressed in the two-object model and proving the deletion safety: - -- A **ClusterWatchRule** selecting CRDs (cluster-scoped) and a **WatchRule** in `tenant-acme` selecting - ConfigMaps with `rules[].namespace: "*"`, against acme's GitTarget declaring - `allowedSourceNamespaces: [repo-config]` and `prune: onEvent`. -- Create a ConfigMap in `repo-config` and one in `tenant-zen`, and a CRD. Assert: `repo-config/…` and - the CRD appear; `tenant-zen/…` is **never** written (allow-list holds). -- Then narrow the policy to admit nothing and force a resync. Assert `repo-config/…` is **not deleted** - (prune `onEvent` suppresses the sweep) — the negative that proves a scope change cannot wipe a - tenant. Flip the GitTarget to `prune: always` and assert the same narrowing now does remove it, so - the control is real in both directions. - -## What does not change - -PR 1, PR 2, PR 3 remain landed and unaffected. `GitTarget.allowedSourceNamespaces`, the delegation -flag, the in-cluster sign-off argument, and the `IsLocalSource()` trap all stand. Git placement still -follows each mirrored object's own namespace — the write path needs no change beyond the `prune` gate. 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 830d690f..00000000 --- a/docs/design/watchrule-source-namespace/pr5-clusterwatchrule-source-ceiling.md +++ /dev/null @@ -1,260 +0,0 @@ -# PR 5 — a declared allowedSourceNamespaces bounds ClusterWatchRule too - -> **⛔ SUPERSEDED — do not implement.** Replaced by -> [pr5-cluster-scope-and-deletion-safety.md](pr5-cluster-scope-and-deletion-safety.md), which removes -> the need for this runtime ceiling by making ClusterWatchRule cluster-scoped only (the -> [two-object model](alt-clusterwatchrule-cluster-scope-only.md)) and adds a GitTarget-wide deletion -> control (`prune: never|onEvent|always`) that makes scope mistakes non-destructive. This page is kept -> for the design history and for the mechanisms the replacement deliberately deletes — the per-namespace -> expansion, the resolved-scope fingerprint, and the "unknown is not empty" retain logic — which the -> replacement's §B0 references as the machinery it avoids. Everything below describes the abandoned -> ceiling approach. - -> 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..21e9cc4b --- /dev/null +++ b/docs/design/watchrule-source-namespace/pr5-gittarget-deletion-safety.md @@ -0,0 +1,113 @@ +# PR 5 — GitTarget deletion safety + +> Phase 5 of [source-namespace addressing](README.md). This is an independently releasable +> **rollback-floor** release: it deliberately contains no source-namespace API change. It must be +> released, and all controller instances upgraded to it, before [PR 6](pr6-cluster-scope-only.md) +> introduces the breaking WatchRule and ClusterWatchRule API changes. +> +> **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 + +Release this PR by itself. Upgrade the CRD and controller, then wait until every controller pod runs +this version. That version is the rollback floor for PR 6: a rollback from PR 6 to PR 5 still +understands `prune.mode: onEvent`, so it cannot turn a newer namespace scope into a sweep. + +Do not claim safety for a rollback past PR 5 after PR-6 manifests have been applied: an older +controller neither understands `prune` nor the new rule-item namespace field. + +## 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. +- The release has passed `task lint`, `task test`, and `task test-e2e` and has become the required + rollback floor before PR 6 starts its manifest migration. diff --git a/docs/design/watchrule-source-namespace/pr6-cluster-scope-only.md b/docs/design/watchrule-source-namespace/pr6-cluster-scope-only.md new file mode 100644 index 00000000..eadc8ffc --- /dev/null +++ b/docs/design/watchrule-source-namespace/pr6-cluster-scope-only.md @@ -0,0 +1,150 @@ +# PR 6 — scope by kind: cluster-only ClusterWatchRule and per-item WatchRule namespaces + +> Phase 6 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), [PR 3](pr3-clusterwatchrule-target-admission.md), and the +> released [PR 5 deletion-safety rollback floor](pr5-gittarget-deletion-safety.md). +> +> This PR supersedes the unshipped top-level `sourceNamespace` interface described by +> [PR 4](pr4-source-namespace-field.md). It reuses that PR's authorization, status, bootstrap-gate, +> and source-scope work, but does not release its API shape. + +## End state + +Scope is carried by the rule kind: + +- **WatchRule** selects namespaced source resources. Each `ResourceRule` has an optional + `namespace` field. +- **ClusterWatchRule** selects cluster-scoped source resources. It has no user-facing `scope` field. + +~~~yaml +kind: WatchRule +metadata: { name: repo-config, namespace: tenant-acme } +spec: + targetRef: { name: acme } + rules: + - resources: [configmaps] # own namespace + - resources: [secrets] + namespace: repo-config # one admitted source namespace + - resources: [deployments] + namespace: "*" # every namespace the target admits +--- +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 simple: a namespace allow-list applies to WatchRules; a +ClusterWatchRule is intentionally cluster-global and is bounded by its source credential's RBAC. + +## 1. ClusterWatchRule becomes cluster-only + +Remove `ResourceScope` and `ClusterResourceRule.scope` from the API. Discovery continues to know a +type's internal scope; the resolver always selects cluster-scoped records for ClusterWatchRule. Its +selections continue to use namespace `""`, now only for genuinely cluster-scoped streams. + +Removing the schema field is insufficient. Existing objects may be read after a schema change without +the old field available to controller code. In the single compile path shared by bootstrap and +reconcile, refuse any ClusterWatchRule selector that resolves to a namespaced type. The terminal +condition must say: + +> ClusterWatchRule is cluster-scoped only; watch namespaced resources with a WatchRule and +> `rules[].namespace`. + +The critical test is `TestBootstrap_PreExistingNamespacedClusterRuleIsRefused`: it must compile no +stream before the first reconciliation can publish status. + +This removes the abandoned runtime-ceiling design entirely: no ClusterWatchRule source-namespace +expansion, resolved-scope fingerprint, source-policy mapper, or selector-outage retention path is +implemented for that kind. + +## 2. WatchRule namespace moves onto ResourceRule + +Delete `WatchRule.spec.sourceNamespace`. Add `ResourceRule.namespace` with structural validation for +either `"*"` or a Kubernetes Namespace name (maximum 63 characters and DNS-label syntax). + +| `rules[].namespace` | Meaning | +|---|---| +| omitted | WatchRule's own namespace; legacy behavior | +| explicit name | One source namespace, through PR 4's three-part gate | +| `"*"` | All source namespaces that `GitTarget.allowedSourceNamespaces` admits | + +Any denied explicit name denies the whole WatchRule. The object reports +`SourceNamespaceAuthorized=False`, `Stalled=True`, and starts no streams; it must name the failing +rule item in its message. A wildcard narrows to the admitted set, which may be empty without being a +terminal refusal. + +The first release supports wildcard expansion against a **names-only** allow-list. An explicit +namespace may still use the selector behavior implemented by PR 4, but a wildcard with a selector +policy is rejected as unsupported until its dynamic invalidation and retaining contract receives a +dedicated follow-up. This keeps the initial wildcard set spec-derived and avoids shipping source +Namespace fan-out as a side effect of the API migration. + +## 3. Compile and invalidation model + +PR 4's current `CompiledRule.SourceNamespace string` and retained-grant map are single-namespace +representations. They cannot be reused verbatim. Replace them with an atomically replaced resolved +scope for the whole WatchRule, containing each item and its concrete namespace set. Because rule +items have no stable API identity, key it to the complete current rule spec rather than an index that +could survive a reorder incorrectly. + +`collectWatchRuleSelections` expands every matched resource record by that item's resolved namespace +set. The WatchRule fingerprint contains each item namespace and, for `"*"`, the resolved set. An +explicit-name edit or a target policy edit must rebuild the table; a stale selected namespace is a +silent incorrect watch. + +## 4. Deliberate capability removal + +ClusterWatchRule's cross-namespace target reference previously let a platform team author a +namespaced mirror without an object in the tenant namespace. This model removes that placement +capability. A platform administrator may still manage WatchRule manifests, but must place them 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. + +## 5. Migration and rollout + +This is a cross-kind migration; a conversion webhook cannot turn a ClusterWatchRule into a WatchRule. +Provide a dry-run migration command or documented preflight that, for each namespaced +ClusterWatchRule: + +1. identifies the target and selected resource rules; +2. requires an explicit compatible `allowedSourceNamespaces` policy; and +3. produces a namespaced WatchRule manifest only when the target policy preserves the intended scope. + +It must refuse a target without a policy. Legacy `scope: Namespaced` meant all source namespaces; +`namespace: "*"` with no policy admits none, so silently generating the replacement would narrow +production data. + +Release order: + +1. PR 5 is already released and every controller runs it. +2. Upgrade to this controller and wait until no PR-5 controller remains active before applying + migrated WatchRules or removing old ClusterWatchRules. +3. Run the preflight, add/verify policies, then apply the generated WatchRules and remove old rules. +4. Roll back only to PR 5 while migrated manifests exist. It understands `prune.mode: onEvent`, so a + scope collapse leaves stale Git rather than sweeping it. + +## Tests + +- Existing namespaced ClusterWatchRules are refused at bootstrap and reconcile; cluster-scoped rules + remain unaffected. +- ClusterWatchRule selection and fingerprint are cluster-scope-only. +- Omitted, explicit, and wildcard rule-item namespaces each generate the expected selections. +- A denied explicit item stops the entire WatchRule; an empty wildcard set does not stall it. +- A wildcard with no policy or a selector policy fails loudly; names-only policy expansion is exact. +- The resolved WatchRule scope changes its fingerprint and reprojects the table after every relevant + rule or policy edit. +- An end-to-end migration verifies that a target cannot receive objects from a namespace outside its + declared policy and that an empty policy change does not delete prior documents under PR 5's + `onEvent` mode. + +## Done when + +- The public `scope` enum and the top-level `sourceNamespace` field are gone from generated CRDs. +- Pre-existing namespaced ClusterWatchRules fail closed at bootstrap. +- The current PR-4 gate is reworked for rule-item scopes rather than shipped alongside it. +- The migration preflight and full validation suite pass. From 388f7949cd3d65a996ebbdd9d7de445f7b7f4bf0 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 21 Jul 2026 09:05:27 +0000 Subject: [PATCH 04/18] docs: redirect PR 4 to scope-by-kind and align field names Replace the rejected alternatives and the superseded PR 6 sketch with a single PR 4 design: ClusterWatchRule becomes cluster-scope-only and WatchRule gains a per-item rules[].sourceNamespace, with the selector-backed "*" wildcard bounded by GitTarget.spec.allowedSourceNamespaces. Rename the ClusterProvider delegation boolean to allowSourceNamespaceOverride. Once ClusterWatchRule has no source-namespace choice, the WatchRule prefix disambiguates nothing, and the field is unreleased so the rename needs no shim. Record the prior art: Flux's AccessFrom ACL independently lands on namespace labels, ORed selectors, deny-when-absent, empty-selector-matches-everything, and a typed AccessDeniedError distinct from "cannot evaluate". It never enumerates a namespace set from a selector, and upstream has been shrinking the field rather than growing it, which is why the wildcard's invalidation path is the half of this design with no precedent to lean on. Keep the shipped-but-unreleased top-level implementation as a baseline record so the reusable authorization, status, and reactivity work is not rebuilt. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/INDEX.md | 2 +- docs/TODO.md | 13 + .../watchrule-source-namespace/README.md | 98 +-- ...alt-clusterwatchrule-cluster-scope-only.md | 287 -------- .../alt-per-item-source-namespace.md | 246 ------- ...al-top-level-source-namespace-baseline.md} | 36 +- .../pr1-namespace-scoped-resync.md | 5 +- .../pr2-stream-scope-collapse.md | 7 +- .../pr4-cluster-scope-only.md | 696 ++++++++++++++++++ .../pr5-gittarget-deletion-safety.md | 25 +- .../pr6-cluster-scope-only.md | 150 ---- test/e2e/source_namespace_e2e_test.go | 3 +- 12 files changed, 807 insertions(+), 761 deletions(-) delete mode 100644 docs/design/watchrule-source-namespace/alt-clusterwatchrule-cluster-scope-only.md delete mode 100644 docs/design/watchrule-source-namespace/alt-per-item-source-namespace.md rename docs/design/watchrule-source-namespace/{pr4-source-namespace-field.md => historical-top-level-source-namespace-baseline.md} (96%) create mode 100644 docs/design/watchrule-source-namespace/pr4-cluster-scope-only.md delete mode 100644 docs/design/watchrule-source-namespace/pr6-cluster-scope-only.md 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/design/watchrule-source-namespace/README.md b/docs/design/watchrule-source-namespace/README.md index e195d9a8..b746cfaa 100644 --- a/docs/design/watchrule-source-namespace/README.md +++ b/docs/design/watchrule-source-namespace/README.md @@ -1,23 +1,23 @@ # Source-namespace addressing and per-target source scope -> **Design in six PRs.** PRs 1–3 are landed. PR 4 is implemented and green in an open pull request, -> but its top-level `sourceNamespace` API is superseded and must not be released. PR 5 establishes -> deletion safety as a separate rollback-floor release; PR 6 reshapes the unshipped PR-4 work and -> makes ClusterWatchRule cluster-only. Index: [INDEX.md](../../INDEX.md). +> **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. Index: [INDEX.md](../../INDEX.md). ## Decision The selected end state is the two-object model: -- **WatchRule** is the namespaced source-resource surface. `spec.rules[].namespace` is omitted for - the rule's own namespace, names one authorized source namespace, or is `"*"` for every namespace - its GitTarget admits. -- **ClusterWatchRule** is the cluster-scoped source-resource surface. It has no `scope` field. +- **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 6 and design a separate API for +If that placement is unacceptable for a deployment, stop before PR 4 and design a separate API for that capability. ## The model @@ -34,7 +34,7 @@ WatchRule ──uses──> GitTarget ──uses──> ClusterProvider - `GitTarget.spec.allowedSourceNamespaces` is a destination-owned, exhaustive allow-list for WatchRule source namespaces. A declared policy has no self-namespace exception. -- `ClusterProvider.spec.allowWatchRuleSourceNamespaceOverride` is the platform-admin delegation that +- `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`. @@ -49,7 +49,17 @@ that destination is therefore a GitTarget property, not a property of an individ 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. -The policy does not apply to ClusterWatchRule after PR 6, because a cluster-only rule has no namespace +### No self-namespace exception + +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. + +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. + +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. @@ -60,31 +70,32 @@ namespace and target policy, or recognize a ClusterWatchRule as intentionally cl | 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 | [Original sourceNamespace implementation](pr4-source-namespace-field.md) | Open, green implementation of the authorization model and source-scope service. Its top-level API is superseded; do not release it unchanged. | open, superseded API | -| 5 | [GitTarget deletion safety](pr5-gittarget-deletion-safety.md) | Add `prune.mode` and make resync sweep opt-in (`always`). Release independently as the rollback floor. | planned | -| 6 | [Scope by kind](pr6-cluster-scope-only.md) | Rework PR-4 authorization for `rules[].namespace`, remove ClusterWatchRule `scope`, refuse stored namespaced rules, and migrate cross-kind manifests. | planned, breaking | +| 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 | -## Release order and the open 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. -**Do not merge or release the open PR 4 unchanged just because it is green.** Its reviewed work is -valuable, but the only user-visible field it adds is known to be the wrong shape and the allow-list is -not a complete boundary while ClusterWatchRule can still select namespaced resources. +## Working and release order -Recommended handling: +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. Preserve the current PR 4 branch and test history as the implementation baseline for its gate, - conditions, bootstrap enforcement, source-scope snapshot, and reactivity wiring. -2. Land and release PR 5 from main independently. Upgrade every controller instance to it; this is - the minimum safe rollback version for the later API migration. -3. Close the present PR 4 as superseded, or retarget it explicitly as the PR-6 branch. Rebase or - cherry-pick its reusable implementation after PR 5, then replace the top-level field with rule-item - namespace resolution and remove ClusterWatchRule `scope` in the same PR. -4. Do not cut a release containing the original top-level `sourceNamespace` field. Release PR 6 only - after its migration preflight and stored-object refusal are complete. +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. -Merging the current PR 4 only as an unreleased development checkpoint is technically possible, but it -creates a dead public field and makes the eventual review harder. Closing or retargeting it is the -cleaner path. +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 @@ -101,23 +112,26 @@ later gain a non-breaking field such as `maxDeletesPerCommit`. ## Compatibility This is preliminary v1alpha3 API. PR 5 changes the default effective sweep behavior in the safe -direction. PR 6 is deliberately breaking: +direction. PR 4 is deliberately breaking: -- `WatchRule.spec.sourceNamespace` moves to `WatchRule.spec.rules[].namespace` before it has reached - a release. -- `ClusterResourceRule.scope` and the public `ResourceScope` enum are removed. +- The unshipped `WatchRule.spec.sourceNamespace` field is replaced with + `WatchRule.spec.rules[].sourceNamespace`; it never reaches a release. +- `ClusterResourceRule.scope` narrows to `Cluster` only. Both superseded fields stay in the schema for + one release as **loud rejections** 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 `namespace: "*"` requires an explicit target policy where legacy cluster rules did - not. + cross-kind and `sourceNamespace: "*"` requires an explicit target policy where legacy cluster rules + did not. -PR 6 supplies a dry-run migration preflight. It must fail rather than silently narrow a target that -has no compatible `allowedSourceNamespaces` policy. A controller rollback is supported only to the -released PR-5 version while PR-6 manifests exist; rolling back farther is unsupported. +There is no migration tool. The breaking change is carried by the release notes and +[UPGRADING.md](../../UPGRADING.md), which must state that a target with no declared policy admits +nothing — so converting without declaring `allowedSourceNamespaces` narrows what is mirrored. ## Deferred work -- Selector-backed `rules[].namespace: "*"` is deferred. The first wildcard release supports - names-only allow-lists; selector fan-out needs independent invalidation and retaining semantics. +- 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 diff --git a/docs/design/watchrule-source-namespace/alt-clusterwatchrule-cluster-scope-only.md b/docs/design/watchrule-source-namespace/alt-clusterwatchrule-cluster-scope-only.md deleted file mode 100644 index 7354eb99..00000000 --- a/docs/design/watchrule-source-namespace/alt-clusterwatchrule-cluster-scope-only.md +++ /dev/null @@ -1,287 +0,0 @@ -# Alternative — the two-object model: scope is carried by the kind, and the enum is removed - -> **Status: selected design rationale; implementation is split into -> [PR 5 deletion safety](pr5-gittarget-deletion-safety.md) and -> [PR 6 scope by kind](pr6-cluster-scope-only.md).** This is the crisp, breaking form of the -> WatchRule-side redesign, sibling to the non-breaking -> [alt-per-item-source-namespace.md](alt-per-item-source-namespace.md). Both share the same WatchRule -> interface (a per-rule-item `namespace`); they differ only on ClusterWatchRule, and that difference -> is the whole breaking/non-breaking axis this page exists to compare. -> References below to a “PR 5 runtime ceiling” are historical: that proposal was abandoned and must -> not be confused with the current PR 5 deletion-safety release. -> -> Supersedes this file's earlier "narrow the enum to `Cluster`" framing: narrowing left a vestigial -> single-value `scope: Cluster` field on every rule. The crisper move is to **remove the field -> entirely** — scope is redundant with discovery, so the kind can carry it. See -> [why remove, not narrow](#why-remove-the-enum-not-narrow-it). -> -> **PR 4 is being implemented as this is written, top-level singular.** The compiled rule already -> reads `SourceNamespace` at [watched_type_resolver.go:301](../../../internal/watch/watched_type_resolver.go#L301). -> This page is a delta against that; the [comparison](#comparing-the-approaches--breaking-change-consequences) -> is the point of the page. -> -> Code references verified against the tree on 2026-07-21. - -## The model - -Two objects, each monomorphic in scope, with scope carried by the **kind** rather than by a field: - -- **`WatchRule`** follows **namespaced** resources. Its `ResourceRule` list items gain a `namespace` - option — omitted means the rule's own namespace (legacy), `"*"` means every namespace the - GitTarget's `allowedSourceNamespaces` **admits**, an explicit name means that source namespace. This - is the interface designed in full in - [alt-per-item-source-namespace.md](alt-per-item-source-namespace.md); it is unchanged here. -- **`ClusterWatchRule`** follows **cluster-scoped** resources. Its `ClusterResourceRule` list items - **lose** the `Scope` field. Every rule is cluster-scoped, because that is what the kind means. - -The [`ResourceScope` enum](../../../api/v1alpha3/clusterwatchrule_types.go#L9-L19) is removed from the -API surface of both kinds — WatchRule never had it, ClusterWatchRule no longer needs it. (The internal -`typeset.Scope` used for discovery matching stays; only the user-facing spec field goes.) - -~~~yaml -# namespaced — WatchRule, per-item namespace -kind: WatchRule -metadata: { name: repo-config, namespace: tenant-acme } -spec: - targetRef: { name: acme } - rules: - - resources: [configmaps] # own namespace (legacy) - - resources: [secrets] - namespace: repo-config # one source namespace - - resources: [deployments] - namespace: "*" # every namespace the target's policy admits ---- -# cluster-scoped — ClusterWatchRule, no scope field at all -kind: ClusterWatchRule -metadata: { name: acme-crds } -spec: - targetRef: { name: acme, namespace: tenant-acme } - rules: - - resources: [customresourcedefinitions] # cluster-scoped, implicitly - apiGroups: [apiextensions.k8s.io] -~~~ - -The result is the sentence the whole folder was trying to make legible, now true by construction: - -> **ClusterWatchRule is the cluster-global surface. WatchRule is the namespaced surface, and every -> namespace it reaches is named in its spec and admitted by its GitTarget.** Which object watches what -> is answerable from the *kind*, never from an audit of a per-rule scope field. - -## Why remove the enum, not narrow it - -The [earlier revision of this page] narrowed `Scope` to a single legal value, `Cluster`. That leaves a -field every rule must still spell out (`scope: Cluster`) whose only legal value is the default — pure -boilerplate. Removal is crisper, and it is *safe*, because the field is **redundant with discovery**: - -- Discovery records already carry scope: [`TypeRecord.Identity.Scope`](../../../internal/typeset/model.go#L39) - is `Namespaced` / `ClusterScoped` / `Unknown`. -- [`matchFollowableRecords`](../../../internal/watch/watched_type_resolver.go#L342) already filters the - declared enum **against** that discovered truth via - [`matchesScope`](../../../internal/watch/watched_type_resolver.go#L397). - -So the operator never needed the user to tell it whether `configmaps` is namespaced — it knows. Under -the two-object model the *kind* supplies the scope filter (WatchRule keeps `ScopeNamespaced` records, -ClusterWatchRule keeps `ScopeCluster` records) and discovery supplies the fact. Nothing is lost by -deleting the field; a class of user error — declaring a scope that contradicts discovery — is deleted -with it. - -## Why PR 6 needs no runtime ceiling - -The abandoned runtime-ceiling proposal existed solely to make `allowedSourceNamespaces` bind -ClusterWatchRule's `scope: Namespaced` streams. If a ClusterWatchRule can only watch cluster-scoped -resources, a **namespace** allow-list is simply not applicable to it — exactly as the -[overview already concedes](README.md#what-the-ceiling-does-not-do) that the ceiling cannot partition -cluster-scoped objects. So the entire PR disappears, not just its API: - -| PR 5 mechanism | Fate under the two-object model | -|---|---| -| §1 expand `namespace: ""` per admitted namespace in `collectClusterWatchRuleSelections` | gone — ClusterWatchRule emits `""` for genuinely cluster-scoped streams, no expansion | -| §2 hash resolved scope into `clusterWatchRuleFingerprint` — state that **is not rule state** | gone — a ClusterWatchRule's scope is spec-derived again. This is the subtlest defect in the folder, and it evaporates | -| §2b "unknown is not empty" (the ClusterWatchRule half of the sweep hazard) | gone — nothing narrows on ClusterWatchRule | -| §3 GitTarget → ClusterWatchRules mapper + informer fan-out | gone | -| the release gate coupling PR 4 to PR 5 | gone — there is no second rule kind to leave unenforced | - -The dynamic-scope machinery is not *eliminated* — `WatchRule` still needs it for `"*"` against a -**selector** policy — but it is *consolidated onto one kind* instead of living on both. Under the -as-designed plan it exists twice: PR 4's selector `allowedSourceNamespaces` on WatchRule **and** PR 5's -ceiling on ClusterWatchRule. Here it exists once, on WatchRule, and only for rules that write `"*"`. - -## Comparing the approaches — breaking-change consequences - -Three approaches are now on the table. They agree on the WatchRule interface (per-item `namespace`, -`"*"` bounded by the allow-list); they differ **only** on ClusterWatchRule, and that is the entire -breaking/non-breaking split. - -| | **As-designed** (PR 4 + PR 5) | **Per-item, non-breaking** ([sibling](alt-per-item-source-namespace.md)) | **Two-object** (this page) | -|---|---|---|---| -| ClusterWatchRule namespaced watch | `scope: Namespaced`, bounded at runtime by PR 5 | `scope: Namespaced`, bounded at runtime by PR 5 | **removed** — use WatchRule `namespace: "*"` | -| Breaking change | no (additive) | no (additive) | **yes** | -| PR 5 runtime ceiling needed | yes | yes | **no — deleted** | -| §2 fingerprint-not-rule-state hazard | present (must be built) | present (must be built) | **absent** | -| Dynamic (`"*"`/selector) machinery | two kinds | two kinds | **one kind** (WatchRule) | -| Migration of existing objects | none | none | **manual, cross-kind** | -| Conversion webhook can automate it | n/a | n/a | **no** (cross-*kind*) | -| API legibility ("which object watches what") | audit each rule's scope | audit each rule's scope | **read the kind** | -| Enum boilerplate | `scope:` on every cluster rule | `scope:` on every cluster rule | **none** | - -The additive approaches cost **more permanent machinery**; the two-object approach costs a **one-time -breaking migration**. The consequences of that migration, in descending order of sharpness: - -### 1. No conversion webhook can migrate it — the move is cross-*kind* - -A conversion webhook translates one **kind** between API **versions**. Moving "watch namespaced -resources cluster-wide" from `ClusterWatchRule{scope: Namespaced}` to `WatchRule{namespace: "*"}` is a -move between **kinds**, which no conversion webhook can express. So migration is inherently -out-of-band: a one-shot migration tool, or a human editing manifests. The additive approaches need -**no** migration at all, because the old spelling keeps working. This is the single structural fact -that makes the two-object model "breaking" in a way pluralizing a field never is. - -### 2. Rollback is a data-plane hazard, not just a config revert - -This is the consequence most likely to be underweighted. After migrating a namespaced ClusterWatchRule -to a WatchRule with `namespace: "*"`, rolling the controller **back** to a version that predates the -per-item field means the old controller does not understand `namespace` — it silently watches the -WatchRule's **own** namespace only. That is a scope *collapse*, and a collapsed scope is the input to -the resync sweep ([PR 1](pr1-namespace-scoped-resync.md) is what makes the sweep namespace-safe, not -sweep-free). So a rollback can **delete a tenant's Git content** for every namespace the `"*"` used to -cover. The additive approaches roll back cleanly, because the old field is still present and still -understood. - -### 3. Rolling upgrade is non-atomic (HA skew) - -During an HA upgrade the old and new controllers run concurrently. The old one reads -`scope: Namespaced` and watches namespaced; the new one ignores/refuses it. A WatchRule written for the -new controller with `namespace: "*"` is invisible to the old one. So there is a window of divergent -behavior that no amount of care removes — the migration cannot be flipped atomically. The additive -approaches have no skew: both controller versions understand both spellings. - -### 4. Stored objects are reinterpreted silently unless explicitly refused - -CRD schema validation runs on **write**, not read. An existing `ClusterWatchRule{scope: Namespaced}` -stays stored and served after the field is removed from the schema; the controller simply stops reading -`scope` and now treats every rule as cluster-scoped. A rule selecting `configmaps` would resolve -against **cluster-scoped** discovery records, match nothing, and go quietly dead — or worse, a rule -selecting a name that exists in both scopes would flip meaning. **The field removal must be paired with -an explicit compile-path refusal** (see [next section](#removing-the-field-does-not-retract-stored-objects)). -The additive approaches never reinterpret anything. - -### 5. GitOps apply loops break loudly - -A user who manages the operator's own CRs through Flux/Argo has `ClusterWatchRule{scope: Namespaced}` -manifests in a Git repo. After the schema change their apply either fails validation (enum value gone) -or silently prunes the field, and their sync goes degraded until they rewrite the manifest as a -WatchRule. That is a visible outage of *their* reconciliation, triggered by upgrading the operator. The -additive approaches leave those manifests valid. - -### 6. Documentation, samples, e2e, and tests — mechanical but not zero - -Every `scope: Namespaced` in the tree changes, plus the docs sentence promising ClusterWatchRule -watches "namespaced resources across multiple namespaces". Surveyed in -[known breakage](#known-breakage). - -### What the breaking cost buys - -Set against all six: the additive approaches carry PR 5's runtime ceiling **forever** — including the -§2 fingerprint-not-rule-state hazard, which is the subtlest and most regression-prone thing in the -folder — and they answer "will this stream namespaces outside my allow-list?" only by audit. The -two-object model pays once, at a controlled upgrade, and thereafter the answer is the kind. Whether a -one-time cross-kind migration with a genuine rollback hazard is worth deleting a permanent class of -runtime machinery is the decision this page exists to frame. It is not obviously yes; it is a real -trade, and the rollback hazard (#2) is the reason it is not. - -## Removing the field does not retract stored objects - -The step most likely to be skipped, and it is a **security/correctness** step, not a cleanup — sharpened -from the enum-narrowing version because now the field is *gone*, not merely restricted: - -1. Remove the `Scope` field from the schema (blocks new namespaced cluster rules and blocks updates). -2. **In the single gated compile path** [PR 4 step 7](pr4-source-namespace-field.md#implementation-steps) - routes both the reconciler and `bootstrapRuleStore` through, refuse any `ClusterWatchRule` rule - whose resource resolves — **via discovery** — to a namespaced type, with a terminal condition naming - the migration ("watch namespaced resources with a WatchRule and `namespace`"). Discovery already - knows the scope ([`matchesScope`](../../../internal/watch/watched_type_resolver.go#L397)), so this - is a check the resolver can already make. - -Doing (1) without (2) is the exact defect this folder was written to remove: a scope decided in one -place and not enforced in another. The must-have test is `TestBootstrap_PreExistingNamespacedClusterRuleIsRefused` -— a stored ClusterWatchRule selecting a namespaced type is not compiled and starts no stream, asserted -at bootstrap before any reconcile. No manifest in the repo can create that object once the field is -gone, so the test is the only thing that can catch a regression in the refusal. - -## Capability A/B — the questions this model resolves by relocation - -The two capabilities `scope: Namespaced` uniquely expressed do not vanish; they move, and the move must -be deliberate: - -- **A — "watch this type in every namespace."** Relocates to WatchRule `namespace: "*"`, **bounded by - the GitTarget policy** — never "every namespace that exists". A tenant author cannot express - "follow the whole cluster"; the ceiling is the destination's. This is the safe form of A, and it is - why the model can ship without answering "should we allow live all-namespaces?" — the answer is - "yes, bounded, and its liveness equals the allow-list's". -- **B — platform-authored namespaced watching from *outside* the tenant namespace.** `WatchRule.targetRef` - is a `LocalTargetReference` with no namespace ([watchrule_types.go:24-42](../../../api/v1alpha3/watchrule_types.go#L24-L42)), - so the WatchRule must live in the namespace it watches from, authored by whoever can write there. - ClusterWatchRule's cross-namespace `targetRef` was the only way for a platform team to configure a - tenant's namespaced mirror without an object in the tenant namespace — **and the two-object model - removes that, with no replacement.** If a real deployment relies on B, this model is wrong for it, - or must add a WatchRule with a namespaced `targetRef` (itself a change). **This is the argument that - can defeat the whole page; a reviewer who needs B should say so.** - -## Known breakage - -From a repo-wide sweep on 2026-07-21 (excluding `external-sources/`): - -- [config/samples/clusterwatchrule.yaml](../../../config/samples/clusterwatchrule.yaml) — already - `scope: Cluster`; drop the now-removed field. -- [test/e2e/unsupported_folder_e2e_test.go:177](../../../test/e2e/unsupported_folder_e2e_test.go#L177) - — a `scope: Namespaced` ConfigMap ClusterWatchRule used only to exercise the refusal path; convert - to a WatchRule or a cluster-scoped rule. -- [docs/configuration.md](../../configuration.md) — "namespaced resources across multiple namespaces" - under `ClusterWatchRule` becomes false; the `ClusterWatchRule` example and the `scope` field docs go. -- The [`ResourceScope` enum + constants](../../../api/v1alpha3/clusterwatchrule_types.go#L9-L19) leave - the **API**; `typeset.Scope` and `matchesScope` stay (discovery still needs them). -- Unit tests across `internal/watch`, `internal/controller`, `internal/rulestore` that construct - `Namespaced` cluster rules — mechanical. - -This is a sweep, not a proof: a reviewer who believes a deployment depends on capability B above -outweighs all of it. - -## Test plan delta - -Relative to PR 4 and PR 5 as written: - -- **Deleted:** every PR 5 ceiling test — narrowing, sparing cluster-scoped rules, the - `clusterWatchRuleFingerprint` resolved-scope test, the table-rebuild-on-policy-change test, - retain-on-unknown, and the establishing/maintaining pair for ClusterWatchRule. The mechanisms they - guard no longer exist. -- **Added (the critical one):** `TestBootstrap_PreExistingNamespacedClusterRuleIsRefused` — §4 above. -- **Added:** admission/compile refuses a namespaced-resolving ClusterWatchRule rule, message naming the - WatchRule + `namespace` migration. -- **Inherited from the sibling doc:** the whole WatchRule per-item namespace test plan - ([alt-per-item § test plan delta](alt-per-item-source-namespace.md#test-plan-delta-relative-to-pr-4-as-written)), - including the partial-object deny-in-whole case and the `"*"` establishing/maintaining cases. -- **Kept:** PR 4's two must-have tests, the fingerprint/selection silent-failure guards, and the - Appendix-A Git-path e2e. - -## Open questions for the reviewer - -1. **Capability B** — must a platform team author namespaced watching for a tenant target from outside - the tenant namespace? If yes, the two-object model removes it with no replacement and should be - rejected or paired with a namespaced-`targetRef` WatchRule. -2. **Is the one-time cross-kind migration, with the rollback data-plane hazard (#2), an acceptable - price** for deleting PR 5's permanent machinery — on a preliminary v1alpha3 whose - [compatibility policy](README.md#compatibility) already accepts observable changes without - conversion? -3. **Migration tooling** — ship a one-shot converter (ClusterWatchRule `scope: Namespaced` → WatchRule - `namespace: "*"`), or document the manual rewrite? A converter cannot be a conversion webhook (#1), - so it is a standalone tool either way. -4. **Singular `namespace` vs plural `namespaces`** on the WatchRule rule item, and **name-only `"*"` - first vs selector-backed `"*"`** — both inherited from the - [sibling doc's open questions](alt-per-item-source-namespace.md#open-questions-for-the-reviewer); - the selector choice is what determines whether the one remaining dynamic machinery ships now or later. - -## What does not change - -PR 1, PR 2, PR 3 are unaffected and remain landed. `GitTarget.allowedSourceNamespaces`, the delegation -flag, the in-cluster sign-off argument, and the `IsLocalSource()` trap all stand verbatim. Git -placement still follows each mirrored object's own namespace, so the write path needs no change. diff --git a/docs/design/watchrule-source-namespace/alt-per-item-source-namespace.md b/docs/design/watchrule-source-namespace/alt-per-item-source-namespace.md deleted file mode 100644 index b73b4412..00000000 --- a/docs/design/watchrule-source-namespace/alt-per-item-source-namespace.md +++ /dev/null @@ -1,246 +0,0 @@ -# Alternative — a per-rule-item namespace, with `*` meaning "all *allowed* namespaces" - -> **Status: rejected alternative, retained for design history.** The selected plan is -> [PR 5 deletion safety](pr5-gittarget-deletion-safety.md) followed by -> [PR 6 scope by kind](pr6-cluster-scope-only.md). This alternative would keep -> `ClusterWatchRule.scope: Namespaced` and the former runtime-ceiling machinery; that machinery is -> abandoned. References below to the former “PR 5” describe that historical alternative, not the -> current deletion-safety PR number. -> -> **PR 4 is being implemented as this is written, top-level singular.** The compiled rule already -> carries `SourceNamespace` and [watched_type_resolver.go:301](../../../internal/watch/watched_type_resolver.go#L301) -> already reads it for the watch selection. So this page is a delta against work in flight, and the -> [migration section](#relationship-to-the-in-flight-pr-4) states exactly what it costs to adopt late. -> -> Code references verified against the tree on 2026-07-21. - -## The proposal - -Move the namespace from the WatchRule spec's top level onto each **rule list item** -([`ResourceRule`](../../../api/v1alpha3/watchrule_types.go#L83)), and give it the same three-valued -shape the other rule-item fields already have: - -~~~yaml -apiVersion: configbutler.ai/v1alpha3 -kind: WatchRule -metadata: - name: repo-config - namespace: tenant-acme -spec: - targetRef: - name: acme - rules: - - resources: [configmaps] # namespace omitted → tenant-acme (legacy, unchanged) - - resources: [secrets] - namespace: repo-config # one explicit source namespace - - resources: [deployments] - namespace: "*" # every namespace the GitTarget's policy ADMITS -~~~ - -Three meanings, mirroring how `resources`, `apiGroups`, and `apiVersions` already read on the same -item: - -| `namespace` on a rule item | Meaning | -|---|---| -| omitted | The WatchRule's own namespace. **Legacy behavior, byte-for-byte.** | -| `"*"` | Every namespace the GitTarget's `allowedSourceNamespaces` **admits** — *not* every namespace that exists. Bounded by policy, deny-by-default when no policy is declared. | -| an explicit name | That one source namespace, subject to the same three-part gate PR 4 already defines. | - -`GitTarget.spec.allowedSourceNamespaces` and the ClusterProvider delegation flag are unchanged, and -so is the authorization argument in [PR 4](pr4-source-namespace-field.md#what-the-delegation-flag-means). -This changes the *interface* to the gate, not the gate. - -> **Open: singular or plural per item.** `namespace: string` (repeat the item for a second namespace) -> or `namespaces: []string` (one item, several names). Singular keeps the shape identical to the -> current in-flight field and reads cleanest for the common one-namespace case; plural is terser for -> a hand-listed set. This page assumes **singular** and treats `"*"` as the way to say "many", -> because a hand-listed multi-namespace set is the rarer need and `"*"`-bounded-by-policy covers it. -> See [open questions](#open-questions-for-the-reviewer). - -## The load-bearing idea: `*` is bounded by the allow-list - -The reason this defers the two hard questions the -[cluster-scope-only alternative](alt-clusterwatchrule-cluster-scope-only.md#the-cost-scope-namespaced-is-the-only-expression-of-two-capabilities) -had to answer is a single semantic choice: - -> `namespace: "*"` resolves to the **admitted** set — exactly what -> `GitTarget.allowedSourceNamespaces` permits — never to the raw set of namespaces the source -> credential can read. - -That has three consequences worth stating separately: - -1. **"Follow the whole cluster" is never expressible by a WatchRule author.** A tenant writing `"*"` - gets what their target's policy admits and nothing more. The catastrophic fail-open reading — a - rule author quietly watching every namespace — is not a reachable state, because the ceiling is - the GitTarget's, set by whoever owns the destination. -2. **No policy declared ⇒ `"*"` admits nothing beyond the legacy own-namespace.** `"*"` is not a - backdoor around deny-by-default. With no `allowedSourceNamespaces` and the delegation flag false, - an explicit `"*"` is denied with the same terminal condition PR 4 gives a denied name — the - message naming the fix (declare a policy, set the flag). The omitted case still means own-namespace - and still needs neither. -3. **Capability A comes back without deleting capability B.** The - [cluster-scope-only page](alt-clusterwatchrule-cluster-scope-only.md#the-cost-scope-namespaced-is-the-only-expression-of-two-capabilities) - named two things `scope: Namespaced` uniquely expressed: (A) "watch this type in every namespace" - and (B) "platform-authored namespaced watching from outside the tenant namespace". `"*"` gives A, - bounded. B is untouched, because ClusterWatchRule and its cross-namespace `targetRef` remain. So - neither question has to be answered to ship this. - -## Honest accounting: this is additive, not a simplification - -The [cluster-scope-only alternative](alt-clusterwatchrule-cluster-scope-only.md) removed PR 5's -runtime ceiling, its fingerprint hazard, and its informer fan-out. **This proposal removes none of -that.** ClusterWatchRule keeps `scope: Namespaced`; the former runtime-ceiling proposal would ship; -and WatchRule *gains* a namespace-expansion path that looks a lot like that proposal's. - -So the end state has two ways to watch namespaced resources across namespaces: - -| | Authored by | Lives in | Ceiling | -|---|---|---|---| -| WatchRule, `namespace: "*"` or names | the tenant (targetRef is namespace-local) | the tenant's namespace | `GitTarget.allowedSourceNamespaces` | -| ClusterWatchRule, `scope: Namespaced` | the platform (targetRef is cross-namespace) | any namespace | `GitTarget.allowedSourceNamespaces` (PR 5) | - -They are not redundant — the authoring locality genuinely differs — but the machinery is doubled, not -shared-down. **The trade this page offers is: keep both capabilities and break nothing, at the cost of -more surface, versus the sibling page's cut both the surface and a capability, and break.** That is the -decision for review; neither is strictly better. - -| | [cluster-scope-only](alt-clusterwatchrule-cluster-scope-only.md) | **per-item `*` (this)** | top-level plural `sourceNamespaces` | -|---|---|---|---| -| Breaking change | yes (enum narrows) | no | no | -| Forces the A/B answers | yes | no | no | -| Effect on PR 5 | deletes its guts | keeps it | keeps it | -| Delta vs in-flight PR 4 | pluralize field | move field into rule item + expansion loop | `string` → `[]string` at one existing point | -| "all allowed" available | via WatchRule wildcard/selector | via `"*"` | via `"*"` | -| Per-type namespace granularity | no | **yes** | no | - -## What it costs inside PR 4 - -### 1. Partial authorization within one object - -Top-level singular authorizes a WatchRule as a unit: one effective namespace, one gate call, one -`SourceNamespaceAuthorized` condition. Per-item, the gate runs per `(rule item, namespace)`, so one -object can hold an admitted item and a denied item at once — a state the singular field cannot -produce. The `SourceNamespaceAuthorized` condition is per-object and cannot cleanly say "item 2 is -denied". Two semantics, and they must not be blended: - -- **A named namespace is *establishing*.** If any named namespace on any item is denied, the **whole - WatchRule** is `SourceNamespaceAuthorized=False`, `Stalled=True`, no streams — deny-in-whole. The - message names the offending item and namespace. A rule that silently watches two of the three - namespaces it asked for is the plural-specific failure this design must refuse; partial success is - worse than loud failure here. -- **`"*"` is *maintaining* / narrowing.** It never denies. It expands to the admitted set, which may be - empty, which is a correct outcome and **not** `Stalled` — the same rule - the former runtime-ceiling design stated for the ceiling. - -So a single WatchRule can carry both a *refusal* semantic (a denied name) and a *narrowing* semantic -(`"*"`). When both are present the refusal dominates the object-level condition. This is the existing -[establishing-versus-maintaining contract](pr4-source-namespace-field.md#establishing-versus-maintaining-a-scope) -applied within one object rather than across two rule kinds — implementable, but it is the part most -likely to be gotten subtly wrong, and it needs its own tests. - -### 2. `"*"` reintroduces the informer and the sweep hazard — but only opt-in - -A name-based item resolves statically: `watchRuleFingerprint` -([watched_type_resolver.go:478-482](../../../internal/watch/watched_type_resolver.go#L478-L482)) keeps -working from spec alone, no source-cluster Namespace informer needed. A `"*"` item against a -**selector** `allowedSourceNamespaces` makes the resolved set depend on source-cluster Namespace -labels — which is the maintaining/sweep hazard [PR 1](pr1-namespace-scoped-resync.md) landed first to -survive, and it drags PR 4's [source-scope service](pr4-source-namespace-field.md#the-source-scope-service--define-this-interface-before-writing-the-gate) -and the source-cluster Namespace informer onto the WatchRule path. - -The good news is that this is **opt-in per item**: a WatchRule that never writes `"*"` (or uses `"*"` -against a name-only policy) is fully static and needs none of it. So the dynamic machinery is paid for -only by the rules that ask for dynamism — which is the right place to pay it. A names-only `"*"` -policy is the recommended first cut; the selector-backed `"*"` can follow with the informer. - -### 3. The fingerprint must include the resolved set for `"*"` items - -Same shape as the former runtime-ceiling design's invalidation rule, now on WatchRule: for a `"*"` -item the resolved namespace set is **not rule state**, so a policy or -Namespace-label change that alters the set must still re-project the watched-type table. Hash the -resolved set into the rule's fingerprint (or carry a source-scope generation into the rebuild -trigger). A name-only item needs nothing new. The silent-failure test from PR 5 applies verbatim: an -unchanged rule object under a changed policy must re-project. - -### 4. Expansion in selection - -[`collectWatchRuleSelections`](../../../internal/watch/watched_type_resolver.go#L283-L307) currently -appends one selection per matched record at `namespace: rule.SourceNamespace`. Per-item, it appends -one selection **per (matched record × resolved namespace)** — the same expansion PR 5 adds to -`collectClusterWatchRuleSelections`, so the two collectors converge on one shape. Prefer expansion at -this site over filtering at the read site: an expanded selection carries the scope through the plan -hash, the informers, and the resync path for free, while -a read-site filter must be repeated at each and is silently wrong if one is missed. - -## Relationship to the in-flight PR 4 - -The delta depends on whether a release has shipped `sourceNamespace`: - -- **Before any release** — pure churn on a preliminary v1alpha3 API that - [README § compatibility](README.md#compatibility) already accepts without conversion. The field is - unset everywhere, so nothing migrates. The interface moves from `spec.sourceNamespace` (top level) - to `spec.rules[].namespace` (per item); the gate, the `SourceNamespaceAuthorized` condition, the - printer column, and the single gated compile path are all **reused as-is**, just called per item - instead of per object. -- **After a release** — pluralize/relocate at the next API version with conversion; do not ship the - singular field into a second release. - -The work in flight is not wasted either way. The parts that survive unchanged are the whole authorization -model and status contract; the parts that move are the field's location (spec → rule item) and the -compiled representation (`CompiledRule.SourceNamespace string` → a per-`ResourceRule` namespace plus an -expansion loop). That is a larger rework of the collector and the compiled rule than **top-level plural -`sourceNamespaces []string`** would be — plural keeps `SourceNamespace` on the compiled rule, just as a -slice, and expands at the one point that already exists. If minimizing disruption to the in-flight PR is -the priority, top-level plural delivers the same `"*"`=all-allowed insight at a smaller delta, losing -only the per-type granularity and the visual symmetry with the other rule-item fields. - -## What does NOT change - -- ClusterWatchRule keeps `scope: Namespaced`; [PR 3](pr3-clusterwatchrule-target-admission.md) and - the former runtime-ceiling proposal are unaffected. This proposal is orthogonal to them. -- `GitTarget.allowedSourceNamespaces`, the delegation flag, and the in-cluster sign-off argument stand - verbatim. -- Git placement still follows each mirrored object's **own** namespace, so the write path needs no - change — a rule item watching `repo-config` still writes under `repo-config/…` - ([PR 4 Appendix A](pr4-source-namespace-field.md#appendix-a-the-source-objects-namespace-already-names-the-git-folder)). -- The `namespace: ""` collapse rules ([PR 2](pr2-stream-scope-collapse.md)) are unaffected: an omitted - item namespace resolves to a concrete own-namespace, and `"*"` expands to concrete names — neither - emits a raw `""` key. Only ClusterWatchRule's cluster-scoped rules still emit `""`. - -## Test plan delta (relative to PR 4 as written) - -- **Kept:** the two must-have tests - ([PR 4 § the two tests that must exist](pr4-source-namespace-field.md#the-two-tests-that-must-exist)), - the fingerprint and selection silent-failure guards, and the Appendix-A Git-path e2e. -- **Changed to per-item:** the gate table becomes per `(item, namespace)`; add the **partial-object** - case explicitly — one item allowed, one item denied ⇒ whole rule `Stalled`, message names the denied - item. -- **Added — `"*"` semantics:** - - `"*"` with no policy and flag false ⇒ denied (deny-by-default), not "all namespaces". - - `"*"` against a name-only policy ⇒ expands to exactly those names, statically; fingerprint changes - when the policy changes though the rule object does not. - - `"*"` against a selector policy, source Namespace access denied, **scope already resolved** ⇒ - `Unknown` / `SourceNamespacePolicyUnavailable`, retained, **not** `Stalled`, **no sweep** — the - maintaining path. - - `"*"` against a selector policy that admits nothing ⇒ zero selections for that item, not a failure. -- **Added — mixed within one object:** a rule with one omitted item, one named item, and one `"*"` - item resolves each independently and the object condition is the correct aggregate (refusal dominates). - -## Open questions for the reviewer - -1. **Singular `namespace` or plural `namespaces` per item?** Singular + `"*"` is the smaller shape and - the assumption here. Plural adds a hand-listed multi-namespace set that `"*"`-bounded-by-policy - largely subsumes. -2. **`"*"` against a *selector* policy in v1, or name-only first?** Name-only defers the source-cluster - Namespace informer and the whole maintaining/sweep path entirely, exactly as the - [cluster-scope-only page](alt-clusterwatchrule-cluster-scope-only.md#names-only-plural-is-cheap-a-selector-is-not) - argues. Recommended: ship name-only `"*"`, add selector `"*"` with the informer as a follow-up. -3. **Additive vs subtractive.** This keeps ClusterWatchRule's namespaced scope and PR 5; the - [sibling page](alt-clusterwatchrule-cluster-scope-only.md) deletes both but is breaking and forces - the capability questions. Which cost is the right one for this project is the decision these two - pages exist to frame. -4. **Deny-in-whole for a denied named item** — confirm this over trim-to-admitted-subset. Trimming is - friendlier and is a silent narrowing, the failure class this whole folder exists to remove. -5. **Is per-type namespace granularity worth the partial-authorization surface?** If not, top-level - plural `sourceNamespaces` gives the `"*"` insight without per-item auth complexity, at a smaller - delta to the in-flight PR. 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 96% 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 46bab3c5..edd437d6 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,19 +1,21 @@ -# PR 4 (superseded implementation baseline) — the top-level sourceNamespace gate - -> Phase 4 of [source-namespace addressing](README.md). **Status: implemented and green in an open -> pull request; do not merge or release unchanged.** Its authorization model, conditions, -> bootstrap gate, source-scope snapshot, and reactivity work are the implementation baseline for -> [PR 6](pr6-cluster-scope-only.md). Its public top-level `sourceNamespace` field is superseded by -> `rules[].namespace` before reaching a release. +# 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 the reusable work. The selected release sequence is -> [PR 5 deletion safety](pr5-gittarget-deletion-safety.md), released first, followed by PR 6. Do not -> cut a release with the API described below. +> 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 6. +is retained by PR 4. ## Example @@ -82,7 +84,7 @@ so the source credential's RBAC remains the hard maximum. Set it only when the o GitTarget is trusted to choose a subset of what that credential may read. The flag gates **granting only**. In the selected design, `allowedSourceNamespaces` authorizes -WatchRule namespace selection; after PR 6 ClusterWatchRule is cluster-only and has no namespace to +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. @@ -205,7 +207,7 @@ Half of this is already wired: 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 6](pr6-cluster-scope-only.md) reworks this snapshot for WatchRule rule-item scopes. It does not +[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 @@ -254,7 +256,7 @@ 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. -PR 6 replaces the current one-namespace retained grant with an atomically replaced, whole-WatchRule +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. @@ -276,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. | A WatchRule already running under a selector-resolved scope. PR 6 defers selector-backed wildcards, but preserves this exact-name selector contract. | +| 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 defers selector-backed wildcards, but preserves this exact-name selector contract. | | 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`**. | @@ -522,8 +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. -- This document's top-level field is not released. Its code is carried forward only through the PR-6 - rework after PR 5 has established the rollback floor. +- 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 f9a16456..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) | The original implementation baseline proves source-namespace authorization and its gate. | -| [PR 6](pr6-cluster-scope-only.md) | Rule-item namespaces can expand one namespaced-resource selection into N concrete source namespaces. | +| [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 @@ -153,5 +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 6 introduces rule-item namespace expansion. It is carried as the retained-scope + 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 70a525e4..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's authorization implementation and PR 6's rule-item namespace expansion. +> **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,13 +29,14 @@ 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 6](pr6-cluster-scope-only.md) removes namespaced selections from ClusterWatchRule entirely and +[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. 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..2ea54ed1 --- /dev/null +++ b/docs/design/watchrule-source-namespace/pr4-cluster-scope-only.md @@ -0,0 +1,696 @@ +# 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. **Both superseded fields stay in the schema for one release as loud rejections** rather than being + deleted outright. Deleting a field makes a re-applied legacy manifest *silently pruned*; keeping it + makes the apply fail. 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/clusterwatchrule-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 | + +**Retire `WatchRule.spec.sourceNamespace` the same way as `scope`** +([watchrule_types.go:53-72](../../../api/v1alpha3/watchrule_types.go#L53-L72)), and for the same reason: +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 — a +silent scope change, the failure class this whole folder exists to remove. Keep the field for one +release and reject it structurally: + +~~~go +// +kubebuilder:validation:XValidation:rule="!has(self.sourceNamespace)",message="spec.sourceNamespace moved to spec.rules[].sourceNamespace" +~~~ + +and refuse a stored value in the compile path with the same message. The field never reached a +release, so the only manifests affected are pre-release ones — including this repo's own fixtures — +which is exactly the population that would otherwise fail silently in CI. + +**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 **nothing**, 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/clusterwatchrule-wildcard.tmpl](../../../test/e2e/templates/restart/clusterwatchrule-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`, or `spec.sourceNamespace`, is rejected by the API server (envtest against + the generated CRDs) — the guard that the fields were 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 and the top-level `sourceNamespace` are unreachable: rejected at admission, + refused at compile, ignored at resolution. +- `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/pr5-gittarget-deletion-safety.md b/docs/design/watchrule-source-namespace/pr5-gittarget-deletion-safety.md index 21e9cc4b..c17fefdf 100644 --- a/docs/design/watchrule-source-namespace/pr5-gittarget-deletion-safety.md +++ b/docs/design/watchrule-source-namespace/pr5-gittarget-deletion-safety.md @@ -1,9 +1,9 @@ # PR 5 — GitTarget deletion safety -> Phase 5 of [source-namespace addressing](README.md). This is an independently releasable -> **rollback-floor** release: it deliberately contains no source-namespace API change. It must be -> released, and all controller instances upgraded to it, before [PR 6](pr6-cluster-scope-only.md) -> introduces the breaking WatchRule and ClusterWatchRule API changes. +> 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. @@ -98,16 +98,19 @@ they want to model `always` or `onEvent`. ## Release and rollback -Release this PR by itself. Upgrade the CRD and controller, then wait until every controller pod runs -this version. That version is the rollback floor for PR 6: a rollback from PR 6 to PR 5 still -understands `prune.mode: onEvent`, so it cannot turn a newer namespace scope into a sweep. +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 PR 5 after PR-6 manifests have been applied: an older -controller neither understands `prune` nor the new rule-item namespace field. +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. -- The release has passed `task lint`, `task test`, and `task test-e2e` and has become the required - rollback floor before PR 6 starts its manifest migration. +- `task lint`, `task test`, and `task test-e2e` pass, closing the do-not-release window PR 4 opened. diff --git a/docs/design/watchrule-source-namespace/pr6-cluster-scope-only.md b/docs/design/watchrule-source-namespace/pr6-cluster-scope-only.md deleted file mode 100644 index eadc8ffc..00000000 --- a/docs/design/watchrule-source-namespace/pr6-cluster-scope-only.md +++ /dev/null @@ -1,150 +0,0 @@ -# PR 6 — scope by kind: cluster-only ClusterWatchRule and per-item WatchRule namespaces - -> Phase 6 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), [PR 3](pr3-clusterwatchrule-target-admission.md), and the -> released [PR 5 deletion-safety rollback floor](pr5-gittarget-deletion-safety.md). -> -> This PR supersedes the unshipped top-level `sourceNamespace` interface described by -> [PR 4](pr4-source-namespace-field.md). It reuses that PR's authorization, status, bootstrap-gate, -> and source-scope work, but does not release its API shape. - -## End state - -Scope is carried by the rule kind: - -- **WatchRule** selects namespaced source resources. Each `ResourceRule` has an optional - `namespace` field. -- **ClusterWatchRule** selects cluster-scoped source resources. It has no user-facing `scope` field. - -~~~yaml -kind: WatchRule -metadata: { name: repo-config, namespace: tenant-acme } -spec: - targetRef: { name: acme } - rules: - - resources: [configmaps] # own namespace - - resources: [secrets] - namespace: repo-config # one admitted source namespace - - resources: [deployments] - namespace: "*" # every namespace the target admits ---- -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 simple: a namespace allow-list applies to WatchRules; a -ClusterWatchRule is intentionally cluster-global and is bounded by its source credential's RBAC. - -## 1. ClusterWatchRule becomes cluster-only - -Remove `ResourceScope` and `ClusterResourceRule.scope` from the API. Discovery continues to know a -type's internal scope; the resolver always selects cluster-scoped records for ClusterWatchRule. Its -selections continue to use namespace `""`, now only for genuinely cluster-scoped streams. - -Removing the schema field is insufficient. Existing objects may be read after a schema change without -the old field available to controller code. In the single compile path shared by bootstrap and -reconcile, refuse any ClusterWatchRule selector that resolves to a namespaced type. The terminal -condition must say: - -> ClusterWatchRule is cluster-scoped only; watch namespaced resources with a WatchRule and -> `rules[].namespace`. - -The critical test is `TestBootstrap_PreExistingNamespacedClusterRuleIsRefused`: it must compile no -stream before the first reconciliation can publish status. - -This removes the abandoned runtime-ceiling design entirely: no ClusterWatchRule source-namespace -expansion, resolved-scope fingerprint, source-policy mapper, or selector-outage retention path is -implemented for that kind. - -## 2. WatchRule namespace moves onto ResourceRule - -Delete `WatchRule.spec.sourceNamespace`. Add `ResourceRule.namespace` with structural validation for -either `"*"` or a Kubernetes Namespace name (maximum 63 characters and DNS-label syntax). - -| `rules[].namespace` | Meaning | -|---|---| -| omitted | WatchRule's own namespace; legacy behavior | -| explicit name | One source namespace, through PR 4's three-part gate | -| `"*"` | All source namespaces that `GitTarget.allowedSourceNamespaces` admits | - -Any denied explicit name denies the whole WatchRule. The object reports -`SourceNamespaceAuthorized=False`, `Stalled=True`, and starts no streams; it must name the failing -rule item in its message. A wildcard narrows to the admitted set, which may be empty without being a -terminal refusal. - -The first release supports wildcard expansion against a **names-only** allow-list. An explicit -namespace may still use the selector behavior implemented by PR 4, but a wildcard with a selector -policy is rejected as unsupported until its dynamic invalidation and retaining contract receives a -dedicated follow-up. This keeps the initial wildcard set spec-derived and avoids shipping source -Namespace fan-out as a side effect of the API migration. - -## 3. Compile and invalidation model - -PR 4's current `CompiledRule.SourceNamespace string` and retained-grant map are single-namespace -representations. They cannot be reused verbatim. Replace them with an atomically replaced resolved -scope for the whole WatchRule, containing each item and its concrete namespace set. Because rule -items have no stable API identity, key it to the complete current rule spec rather than an index that -could survive a reorder incorrectly. - -`collectWatchRuleSelections` expands every matched resource record by that item's resolved namespace -set. The WatchRule fingerprint contains each item namespace and, for `"*"`, the resolved set. An -explicit-name edit or a target policy edit must rebuild the table; a stale selected namespace is a -silent incorrect watch. - -## 4. Deliberate capability removal - -ClusterWatchRule's cross-namespace target reference previously let a platform team author a -namespaced mirror without an object in the tenant namespace. This model removes that placement -capability. A platform administrator may still manage WatchRule manifests, but must place them 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. - -## 5. Migration and rollout - -This is a cross-kind migration; a conversion webhook cannot turn a ClusterWatchRule into a WatchRule. -Provide a dry-run migration command or documented preflight that, for each namespaced -ClusterWatchRule: - -1. identifies the target and selected resource rules; -2. requires an explicit compatible `allowedSourceNamespaces` policy; and -3. produces a namespaced WatchRule manifest only when the target policy preserves the intended scope. - -It must refuse a target without a policy. Legacy `scope: Namespaced` meant all source namespaces; -`namespace: "*"` with no policy admits none, so silently generating the replacement would narrow -production data. - -Release order: - -1. PR 5 is already released and every controller runs it. -2. Upgrade to this controller and wait until no PR-5 controller remains active before applying - migrated WatchRules or removing old ClusterWatchRules. -3. Run the preflight, add/verify policies, then apply the generated WatchRules and remove old rules. -4. Roll back only to PR 5 while migrated manifests exist. It understands `prune.mode: onEvent`, so a - scope collapse leaves stale Git rather than sweeping it. - -## Tests - -- Existing namespaced ClusterWatchRules are refused at bootstrap and reconcile; cluster-scoped rules - remain unaffected. -- ClusterWatchRule selection and fingerprint are cluster-scope-only. -- Omitted, explicit, and wildcard rule-item namespaces each generate the expected selections. -- A denied explicit item stops the entire WatchRule; an empty wildcard set does not stall it. -- A wildcard with no policy or a selector policy fails loudly; names-only policy expansion is exact. -- The resolved WatchRule scope changes its fingerprint and reprojects the table after every relevant - rule or policy edit. -- An end-to-end migration verifies that a target cannot receive objects from a namespace outside its - declared policy and that an empty policy change does not delete prior documents under PR 5's - `onEvent` mode. - -## Done when - -- The public `scope` enum and the top-level `sourceNamespace` field are gone from generated CRDs. -- Pre-existing namespaced ClusterWatchRules fail closed at bootstrap. -- The current PR-4 gate is reworked for rule-item scopes rather than shipped alongside it. -- The migration preflight and full validation suite pass. diff --git a/test/e2e/source_namespace_e2e_test.go b/test/e2e/source_namespace_e2e_test.go index 10383b3e..817a1db5 100644 --- a/test/e2e/source_namespace_e2e_test.go +++ b/test/e2e/source_namespace_e2e_test.go @@ -14,7 +14,8 @@ import ( ) // This spec covers WatchRule.spec.sourceNamespace end to end (see -// docs/design/watchrule-source-namespace/pr4-source-namespace-field.md). +// docs/design/watchrule-source-namespace/historical-top-level-source-namespace-baseline.md; the +// field moves onto spec.rules[] in 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 From 15fa3389cbef361dabacc357bc369be684c9e149 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 21 Jul 2026 09:59:29 +0000 Subject: [PATCH 05/18] feat(api)!: move sourceNamespace onto WatchRule rules[], narrow ClusterWatchRule scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope is now carried by the rule KIND. `WatchRule` selects namespaced resources and each `spec.rules[]` item names the source namespace it watches; `ClusterWatchRule` selects cluster-scoped resources and has no scope or namespace choice. PR 4 of docs/design/watchrule-source-namespace/. - `ResourceRule.sourceNamespace`: omitted (the rule's own namespace), an exact DNS-1123 name, or `"*"` for every namespace the GitTarget admits. Validated structurally so a malformed namespace is rejected at admission rather than resolving to nothing at compile time. - `EffectiveSourceNamespace()` / `OverridesSourceNamespace()` move down onto the item; `"*"` always counts as an override, so it needs the delegation flag even against a policy listing only the rule's own namespace. - `NamespaceMatcher.SelectorAdmits` exposes the selector half alone, for the enumeration a wildcard needs (a nil selector admits nothing; an empty one admits everything). - `ClusterProvider.spec.allowWatchRuleSourceNamespaceOverride` renamed to `allowSourceNamespaceOverride`. Unreleased, so a plain rename with no shim. Both superseded fields are RETAINED for one release as loud rejections, not deleted. Deleting is the silent option: CRD pruning happens on write, so a re-applied legacy manifest would be accepted with the value dropped and the rule would quietly change what it mirrors. - `WatchRule.spec.sourceNamespace` — rejected by an XValidation rule naming its replacement; `DeclaresRemovedSourceNamespace()` keeps a stored value readable. - `ClusterResourceRule.scope` — optional, defaults to `Cluster`, enum narrowed to `Cluster`; `DeclaresNamespacedScope()` keys the compile-time refusal on the STORED value, never on what the selector happens to resolve. BREAKING CHANGE: `ClusterWatchRule` is cluster-scope-only and `WatchRule.spec.sourceNamespace` moved to `spec.rules[].sourceNamespace`. A namespaced `ClusterWatchRule` becomes a `WatchRule` in the tenant namespace; its namespaced items become `sourceNamespace: "*"` (or explicit names). A target that declares no `allowedSourceNamespaces` admits NOTHING to a `"*"` item, so converting without also declaring that policy narrows production data. See docs/UPGRADING.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/v1alpha3/clusterprovider_types.go | 29 ++-- api/v1alpha3/clusterwatchrule_types.go | 84 +++++++--- api/v1alpha3/gittarget_types.go | 46 ++++-- api/v1alpha3/namespace_matcher.go | 21 +++ api/v1alpha3/namespace_matcher_test.go | 91 +++++++++-- api/v1alpha3/watchrule_types.go | 153 +++++++++++++----- .../configbutler.ai_clusterproviders.yaml | 19 +-- .../configbutler.ai_clusterwatchrules.yaml | 64 +++++--- .../crd/bases/configbutler.ai_gittargets.yaml | 46 ++++-- .../crd/bases/configbutler.ai_watchrules.yaml | 80 ++++++--- config/samples/clusterwatchrule.yaml | 3 +- 11 files changed, 455 insertions(+), 181 deletions(-) diff --git a/api/v1alpha3/clusterprovider_types.go b/api/v1alpha3/clusterprovider_types.go index ca33b915..af8db470 100644 --- a/api/v1alpha3/clusterprovider_types.go +++ b/api/v1alpha3/clusterprovider_types.go @@ -74,21 +74,22 @@ type ClusterProviderSpec struct { // +optional AllowedNamespaces *NamespaceMatcher `json:"allowedNamespaces,omitempty"` - // AllowWatchRuleSourceNamespaceOverride 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. + // 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. // - // The flag does not grant access by itself — an admitted GitTarget must still list the + // The flag does not grant access by itself — an admitted GitTarget must still admit the // namespace in spec.allowedSourceNamespaces. What it delegates is the AUTHORITY to choose: a // target owner may then configure a broad allow-list, including one matching every source // namespace, so the source credential's own RBAC remains the hard maximum. Set it only when // the owners of admitted GitTargets are trusted to pick a subset of what that credential // may read. // - // It gates GRANTING only. spec.allowedSourceNamespaces plays two roles — widening a WatchRule - // beyond its own namespace, and 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. + // It is required for EVERY cross-source-namespace request, including a + // rules[].sourceNamespace of "*": a wildcard expresses a request to follow the target's + // policy set, so a later policy edit could otherwise widen the watch with no platform-admin + // opt-in. It does not apply to ClusterWatchRule, which is cluster-scope-only and selects no + // namespaces at all. // // 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 @@ -104,7 +105,7 @@ type ClusterProviderSpec struct { // spec.kubeConfig, and neither that nor the provider's name decides this. Only this flag does. // +optional // +kubebuilder:default=false - AllowWatchRuleSourceNamespaceOverride bool `json:"allowWatchRuleSourceNamespaceOverride,omitempty"` + 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. @@ -213,11 +214,11 @@ func (p *ClusterProvider) AllowsNamespace(nsName string, nsLabels map[string]str return p.Spec.AllowedNamespaces.Matches(nsName, nsLabels) } -// AllowsWatchRuleSourceNamespaceOverride 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) AllowsWatchRuleSourceNamespaceOverride() bool { - return p.Spec.AllowWatchRuleSourceNamespaceOverride +// 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..ca02562f 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,44 @@ 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"` + // Scope is REMOVED as a choice: a ClusterWatchRule is cluster-scoped only, so the only + // accepted value is "Cluster" — which is also the default, making the field omittable. + // + // To watch NAMESPACED resources, use a WatchRule in the tenant namespace and set + // spec.rules[].sourceNamespace ("*" reaches every namespace the GitTarget admits). + // + // The field is retained in the schema for one release 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 with a message naming the replacement. + // + // 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. @@ -147,20 +181,28 @@ 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. +// ClusterWatchRule selects CLUSTER-SCOPED resources on the source cluster its GitTarget mirrors +// from. It is the cluster-scoped half of the two-object model: scope is carried by the rule KIND, +// so it has no per-rule scope choice and no source-namespace selection at all. // // 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 is cluster-scoped and requires cluster-admin permissions. +// - It references a GitTarget via targetRef (namespace required), and the referenced target's +// namespace must be admitted by that target's ClusterProvider. +// - 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. // // 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) +// +// To mirror NAMESPACED resources, use a WatchRule in the tenant namespace and set +// spec.rules[].sourceNamespace — "*" reaches every namespace the GitTarget's +// allowedSourceNamespaces admits, which replaces the removed cluster-wide namespaced scope. type ClusterWatchRule struct { metav1.TypeMeta `json:",inline"` diff --git a/api/v1alpha3/gittarget_types.go b/api/v1alpha3/gittarget_types.go index d3e8b884..767dd3a1 100644 --- a/api/v1alpha3/gittarget_types.go +++ b/api/v1alpha3/gittarget_types.go @@ -101,15 +101,29 @@ type GitTargetSpec struct { // AllowedSourceNamespaces bounds which SOURCE-cluster namespaces may be mirrored INTO this // target. It is a property of the DESTINATION, not of any requesting rule: when declared it is - // exhaustive for every rule that writes here, of every kind. + // exhaustive for every WatchRule that writes here. // // The invariant everything else serves — a declared policy is exhaustive: // - // | declared? | WatchRule | ClusterWatchRule (Namespaced rules) | - // | no | its own namespace only (legacy) | all source namespaces (legacy) | - // | yes | exactly what the policy admits | exactly what the policy admits | + // | declared? | a WatchRule item may watch | + // | no | the WatchRule's own namespace only (legacy) | + // | yes | exactly what the policy admits, and nothing else | // - // There is deliberately NO self-namespace exception. Once a policy is declared it must list + // It is also what a rules[].sourceNamespace of "*" resolves THROUGH: + // + // | policy | "*" resolves to | + // | undeclared | denied — deny-by-default; "*" is not a backdoor | + // | {} (declared, empty) | nothing | + // | names: [a, b] | exactly a and b, with no source-cluster access | + // | selector: {matchLabels: …} | every source namespace carrying those labels, live | + // | selector: {} | EVERY source namespace — the "all namespaces" form | + // + // The last row is the deliberate "all source namespaces" declaration, and it is the + // replacement for the removed cluster-wide namespaced ClusterWatchRule: declared by the + // destination owner rather than by the rule author, legible here, and self-updating as + // namespaces come and go. + // + // There is deliberately NO self-namespace exception. Once a policy is declared it must admit // every namespace that may reach this target, INCLUDING a co-resident legacy WatchRule's own // namespace. 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 @@ -120,16 +134,18 @@ type GitTargetSpec struct { // Its selector matches labels on Namespaces in the SOURCE cluster this target mirrors from — // not the control cluster ClusterProvider.allowedNamespaces describes. Evaluating it therefore // needs Namespace get/list/watch for the identity in that cluster's credential; an - // exact-NAMES entry stays usable without it, which is a deliberate degradation path. + // exact-NAMES entry stays usable without it, which is a deliberate degradation path that also + // keeps "*" resolvable against a names-only policy. // - // Empty or omitted are NOT the same: omitted declares no policy (each rule kind keeps its - // legacy scope), while a declared-but-empty policy admits nothing. Widening a WatchRule beyond - // its own namespace additionally requires the ClusterProvider to set - // spec.allowWatchRuleSourceNamespaceOverride; NARROWING never does. + // Empty or omitted are NOT the same: omitted declares no policy (a WatchRule keeps its own + // namespace), while a declared-but-empty policy admits nothing. Naming any namespace other + // than the WatchRule's own — including "*" — additionally requires the ClusterProvider to set + // spec.allowSourceNamespaceOverride. // - // Note that a namespace allow-list cannot partition CLUSTER-SCOPED objects, which have no - // namespace: a ClusterWatchRule selecting cluster-scoped types receives every such object the - // source credential can read, and this field is neither consulted nor a bound for them. + // It does NOT bound ClusterWatchRule. Cluster-scoped objects have no namespace, so a + // ClusterWatchRule is intentionally cluster-global and this field is neither consulted nor a + // bound for it; isolating cluster-scoped objects between tenants takes separate source + // credentials/ClusterProviders. // +optional AllowedSourceNamespaces *NamespaceMatcher `json:"allowedSourceNamespaces,omitempty"` } @@ -278,8 +294,8 @@ func (g *GitTarget) IsLocalSource() bool { } // DeclaresSourceNamespacePolicy reports whether this target declares spec.allowedSourceNamespaces -// at all. A declared policy is EXHAUSTIVE — it bounds every rule kind writing here, with no -// self-namespace exception — while an absent one leaves each rule kind its legacy scope. Callers +// 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() diff --git a/api/v1alpha3/namespace_matcher.go b/api/v1alpha3/namespace_matcher.go index bd617e10..f9bc0ab0 100644 --- a/api/v1alpha3/namespace_matcher.go +++ b/api/v1alpha3/namespace_matcher.go @@ -72,6 +72,27 @@ 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 diff --git a/api/v1alpha3/namespace_matcher_test.go b/api/v1alpha3/namespace_matcher_test.go index 637f15dd..9c8a584e 100644 --- a/api/v1alpha3/namespace_matcher_test.go +++ b/api/v1alpha3/namespace_matcher_test.go @@ -79,23 +79,88 @@ func TestNamespaceMatcher_InvalidSelectorIsAnError(t *testing.T) { require.Error(t, err) } -// TestWatchRule_EffectiveSourceNamespace pins the defaulting every consumer keys on. Getting this -// wrong produces a stale watch, not a visible failure. -func TestWatchRule_EffectiveSourceNamespace(t *testing.T) { - rule := &WatchRule{ObjectMeta: metav1.ObjectMeta{Name: "r", Namespace: "tenant-acme"}} +// 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") - assert.Equal(t, "tenant-acme", rule.EffectiveSourceNamespace(), "omitted means the rule's own") - assert.False(t, rule.OverridesSourceNamespace()) + admits, err = declared.SelectorAdmits(map[string]string{"anything": "at-all"}) + require.NoError(t, err) + assert.True(t, admits) - rule.Spec.SourceNamespace = "repo-config" - assert.Equal(t, "repo-config", rule.EffectiveSourceNamespace()) - assert.True(t, rule.OverridesSourceNamespace()) + 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. - rule.Spec.SourceNamespace = "tenant-acme" - assert.Equal(t, "tenant-acme", rule.EffectiveSourceNamespace()) - assert.False(t, rule.OverridesSourceNamespace(), + 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)) +} + +// TestWatchRule_DeclaresRemovedSourceNamespace covers the stored-object half of decision 10: the +// field is rejected at admission, but a pre-release object keeps its value in etcd and the compile +// path must still see it. +func TestWatchRule_DeclaresRemovedSourceNamespace(t *testing.T) { + rule := &WatchRule{ObjectMeta: metav1.ObjectMeta{Name: "r", Namespace: "tenant-acme"}} + assert.False(t, rule.DeclaresRemovedSourceNamespace()) + + rule.Spec.SourceNamespace = "repo-config" + assert.True(t, rule.DeclaresRemovedSourceNamespace(), + "a stored top-level sourceNamespace must be visible to Go, or the refusal has nothing to key on") +} + +// 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 @@ -120,6 +185,6 @@ func TestGitTarget_SourceNamespacePolicy(t *testing.T) { // must be false on a provider that never mentions it. func TestClusterProvider_DelegationFlagDefaultsClosed(t *testing.T) { provider := &ClusterProvider{} - assert.False(t, provider.AllowsWatchRuleSourceNamespaceOverride(), + 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 04b5a7c2..894666a8 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,38 +44,34 @@ type LocalTargetReference struct { } // WatchRuleSpec defines the desired state of WatchRule. -// WatchRule watches resources in ONE namespace of its GitTarget's source cluster: its own -// namespace by default, or the namespace spec.sourceNamespace names when authorized. +// 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. +// +kubebuilder:validation:XValidation:rule="!has(self.sourceNamespace)",message="spec.sourceNamespace moved to spec.rules[].sourceNamespace" type WatchRuleSpec struct { // TargetRef references the GitTarget to use. // Must be in the same namespace. // +required TargetRef LocalTargetReference `json:"targetRef"` - // SourceNamespace is the namespace to watch IN THE SOURCE CLUSTER the referenced GitTarget - // mirrors from. Omitted, it is this WatchRule's own namespace — the legacy behavior, which - // needs no authorization as long as the GitTarget declares no allowedSourceNamespaces policy. - // - // Naming a DIFFERENT namespace requires all three of: + // SourceNamespace is REMOVED. It moved to spec.rules[].sourceNamespace, so that the source + // namespace sits beside the resource selector it applies to. // - // 1. the GitTarget's namespace is admitted by its ClusterProvider (spec.allowedNamespaces); - // 2. that ClusterProvider sets spec.allowWatchRuleSourceNamespaceOverride; and - // 3. the GitTarget's spec.allowedSourceNamespaces admits this namespace. + // The field is retained in the schema for one release purely so that re-applying a manifest + // that still sets it FAILS. Deleting it outright would be worse and silent: CRD pruning happens + // on write, so an unrecognised top-level field is dropped without an error and the rule would + // quietly watch its own namespace instead of the one it asked for. The XValidation rule on + // spec rejects any value at admission, and the compile path refuses a stored one with the same + // message. // - // The outcome is published as the SourceNamespaceAuthorized condition. Once the GitTarget - // declares a policy that policy is exhaustive, so even an OMITTED sourceNamespace is then - // checked against it — the rule's own namespace gets no implicit carve-out. - // - // This changes only which namespace is WATCHED. It never changes where objects are written: - // Git placement follows each mirrored object's OWN namespace, so a rule in "tenant-acme" - // watching "repo-config" writes under repo-config/…, not tenant-acme/…. + // Deprecated: use spec.rules[].sourceNamespace. Removed one release from now, or at v1beta1. // +optional // +kubebuilder:validation:MinLength=1 SourceNamespace string `json:"sourceNamespace,omitempty"` - // 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"` @@ -137,6 +135,83 @@ type ResourceRule struct { // +kubebuilder:validation:items:MinLength=1 // +kubebuilder:validation:items:Pattern=`^[^/]*$` Resources []string `json:"resources"` + + // SourceNamespace is the namespace this item watches IN THE SOURCE CLUSTER the referenced + // GitTarget mirrors from. + // + // | value | meaning | + // | omitted | this WatchRule's own namespace — the legacy behavior | + // | an exact name | one source namespace, subject to the gate below | + // | "*" | every namespace GitTarget.spec.allowedSourceNamespaces admits, live | + // + // Naming a namespace OTHER than this WatchRule's own — including "*" — requires all three of: + // + // 1. the GitTarget's namespace is admitted by its ClusterProvider (spec.allowedNamespaces); + // 2. that ClusterProvider sets spec.allowSourceNamespaceOverride; and + // 3. the GitTarget's spec.allowedSourceNamespaces admits the namespace. + // + // "*" never means "every namespace that exists": it expands to exactly what the GitTarget's + // policy admits, so a target that declares no policy denies it. The outcome for all items is + // aggregated into the one SourceNamespaceAuthorized condition. A DENIED explicit name refuses + // the whole WatchRule rather than silently trimming the item; a "*" that admits nothing is + // valid, starts no stream for this item, and is reported as NoAdmittedSourceNamespaces. + // + // Once the GitTarget declares a policy that policy is exhaustive, so even an OMITTED + // sourceNamespace is then checked against it — the rule's own namespace gets no carve-out. + // + // This changes only which namespace is WATCHED. It never changes where objects are written: + // Git placement follows each mirrored object's OWN namespace, so a rule in "tenant-acme" + // watching "repo-config" writes under repo-config/…, not tenant-acme/…. + // + // Cost note: a "*" item opens one watch stream per (matched type × admitted namespace) and + // produces one resync scope each, rather than a single cluster-wide stream. That is deliberate — + // it keeps every replay scoped to one namespace — but it is a real fan-out on a broad policy. + // +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. @@ -199,13 +274,17 @@ type WatchRuleStreamsStatus struct { // +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 in ONE namespace of the source cluster its GitTarget -// mirrors from. 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. It is +// the namespaced half of the two-object model: scope is carried by the rule KIND, so a WatchRule +// never selects cluster-scoped types and a ClusterWatchRule never selects namespaced ones. +// +// It provides fine-grained control over which resources trigger Git commits, with filtering by +// operation type, API group, version, and source namespace. // // Security model: -// - WatchRule is namespace-scoped and watches its OWN namespace unless spec.sourceNamespace -// names another one AND that override passes the three-part gate described on that field — +// - Each spec.rules[] item watches its own source namespace: the WatchRule's OWN namespace when +// omitted, an explicit name, or "*" for every namespace the GitTarget admits. Anything other +// than its own namespace passes the three-part gate described on rules[].sourceNamespace — // provider admission, an explicit provider-side delegation flag, and the GitTarget's // allowedSourceNamespaces. The gate is deny-by-default and re-evaluated on every reconcile, // so a policy tightened later revokes a running rule. @@ -229,26 +308,12 @@ type WatchRule struct { Status WatchRuleStatus `json:"status,omitempty"` } -// EffectiveSourceNamespace is the source-cluster namespace this rule actually watches: -// spec.sourceNamespace when set, and the rule's OWN namespace otherwise. -// -// It is controller logic rather than an API-server default because an apiserver default cannot -// refer to metadata.namespace. Every consumer must call it instead of reading either field -// directly — the compiled rule, the watch selection, and the fingerprint that decides whether the -// watched-type table is re-projected all key on this value, and a site that reads -// metadata.namespace instead produces a STALE WATCH rather than a visible failure. -func (w *WatchRule) EffectiveSourceNamespace() string { - if w.Spec.SourceNamespace != "" { - return w.Spec.SourceNamespace - } - return w.Namespace -} - -// OverridesSourceNamespace reports whether this rule asks for a source namespace OTHER than its -// own — the case that needs the ClusterProvider's delegation flag. A spec.sourceNamespace that -// merely restates the rule's own namespace is not an override and is treated as the legacy case. -func (w *WatchRule) OverridesSourceNamespace() bool { - return w.EffectiveSourceNamespace() != w.Namespace +// DeclaresRemovedSourceNamespace reports whether a STORED WatchRule still carries the removed +// top-level spec.sourceNamespace. Admission rejects it, but an object written before this release +// keeps its value in etcd, so the compile path must refuse it too — otherwise the rule would +// silently watch its own namespace instead of the one it asked for. +func (w *WatchRule) DeclaresRemovedSourceNamespace() bool { + return w.Spec.SourceNamespace != "" } // +kubebuilder:object:root=true diff --git a/config/crd/bases/configbutler.ai_clusterproviders.yaml b/config/crd/bases/configbutler.ai_clusterproviders.yaml index faeba7b1..08302f6c 100644 --- a/config/crd/bases/configbutler.ai_clusterproviders.yaml +++ b/config/crd/bases/configbutler.ai_clusterproviders.yaml @@ -70,24 +70,25 @@ spec: spec: description: spec defines the desired state of ClusterProvider. properties: - allowWatchRuleSourceNamespaceOverride: + allowSourceNamespaceOverride: default: false description: |- - AllowWatchRuleSourceNamespaceOverride 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. + 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. - The flag does not grant access by itself — an admitted GitTarget must still list the + The flag does not grant access by itself — an admitted GitTarget must still admit the namespace in spec.allowedSourceNamespaces. What it delegates is the AUTHORITY to choose: a target owner may then configure a broad allow-list, including one matching every source namespace, so the source credential's own RBAC remains the hard maximum. Set it only when the owners of admitted GitTargets are trusted to pick a subset of what that credential may read. - It gates GRANTING only. spec.allowedSourceNamespaces plays two roles — widening a WatchRule - beyond its own namespace, and 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. + It is required for EVERY cross-source-namespace request, including a + rules[].sourceNamespace of "*": a wildcard expresses a request to follow the target's + policy set, so a later policy edit could otherwise widen the watch with no platform-admin + opt-in. It does not apply to ClusterWatchRule, which is cluster-scope-only and selects no + namespaces at all. 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 diff --git a/config/crd/bases/configbutler.ai_clusterwatchrules.yaml b/config/crd/bases/configbutler.ai_clusterwatchrules.yaml index ba75983d..93c635e3 100644 --- a/config/crd/bases/configbutler.ai_clusterwatchrules.yaml +++ b/config/crd/bases/configbutler.ai_clusterwatchrules.yaml @@ -42,20 +42,28 @@ 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. It is the cluster-scoped half of the two-object model: scope is carried by the rule KIND, + so it has no per-rule scope choice and no source-namespace selection at all. 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 is cluster-scoped and requires cluster-admin permissions. + - It references a GitTarget via targetRef (namespace required), and the referenced target's + namespace must be admitted by that target's ClusterProvider. + - 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. 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) + + To mirror NAMESPACED resources, use a WatchRule in the tenant namespace and set + spec.rules[].sourceNamespace — "*" reaches every namespace the GitTarget's + allowedSourceNamespaces admits, which replaces the removed cluster-wide namespaced scope. properties: apiVersion: description: |- @@ -79,14 +87,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 +158,30 @@ 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 the only + accepted value is "Cluster" — which is also the default, making the field omittable. + + To watch NAMESPACED resources, use a WatchRule in the tenant namespace and set + spec.rules[].sourceNamespace ("*" reaches every namespace the GitTarget admits). + + The field is retained in the schema for one release 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 with a message naming the replacement. + + 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 ec1d1fc3..1fd4ea93 100644 --- a/config/crd/bases/configbutler.ai_gittargets.yaml +++ b/config/crd/bases/configbutler.ai_gittargets.yaml @@ -97,14 +97,26 @@ spec: description: "AllowedSourceNamespaces bounds which SOURCE-cluster namespaces may be mirrored INTO this\ntarget. It is a property of the DESTINATION, not of any requesting rule: when declared it is\nexhaustive - for every rule that writes here, of every kind.\n\nThe invariant - everything else serves — a declared policy is exhaustive:\n\n\t| - declared? | WatchRule | ClusterWatchRule (Namespaced - rules) |\n\t| no | its own namespace only (legacy) | all - source namespaces (legacy) |\n\t| yes | exactly what - the policy admits | exactly what the policy admits |\n\nThere + for every WatchRule that writes here.\n\nThe invariant everything + else serves — a declared policy is exhaustive:\n\n\t| declared? + | a WatchRule item may watch |\n\t| no + \ | the WatchRule's own namespace only (legacy) |\n\t| + yes | exactly what the policy admits, and nothing else |\n\nIt + is also what a rules[].sourceNamespace of \"*\" resolves THROUGH:\n\n\t| + policy | \"*\" resolves to |\n\t| + undeclared | denied — deny-by-default; \"*\" is + not a backdoor |\n\t| {} (declared, empty) | nothing + \ |\n\t| names: [a, b] + \ | exactly a and b, with no source-cluster access + \ |\n\t| selector: {matchLabels: …} | every source namespace + carrying those labels, live |\n\t| selector: {} | + EVERY source namespace — the \"all namespaces\" form |\n\nThe last + row is the deliberate \"all source namespaces\" declaration, and + it is the\nreplacement for the removed cluster-wide namespaced ClusterWatchRule: + declared by the\ndestination owner rather than by the rule author, + legible here, and self-updating as\nnamespaces come and go.\n\nThere is deliberately NO self-namespace exception. Once a policy is declared - it must list\nevery namespace that may reach this target, INCLUDING + it must admit\nevery namespace that may reach this target, INCLUDING a co-resident legacy WatchRule's own\nnamespace. An implicit carve-out would mean the field does not actually bound what arrives\nhere, so a reader auditing it would be wrong about the target's contents @@ -116,16 +128,16 @@ spec: from —\nnot the control cluster ClusterProvider.allowedNamespaces describes. Evaluating it therefore\nneeds Namespace get/list/watch for the identity in that cluster's credential; an\nexact-NAMES entry - stays usable without it, which is a deliberate degradation path.\n\nEmpty - or omitted are NOT the same: omitted declares no policy (each rule - kind keeps its\nlegacy scope), while a declared-but-empty policy - admits nothing. Widening a WatchRule beyond\nits own namespace additionally - requires the ClusterProvider to set\nspec.allowWatchRuleSourceNamespaceOverride; - NARROWING never does.\n\nNote that a namespace allow-list cannot - partition CLUSTER-SCOPED objects, which have no\nnamespace: a ClusterWatchRule - selecting cluster-scoped types receives every such object the\nsource - credential can read, and this field is neither consulted nor a bound - for them." + stays usable without it, which is a deliberate degradation path + that also\nkeeps \"*\" resolvable against a names-only policy.\n\nEmpty + or omitted are NOT the same: omitted declares no policy (a WatchRule + keeps its own\nnamespace), while a declared-but-empty policy admits + nothing. Naming any namespace other\nthan the WatchRule's own — + including \"*\" — additionally requires the ClusterProvider to set\nspec.allowSourceNamespaceOverride.\n\nIt + does NOT bound ClusterWatchRule. Cluster-scoped objects have no + namespace, so a\nClusterWatchRule is intentionally cluster-global + and this field is neither consulted nor a\nbound for it; isolating + cluster-scoped objects between tenants takes separate source\ncredentials/ClusterProviders." properties: names: description: Names is an explicit allow-list of namespace names. diff --git a/config/crd/bases/configbutler.ai_watchrules.yaml b/config/crd/bases/configbutler.ai_watchrules.yaml index 005de540..7c2ce7b4 100644 --- a/config/crd/bases/configbutler.ai_watchrules.yaml +++ b/config/crd/bases/configbutler.ai_watchrules.yaml @@ -46,13 +46,17 @@ spec: schema: openAPIV3Schema: description: |- - WatchRule watches namespaced resources in ONE namespace of the source cluster its GitTarget - mirrors from. 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. It is + the namespaced half of the two-object model: scope is carried by the rule KIND, so a WatchRule + never selects cluster-scoped types and a ClusterWatchRule never selects namespaced ones. + + It provides fine-grained control over which resources trigger Git commits, with filtering by + operation type, API group, version, and source namespace. Security model: - - WatchRule is namespace-scoped and watches its OWN namespace unless spec.sourceNamespace - names another one AND that override passes the three-part gate described on that field — + - Each spec.rules[] item watches its own source namespace: the WatchRule's OWN namespace when + omitted, an explicit name, or "*" for every namespace the GitTarget admits. Anything other + than its own namespace passes the three-part gate described on rules[].sourceNamespace — provider admission, an explicit provider-side delegation flag, and the GitTarget's allowedSourceNamespaces. The gate is deny-by-default and re-evaluated on every reconcile, so a policy tightened later revokes a running rule. @@ -83,9 +87,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. @@ -162,6 +166,41 @@ spec: type: string minItems: 1 type: array + sourceNamespace: + description: "SourceNamespace is the namespace this item watches + IN THE SOURCE CLUSTER the referenced\nGitTarget mirrors from.\n\n\t| + value | meaning |\n\t| + omitted | this WatchRule's own namespace — the legacy + behavior |\n\t| an exact name | one source + namespace, subject to the gate below |\n\t| + \"*\" | every namespace GitTarget.spec.allowedSourceNamespaces + admits, live |\n\nNaming a namespace OTHER than this WatchRule's + own — including \"*\" — requires all three of:\n\n 1. the + GitTarget's namespace is admitted by its ClusterProvider (spec.allowedNamespaces);\n + 2. that ClusterProvider sets spec.allowSourceNamespaceOverride; + and\n 3. the GitTarget's spec.allowedSourceNamespaces admits + the namespace.\n\n\"*\" never means \"every namespace that + exists\": it expands to exactly what the GitTarget's\npolicy + admits, so a target that declares no policy denies it. The + outcome for all items is\naggregated into the one SourceNamespaceAuthorized + condition. A DENIED explicit name refuses\nthe whole WatchRule + rather than silently trimming the item; a \"*\" that admits + nothing is\nvalid, starts no stream for this item, and is + reported as NoAdmittedSourceNamespaces.\n\nOnce the GitTarget + declares a policy that policy is exhaustive, so even an OMITTED\nsourceNamespace + is then checked against it — the rule's own namespace gets + no carve-out.\n\nThis changes only which namespace is WATCHED. + It never changes where objects are written:\nGit placement + follows each mirrored object's OWN namespace, so a rule in + \"tenant-acme\"\nwatching \"repo-config\" writes under repo-config/…, + not tenant-acme/….\n\nCost note: a \"*\" item opens one watch + stream per (matched type × admitted namespace) and\nproduces + one resync scope each, rather than a single cluster-wide stream. + That is deliberate —\nit keeps every replay scoped to one + namespace — but it is a real fan-out on a broad policy." + maxLength: 63 + pattern: ^(\*|[a-z0-9]([-a-z0-9]*[a-z0-9])?)$ + type: string required: - resources type: object @@ -169,23 +208,17 @@ spec: type: array sourceNamespace: description: |- - SourceNamespace is the namespace to watch IN THE SOURCE CLUSTER the referenced GitTarget - mirrors from. Omitted, it is this WatchRule's own namespace — the legacy behavior, which - needs no authorization as long as the GitTarget declares no allowedSourceNamespaces policy. - - Naming a DIFFERENT namespace requires all three of: - - 1. the GitTarget's namespace is admitted by its ClusterProvider (spec.allowedNamespaces); - 2. that ClusterProvider sets spec.allowWatchRuleSourceNamespaceOverride; and - 3. the GitTarget's spec.allowedSourceNamespaces admits this namespace. + SourceNamespace is REMOVED. It moved to spec.rules[].sourceNamespace, so that the source + namespace sits beside the resource selector it applies to. - The outcome is published as the SourceNamespaceAuthorized condition. Once the GitTarget - declares a policy that policy is exhaustive, so even an OMITTED sourceNamespace is then - checked against it — the rule's own namespace gets no implicit carve-out. + The field is retained in the schema for one release purely so that re-applying a manifest + that still sets it FAILS. Deleting it outright would be worse and silent: CRD pruning happens + on write, so an unrecognised top-level field is dropped without an error and the rule would + quietly watch its own namespace instead of the one it asked for. The XValidation rule on + spec rejects any value at admission, and the compile path refuses a stored one with the same + message. - This changes only which namespace is WATCHED. It never changes where objects are written: - Git placement follows each mirrored object's OWN namespace, so a rule in "tenant-acme" - watching "repo-config" writes under repo-config/…, not tenant-acme/…. + Deprecated: use spec.rules[].sourceNamespace. Removed one release from now, or at v1beta1. minLength: 1 type: string targetRef: @@ -219,6 +252,9 @@ spec: - rules - targetRef type: object + x-kubernetes-validations: + - message: spec.sourceNamespace moved to spec.rules[].sourceNamespace + rule: '!has(self.sourceNamespace)' status: description: status defines the observed state of WatchRule properties: 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"] From 72d1a2c7f647cc639a46308a81d5741ad6cfdce2 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 21 Jul 2026 09:59:42 +0000 Subject: [PATCH 06/18] refactor(authz): resolve a WatchRule's whole per-item source scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `WatchRuleSourceNamespaceAdmitted` decided one namespace for a whole rule. Scope now lives on `spec.rules[]`, so the gate answers the whole rule at once: `ResolveWatchRuleSourceScope` returns a `ResolvedSourceScope` carrying one `SourceNamespaceDecision` per item (its index, what it requested, and the namespace set it resolved to). - The three-part ordering — GitTarget policy, then ClusterProvider delegation, then the rule's own namespace — is unchanged, factored into an `itemGate` that memoises the ClusterProvider verdict. The verdict is identical for every item, so it is fetched once per rule rather than once per item. - A `"*"` item enumerates: the policy's literal `names` are answered locally, the selector half is enumerated against the SOURCE cluster's namespaces, and the two are unioned. A selector that cannot be evaluated makes the whole item Unknown/Unavailable — never a partial set, because a partial set silently narrows what a rule mirrors. - `NoAdmittedSourceNamespaces` is a distinct True reason so a wildcard that resolves to nothing reports that it watches nothing instead of looking healthy. - `SourceNamespaceFieldRemoved` refuses a stored `spec.sourceNamespace` terminally. - `aggregateSourceScope` collapses the items with a stated precedence: denied beats unavailable beats unknown beats allowed. One denied item refuses the whole rule, and the message names which item. `Fingerprint()` renders the resolved sets so callers can key cached work on them. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/authz/source_namespace.go | 579 ++++++++++++++++++------ internal/authz/source_namespace_test.go | 429 ++++++++++++++---- 2 files changed, 788 insertions(+), 220 deletions(-) diff --git a/internal/authz/source_namespace.go b/internal/authz/source_namespace.go index dde7c598..0919c0f8 100644 --- a/internal/authz/source_namespace.go +++ b/internal/authz/source_namespace.go @@ -5,6 +5,8 @@ package authz import ( "context" "fmt" + "sort" + "strings" apierrors "k8s.io/apimachinery/pkg/api/errors" k8stypes "k8s.io/apimachinery/pkg/types" @@ -17,14 +19,22 @@ import ( // 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 the rule watches its OWN namespace and - // the GitTarget declares no allowedSourceNamespaces policy. No authorization was needed. + // 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 the effective source namespace passed - // the policy — including an own-namespace rule that a DECLARED policy explicitly admits. + // 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 @@ -43,6 +53,12 @@ const ( // 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" + + // ReasonSourceNamespaceFieldRemoved is the TERMINAL False reason for a STORED WatchRule that + // still carries the removed top-level spec.sourceNamespace. Admission rejects the field, but an + // object written before this release keeps its value in etcd; refusing it here is what stops + // the rule from silently watching its own namespace instead of the one it asked for. + ReasonSourceNamespaceFieldRemoved = "SourceNamespaceFieldRemoved" ) // SourceScopeVerdict is the THREE-valued answer a source-namespace policy evaluation produces. @@ -70,7 +86,7 @@ type SourceScopeResult struct { Message string } -// SourceNamespaceResolver evaluates a GitTarget's allowedSourceNamespaces against a namespace in +// 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 @@ -80,26 +96,48 @@ type SourceScopeResult struct { // 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 the outcome of the WatchRule source-namespace gate. +// SourceNamespaceDecision is one rule ITEM's source-namespace verdict, plus the concrete namespace +// set it resolved to. type SourceNamespaceDecision struct { - // Namespace is the effective source namespace the gate ruled on. - Namespace string + // 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. + // Reason is the SourceNamespaceAuthorized condition reason this item would produce. Reason string // Message explains the verdict to an operator. Message string } -// Admitted reports whether the rule may compile. +// 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 @@ -111,52 +149,158 @@ func (d SourceNamespaceDecision) Terminal() bool { return d.Verdict == SourceScopeDenied || d.Verdict == SourceScopeUnavailable } -// WatchRuleSourceNamespaceAdmitted is the WatchRule source-namespace gate: may this rule watch its -// effective source namespace in its GitTarget's source cluster? +// 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 ordering is the contract: +// 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 additionally requires the GitTarget's namespace to be admitted by its -// ClusterProvider, and that provider to set allowWatchRuleSourceNamespaceOverride. -// 3. Whenever a policy is declared it is EXHAUSTIVE — evaluated even for an own-namespace rule, +// 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 WatchRuleSourceNamespaceAdmitted( +func ResolveWatchRuleSourceScope( ctx context.Context, reader client.Reader, rule *configv1alpha3.WatchRule, target *configv1alpha3.GitTarget, resolver SourceNamespaceResolver, +) (ResolvedSourceScope, error) { + // A STORED object carrying the removed top-level field is refused before anything else: it + // asked for a namespace this controller no longer reads, and resolving the items as if it had + // not asked is precisely the silent scope change this design exists to remove. + if rule.DeclaresRemovedSourceNamespace() { + return removedFieldScope(rule), nil + } + + 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 +} + +// removedFieldScope is the terminal refusal for a stored spec.sourceNamespace. Reading the +// deprecated field is the entire point: it is retained precisely so a stored value stays visible to +// Go and can be refused instead of silently ignored. +func removedFieldScope(rule *configv1alpha3.WatchRule) ResolvedSourceScope { + msg := fmt.Sprintf( + "WatchRule %s/%s still sets spec.sourceNamespace (%q): that field moved to "+ + "spec.rules[].sourceNamespace; move the value onto the rule items it applies to", + rule.Namespace, rule.Name, rule.Spec.SourceNamespace) //nolint:staticcheck // deliberate: see above + return ResolvedSourceScope{ + Verdict: SourceScopeDenied, + Reason: ReasonSourceNamespaceFieldRemoved, + Message: msg, + } +} + +// 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) { - effective := rule.EffectiveSourceNamespace() + 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 !rule.OverridesSourceNamespace() && !target.DeclaresSourceNamespacePolicy() { - return SourceNamespaceDecision{ - Namespace: effective, - Verdict: SourceScopeAdmitted, - Reason: ReasonLegacySourceNamespace, - Message: fmt.Sprintf( - "watching this WatchRule's own namespace %q; the GitTarget declares no "+ - "allowedSourceNamespaces policy, so no authorization is required", - effective), - }, nil + 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) An override needs provider admission of the target AND the explicit delegation. - if rule.OverridesSourceNamespace() { - refusal, refused, err := overrideDelegated(ctx, reader, rule, target, effective) + // (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 } @@ -166,164 +310,329 @@ func WatchRuleSourceNamespaceAdmitted( } // (3) A declared policy is exhaustive; an override against no policy is denied by default. - if !target.DeclaresSourceNamespacePolicy() { - return SourceNamespaceDecision{ - Namespace: effective, - Verdict: SourceScopeDenied, - Reason: ReasonSourceNamespaceNotAllowed, - Message: fmt.Sprintf( - "GitTarget %s/%s declares no spec.allowedSourceNamespaces, so no source namespace "+ - "other than a rule's own may be mirrored into it; add %q to that policy", - target.Namespace, target.Name, effective), - }, nil + 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 } - return evaluatePolicy(ctx, rule, target, effective, resolver), 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 to an override: the provider -// must admit the GitTarget's own namespace, and it must set the delegation flag. +// 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, 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 overrideDelegated( +// 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, - reader client.Reader, - rule *configv1alpha3.WatchRule, - target *configv1alpha3.GitTarget, - effective string, + index int, + item *configv1alpha3.ResourceRule, ) (SourceNamespaceDecision, bool, error) { - providerName := target.SourceCluster() + 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 := reader.Get(ctx, k8stypes.NamespacedName{Name: providerName}, &provider); err != nil { + if err := g.reader.Get(ctx, k8stypes.NamespacedName{Name: providerName}, &provider); err != nil { if apierrors.IsNotFound(err) { - return SourceNamespaceDecision{ - Namespace: effective, - Verdict: SourceScopeDenied, - Reason: ReasonSourceNamespaceNotAllowed, + 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.allowWatchRuleSourceNamespaceOverride", + "existing provider that sets spec.allowSourceNamespaceOverride", providerName), - }, true, nil + }, nil } // Transient: requeue rather than tear down a running stream. - return SourceNamespaceDecision{}, false, - fmt.Errorf("read ClusterProvider %q: %w", providerName, err) + 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, reader, target) + admitted, err := GitTargetAdmitted(ctx, g.reader, g.target) if err != nil { - return SourceNamespaceDecision{}, false, err + return nil, err } if !admitted.Allowed { - return SourceNamespaceDecision{ - Namespace: effective, - Verdict: SourceScopeDenied, - Reason: ReasonSourceNamespaceNotAllowed, + return &SourceNamespaceDecision{ + Verdict: SourceScopeDenied, + Reason: ReasonSourceNamespaceNotAllowed, Message: fmt.Sprintf( "GitTarget %s/%s may not mirror through ClusterProvider %q at all: %s", - target.Namespace, target.Name, providerName, admitted.Message), - }, true, nil + g.target.Namespace, g.target.Name, providerName, admitted.Message), + }, nil } - if !provider.AllowsWatchRuleSourceNamespaceOverride() { - return SourceNamespaceDecision{ - Namespace: effective, - Verdict: SourceScopeDenied, - Reason: ReasonSourceNamespaceNotAllowed, + if !provider.AllowsSourceNamespaceOverride() { + return &SourceNamespaceDecision{ + Verdict: SourceScopeDenied, + Reason: ReasonSourceNamespaceNotAllowed, Message: fmt.Sprintf( - "WatchRule %s/%s requests source namespace %q, but ClusterProvider %q does not set "+ - "spec.allowWatchRuleSourceNamespaceOverride; a WatchRule may watch only its own "+ - "namespace %q until a platform admin delegates that choice", - rule.Namespace, rule.Name, effective, providerName, rule.Namespace), - }, true, nil + "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 SourceNamespaceDecision{}, false, nil + return nil, nil //nolint:nilnil // nil refusal means "the provider side permits"; see the godoc. } -// evaluatePolicy runs the GitTarget's declared allowedSourceNamespaces through the resolver and -// maps its three-valued answer onto the condition's reasons. -func evaluatePolicy( +// 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, - rule *configv1alpha3.WatchRule, - target *configv1alpha3.GitTarget, - effective string, - resolver SourceNamespaceResolver, + index int, + item *configv1alpha3.ResourceRule, + base SourceNamespaceDecision, ) SourceNamespaceDecision { - result := resolveWith(ctx, resolver, target, effective) + candidate := item.EffectiveSourceNamespace(g.rule.Namespace) + result := resolveWith(ctx, g.resolver, g.target, candidate) + label := g.describeItem(index, item) switch result.Verdict { case SourceScopeAdmitted: - return SourceNamespaceDecision{ - Namespace: effective, - Verdict: SourceScopeAdmitted, - Reason: ReasonSourceNamespaceAllowed, - Message: fmt.Sprintf( - "source namespace %q is admitted by GitTarget %s/%s spec.allowedSourceNamespaces", - effective, target.Namespace, target.Name), - } + 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: - return SourceNamespaceDecision{ - Namespace: effective, - Verdict: SourceScopeDenied, - Reason: ReasonSourceNamespaceNotAllowed, - Message: deniedMessage(rule, target, effective, result.Message), - } + base.Verdict = SourceScopeDenied + base.Reason = ReasonSourceNamespaceNotAllowed + base.Message = label + ": " + g.deniedMessage(item, candidate, result.Message) case SourceScopeUnavailable: - return SourceNamespaceDecision{ - Namespace: effective, - Verdict: SourceScopeUnavailable, - Reason: ReasonSourceNamespacePolicyUnavailable, - Message: fmt.Sprintf( - "GitTarget %s/%s spec.allowedSourceNamespaces cannot be evaluated for %q: %s", - target.Namespace, target.Name, effective, result.Message), - } + 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: - return SourceNamespaceDecision{ - Namespace: effective, - Verdict: SourceScopeUnknown, - Reason: ReasonCheckingSourceNamespacePolicy, - Message: fmt.Sprintf( - "still establishing whether source namespace %q is admitted by GitTarget %s/%s: %s", - effective, target.Namespace, target.Name, result.Message), + 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 override silently denies a co-resident LEGACY rule +// 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 deniedMessage( - rule *configv1alpha3.WatchRule, - target *configv1alpha3.GitTarget, - effective string, - detail string, -) string { - if !rule.OverridesSourceNamespace() { +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)", - effective, target.Namespace, target.Name) + 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", - effective, target.Namespace, target.Name) + 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 @@ -357,3 +666,19 @@ func resolveWith( } 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 index 42addc85..69403a50 100644 --- a/internal/authz/source_namespace_test.go +++ b/internal/authz/source_namespace_test.go @@ -27,6 +27,7 @@ const ( snTargetName = "acme" snRuleName = "repo-config-rule" snProvider = "workspaces" + snWildcard = configv1alpha3.SourceNamespaceWildcard ) func snScheme(t *testing.T) *runtime.Scheme { @@ -50,13 +51,19 @@ func snTarget(policy *configv1alpha3.NamespaceMatcher) *configv1alpha3.GitTarget } } -func snRule(sourceNamespace string) *configv1alpha3.WatchRule { +// 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}, - SourceNamespace: sourceNamespace, - Rules: []configv1alpha3.ResourceRule{{Resources: []string{"configmaps"}}}, + TargetRef: configv1alpha3.LocalTargetReference{Name: snTargetName}, + Rules: items, }, } } @@ -67,8 +74,8 @@ func snClusterProvider(delegate bool) *configv1alpha3.ClusterProvider { return &configv1alpha3.ClusterProvider{ ObjectMeta: metav1.ObjectMeta{Name: snProvider}, Spec: configv1alpha3.ClusterProviderSpec{ - AllowedNamespaces: &configv1alpha3.NamespaceMatcher{Names: []string{snTenantNS}}, - AllowWatchRuleSourceNamespaceOverride: delegate, + AllowedNamespaces: &configv1alpha3.NamespaceMatcher{Names: []string{snTenantNS}}, + AllowSourceNamespaceOverride: delegate, }, } } @@ -77,8 +84,11 @@ func snClusterProvider(delegate bool) *configv1alpha3.ClusterProvider { // 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 - calls int + result authz.SourceScopeResult + enumerated []string + enumeration authz.SourceScopeResult + calls int + enumCalls int } func (s *stubResolver) ResolveSourceNamespace( @@ -88,6 +98,13 @@ func (s *stubResolver) ResolveSourceNamespace( 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}} } @@ -96,10 +113,42 @@ func denying() *stubResolver { return &stubResolver{result: authz.SourceScopeResult{Verdict: authz.SourceScopeDenied}} } -// TestWatchRuleSourceNamespaceAdmitted is the gate's truth table, modelled on +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 TestWatchRuleSourceNamespaceAdmitted(t *testing.T) { +func TestResolveWatchRuleSourceScope(t *testing.T) { labelled := map[string]string{"gitops.configbutler.ai/mirrorable": "true"} selectorPolicy := &configv1alpha3.NamespaceMatcher{ Selector: &metav1.LabelSelector{MatchLabels: labelled}, @@ -107,21 +156,23 @@ func TestWatchRuleSourceNamespaceAdmitted(t *testing.T) { tests := []struct { name string - // sourceNamespace is WatchRule.spec.sourceNamespace ("" = omitted). + // 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. A rule that omits sourceNamespace must pass with no policy and no flag. + // 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, @@ -129,6 +180,7 @@ func TestWatchRuleSourceNamespaceAdmitted(t *testing.T) { 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. @@ -145,6 +197,7 @@ func TestWatchRuleSourceNamespaceAdmitted(t *testing.T) { delegate: false, wantVerdict: authz.SourceScopeAdmitted, wantReason: authz.ReasonSourceNamespaceAllowed, + wantNamespaces: []string{snTenantNS}, }, { name: "differs, flag false: denied even though the target names it", sourceNamespace: snSourceNS, @@ -173,6 +226,7 @@ func TestWatchRuleSourceNamespaceAdmitted(t *testing.T) { delegate: true, wantVerdict: authz.SourceScopeAdmitted, wantReason: authz.ReasonSourceNamespaceAllowed, + wantNamespaces: []string{snSourceNS}, }, { name: "differs, flag true, target selector matches: allowed", sourceNamespace: snSourceNS, @@ -181,6 +235,7 @@ func TestWatchRuleSourceNamespaceAdmitted(t *testing.T) { resolver: admitting(), wantVerdict: authz.SourceScopeAdmitted, wantReason: authz.ReasonSourceNamespaceAllowed, + wantNamespaces: []string{snSourceNS}, }, { name: "differs, flag true, target names a DIFFERENT namespace: denied", sourceNamespace: snSourceNS, @@ -217,46 +272,197 @@ func TestWatchRuleSourceNamespaceAdmitted(t *testing.T) { }}, 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) { - target := snTarget(tc.policy) - cl := fake.NewClientBuilder(). - WithScheme(snScheme(t)). - WithObjects( - target, - snClusterProvider(tc.delegate), - &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snTenantNS}}, - ). - Build() - var resolver authz.SourceNamespaceResolver if tc.resolver != nil { resolver = tc.resolver } - decision, err := authz.WatchRuleSourceNamespaceAdmitted( - context.Background(), cl, snRule(tc.sourceNamespace), target, resolver) + resolved := resolveOne(t, tc.sourceNamespace, tc.policy, tc.delegate, resolver) - require.NoError(t, err) - assert.Equal(t, tc.wantVerdict, decision.Verdict, "verdict (message: %s)", decision.Message) - assert.Equal(t, tc.wantReason, decision.Reason) - assert.NotEmpty(t, decision.Message, "every verdict must carry an operator-legible message") - - wantNS := tc.sourceNamespace - if wantNS == "" { - wantNS = snTenantNS + 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)) } - assert.Equal(t, wantNS, decision.Namespace) }) } } -// TestWatchRuleSourceNamespaceAdmitted_TargetIsolation is the multi-tenant invariant: a GitTarget's +// 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_StoredTopLevelFieldIsRefused covers decision 10's stored-object +// half: admission rejects spec.sourceNamespace, but a pre-release object keeps its value in etcd and +// resolving the items as if it had not asked would silently watch the wrong namespace. +func TestResolveWatchRuleSourceScope_StoredTopLevelFieldIsRefused(t *testing.T) { + target := snTarget(nil) + cl := fake.NewClientBuilder().WithScheme(snScheme(t)). + WithObjects(target, snClusterProvider(true)).Build() + + rule := snRule("") + rule.Spec.SourceNamespace = snSourceNS //nolint:staticcheck // the point of the test + + resolved, err := authz.ResolveWatchRuleSourceScope(context.Background(), cl, rule, target, nil) + + require.NoError(t, err) + assert.Equal(t, authz.SourceScopeDenied, resolved.Verdict) + assert.Equal(t, authz.ReasonSourceNamespaceFieldRemoved, resolved.Reason) + assert.Contains(t, resolved.Message, "spec.rules[].sourceNamespace", + "the refusal must name the replacement, because the move is not automatic") + assert.Empty(t, resolved.Items, "a refused rule resolves no items") +} + +// 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 TestWatchRuleSourceNamespaceAdmitted_TargetIsolation(t *testing.T) { +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"}}) @@ -266,19 +472,19 @@ func TestWatchRuleSourceNamespaceAdmitted_TargetIsolation(t *testing.T) { &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snTenantNS}}). Build() - decision, err := authz.WatchRuleSourceNamespaceAdmitted( + resolved, err := authz.ResolveWatchRuleSourceScope( context.Background(), cl, snRule("shared"), acme, nil) require.NoError(t, err) - assert.Equal(t, authz.SourceScopeDenied, decision.Verdict, + assert.Equal(t, authz.SourceScopeDenied, resolved.Verdict, "another target's policy must never widen this one") - assert.Equal(t, authz.ReasonSourceNamespaceNotAllowed, decision.Reason) + assert.Equal(t, authz.ReasonSourceNamespaceNotAllowed, resolved.Reason) } -// TestWatchRuleSourceNamespaceAdmitted_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 TestWatchRuleSourceNamespaceAdmitted_UnadmittedGitTargetCannotDelegate(t *testing.T) { +// 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"}} @@ -289,33 +495,33 @@ func TestWatchRuleSourceNamespaceAdmitted_UnadmittedGitTargetCannotDelegate(t *t &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snTenantNS}}). Build() - decision, err := authz.WatchRuleSourceNamespaceAdmitted( + resolved, err := authz.ResolveWatchRuleSourceScope( context.Background(), cl, snRule(snSourceNS), target, nil) require.NoError(t, err) - assert.Equal(t, authz.SourceScopeDenied, decision.Verdict) - assert.Contains(t, decision.Message, "may not mirror through ClusterProvider") + assert.Equal(t, authz.SourceScopeDenied, resolved.Verdict) + assert.Contains(t, resolved.Message, "may not mirror through ClusterProvider") } -// TestWatchRuleSourceNamespaceAdmitted_MissingClusterProviderDeniesOverride: an absent provider -// delegates nothing. It must not be an implicit allow. -func TestWatchRuleSourceNamespaceAdmitted_MissingClusterProviderDeniesOverride(t *testing.T) { +// 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() - decision, err := authz.WatchRuleSourceNamespaceAdmitted( + resolved, err := authz.ResolveWatchRuleSourceScope( context.Background(), cl, snRule(snSourceNS), target, nil) require.NoError(t, err) - assert.Equal(t, authz.SourceScopeDenied, decision.Verdict) - assert.Equal(t, authz.ReasonSourceNamespaceNotAllowed, decision.Reason) + assert.Equal(t, authz.SourceScopeDenied, resolved.Verdict) + assert.Equal(t, authz.ReasonSourceNamespaceNotAllowed, resolved.Reason) } -// TestWatchRuleSourceNamespaceAdmitted_ProviderReadErrorIsRequeued: a transient apiserver failure -// must surface as an ERROR the caller requeues on, never as a silent denial that would tear down a +// 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 TestWatchRuleSourceNamespaceAdmitted_ProviderReadErrorIsRequeued(t *testing.T) { +func TestResolveWatchRuleSourceScope_ProviderReadErrorIsRequeued(t *testing.T) { target := snTarget(&configv1alpha3.NamespaceMatcher{Names: []string{snSourceNS}}) boom := errors.New("etcdserver: request timed out") @@ -335,75 +541,83 @@ func TestWatchRuleSourceNamespaceAdmitted_ProviderReadErrorIsRequeued(t *testing }). Build() - _, err := authz.WatchRuleSourceNamespaceAdmitted( + _, 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) } -// TestWatchRuleSourceNamespaceAdmitted_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 +// 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 TestWatchRuleSourceNamespaceAdmitted_ExactNamesNeedNoResolver(t *testing.T) { - ctx := context.Background() - +func TestResolveWatchRuleSourceScope_ExactNamesNeedNoResolver(t *testing.T) { t.Run("exact name still admits", func(t *testing.T) { - target := snTarget(&configv1alpha3.NamespaceMatcher{Names: []string{snSourceNS}}) - cl := fake.NewClientBuilder().WithScheme(snScheme(t)). - WithObjects(target, snClusterProvider(true), - &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snTenantNS}}).Build() + resolved := resolveOne(t, snSourceNS, + &configv1alpha3.NamespaceMatcher{Names: []string{snSourceNS}}, true, nil) - decision, err := authz.WatchRuleSourceNamespaceAdmitted(ctx, cl, snRule(snSourceNS), target, nil) - - require.NoError(t, err) - assert.Equal(t, authz.SourceScopeAdmitted, decision.Verdict, + 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) { - target := snTarget(&configv1alpha3.NamespaceMatcher{ + resolved := resolveOne(t, snSourceNS, &configv1alpha3.NamespaceMatcher{ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"x": "y"}}, - }) - cl := fake.NewClientBuilder().WithScheme(snScheme(t)). - WithObjects(target, snClusterProvider(true), - &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snTenantNS}}).Build() + }, true, nil) - decision, err := authz.WatchRuleSourceNamespaceAdmitted(ctx, cl, snRule(snSourceNS), target, 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) - require.NoError(t, err) - assert.Equal(t, authz.SourceScopeUnknown, decision.Verdict) - assert.Equal(t, authz.ReasonCheckingSourceNamespacePolicy, decision.Reason) + 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") }) } -// TestWatchRuleSourceNamespaceAdmitted_NameFastPathSkipsTheResolver pins the degradation path's +// 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 TestWatchRuleSourceNamespaceAdmitted_NameFastPathSkipsTheResolver(t *testing.T) { - target := snTarget(&configv1alpha3.NamespaceMatcher{ +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"}}, - }) - cl := fake.NewClientBuilder().WithScheme(snScheme(t)). - WithObjects(target, snClusterProvider(true), - &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snTenantNS}}).Build() - - resolver := denying() - decision, err := authz.WatchRuleSourceNamespaceAdmitted( - context.Background(), cl, snRule(snSourceNS), target, resolver) + }, true, resolver) - require.NoError(t, err) - assert.Equal(t, authz.SourceScopeAdmitted, decision.Verdict) + assert.Equal(t, authz.SourceScopeAdmitted, resolved.Verdict) assert.Zero(t, resolver.calls, "an exact-name match must never reach the source-scope service") } -// TestWatchRuleSourceNamespaceDecision_TerminalClassification pins which verdicts stop a rule while +// 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 TestWatchRuleSourceNamespaceDecision_TerminalClassification(t *testing.T) { +func TestSourceNamespaceDecision_TerminalClassification(t *testing.T) { for verdict, wantTerminal := range map[authz.SourceScopeVerdict]bool{ authz.SourceScopeAdmitted: false, authz.SourceScopeUnknown: false, @@ -412,5 +626,34 @@ func TestWatchRuleSourceNamespaceDecision_TerminalClassification(t *testing.T) { } { 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) } From 98055c0e3e1a8e81b18d3ba952ccceb2fa2d1b85 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 21 Jul 2026 09:59:44 +0000 Subject: [PATCH 07/18] refactor(watch): carry the resolved source scope through compile and planning A compiled rule used to hold one source namespace for the whole object. It now holds one resolved namespace SET per item, and every stage from compile to the watched-type table is keyed on that set. - `CompiledRule.SourceNamespace` (string) is replaced by `CompiledResourceRule.SourceNamespaces []string`. `AddOrUpdateWatchRule` takes an index-aligned `sourceNamespaces [][]string` and replaces the whole rule atomically, so a rule is never half-recompiled. `GetWatchRule` is added for callers that need the compiled form back. - `GetMatchingRules` filters the event's namespace per ITEM instead of against the rule object's own namespace, so one rule can legitimately match events from several namespaces. - Wildcard expansion happens at the SELECTION site, not as a read-side filter: `collectWatchRuleSelections` emits one selection per (record x resolved namespace). A read-site filter would have to re-resolve the policy on every event. - `watchRuleFingerprint` hashes each item's RESOLVED namespace set. This is the hazard the design doc calls out: a `"*"` item's inputs are the GitTarget policy and the source cluster's namespaces, neither of which is rule state, so byte-identical rule objects must still re-project the table when the policy moves. Hashing the resolved set is what makes that happen. - Retention is keyed by `SourceScopeSpecHash` (the rule's namespace plus every item's REQUESTED value). An edit or a reorder discards the grant, so a rule cannot inherit a scope it no longer asks for, while an unevaluatable policy keeps the last resolved set instead of sweeping the tree. - `Manager.EnumerateSourceNamespaces` implements the resolver's enumeration half against the source cluster; `unusableSnapshot` is shared by both resolver entry points. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/rulestore/store.go | 150 ++++++++++---- internal/rulestore/store_test.go | 171 +++++++++++++--- internal/watch/api_resource_catalog_test.go | 6 +- internal/watch/helpers_test.go | 17 ++ internal/watch/manager_catalog.go | 5 +- internal/watch/manager_snapshot_test.go | 20 +- .../watch/source_namespace_planning_test.go | 174 +++++++++++----- internal/watch/source_namespace_scope.go | 171 ++++++++++++---- internal/watch/source_namespace_test.go | 118 ++++++++--- internal/watch/watched_type_resolver.go | 71 +++++-- internal/watch/watched_type_resolver_test.go | 21 +- internal/watch/watchrule_compile.go | 188 ++++++++++++++---- 12 files changed, 844 insertions(+), 268 deletions(-) diff --git a/internal/rulestore/store.go b/internal/rulestore/store.go index 7e5cfaa7..bdf338eb 100644 --- a/internal/rulestore/store.go +++ b/internal/rulestore/store.go @@ -21,17 +21,6 @@ type CompiledRule struct { // Source is the NamespacedName of the WatchRule CR. Source types.NamespacedName - // SourceNamespace is the namespace this rule watches IN ITS SOURCE CLUSTER — the value of - // WatchRule.EffectiveSourceNamespace() at compile time. - // - // It is a field of its own rather than an overload of Source.Namespace because the two are - // genuinely different namespaces in (potentially) different clusters: Source names the - // WatchRule OBJECT in the control plane, while this names the namespace whose objects are - // mirrored. They coincide only for a legacy rule. Every watch-planning consumer — the - // watched-type selection 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. - SourceNamespace string - // GitTarget reference (for event routing) GitTargetRef string GitTargetNamespace string @@ -59,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. @@ -80,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 @@ -90,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. @@ -113,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) @@ -123,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, @@ -140,7 +160,6 @@ func (s *RuleStore) AddOrUpdateWatchRule( compiled := CompiledRule{ Source: key, - SourceNamespace: rule.EffectiveSourceNamespace(), GitTargetRef: gitTargetName, GitTargetNamespace: gitTargetNamespace, GitProviderRef: gitProviderName, @@ -151,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: @@ -207,7 +247,6 @@ func (s *RuleStore) AddOrUpdateClusterWatchRule( APIGroups: r.APIGroups, APIVersions: r.APIVersions, Resources: r.Resources, - Scope: r.Scope, }) } @@ -263,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? @@ -270,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) } } @@ -318,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, @@ -326,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, @@ -359,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 } } @@ -369,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 @@ -393,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 { @@ -614,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/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_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/source_namespace_planning_test.go b/internal/watch/source_namespace_planning_test.go index 4105c430..77352819 100644 --- a/internal/watch/source_namespace_planning_test.go +++ b/internal/watch/source_namespace_planning_test.go @@ -12,44 +12,69 @@ import ( "github.com/ConfigButler/gitops-reverser/internal/rulestore" ) -// These are the SILENT-FAILURE guards. Both sites they cover produce a stale watch rather than a +// 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 -// namespace. Without these tests the omissions are invisible until someone notices in production. +// namespaces. Without these tests the omissions are invisible until someone notices in production. -// watchRuleWithSource is watchRuleForTarget plus an explicit spec.sourceNamespace, so a test can +// 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.SourceNamespace = sourceNamespace + rule.Spec.Rules[0].SourceNamespace = sourceNamespace return rule } -// watchRuleOwnNamespace is the control-plane namespace every rule in these tests lives in; the -// point of each case is to vary the SOURCE namespace against it. -const watchRuleOwnNamespace = "tenant-acme" +// 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") +} -// makeStoreWithRule compiles one WatchRule into a fresh store, for the tests that assert on the -// compiled form rather than on the resolved watch plan. -func makeStoreWithRule(t *testing.T, rule configv1alpha3.WatchRule) *rulestore.RuleStore { - t.Helper() +// 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() - store.AddOrUpdateWatchRule(rule, "target", "test-ns", "test-provider", "test-ns", "main", "test-path") + addRule(store, rule, scope) return store } -// TestWatchRuleFingerprint_DiffersBySourceNamespace guards the fingerprint step. The watched-type -// table is only re-projected when the rules fingerprint changes, so if the fingerprint hashed the -// rule OBJECT's namespace instead of its SOURCE namespace, editing spec.sourceNamespace would -// leave the old watch running forever. +// 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) { - store := makeStoreWithRule(t, watchRuleWithSource("rule", "target", "repo-config")) - withOverride := store.SnapshotWatchRules()[0] + rule := watchRuleWithSource("rule", "target", "repo-config") + withOverride := makeStoreWithScope(rule, itemScope("repo-config")).SnapshotWatchRules()[0] - store = makeStoreWithRule(t, watchRuleWithSource("rule", "target", "other-namespace")) - withDifferentOverride := store.SnapshotWatchRules()[0] + other := watchRuleWithSource("rule", "target", "other-namespace") + withDifferentOverride := makeStoreWithScope(other, itemScope("other-namespace")).SnapshotWatchRules()[0] - store = makeStoreWithRule(t, watchRuleWithSource("rule", "target", "")) - legacy := store.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 "+ @@ -58,14 +83,14 @@ func TestWatchRuleFingerprint_DiffersBySourceNamespace(t *testing.T) { "adding an override must re-project the table") } -// TestCollectWatchRuleSelections_UsesEffectiveSourceNamespace guards the selection step: the watch -// scope must be the rule's SOURCE namespace, not the namespace the WatchRule object lives in. -func TestCollectWatchRuleSelections_UsesEffectiveSourceNamespace(t *testing.T) { +// 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) - store.AddOrUpdateWatchRule( + addRule(store, watchRuleWithSource("override-rule", "src-target", "repo-config"), - "src-target", "test-ns", "test-provider", "test-ns", "main", "test-path", - ) + itemScope("repo-config")) manager.refreshWatchedTypeTables() @@ -76,14 +101,46 @@ func TestCollectWatchRuleSelections_UsesEffectiveSourceNamespace(t *testing.T) { "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) - store.AddOrUpdateWatchRule( - watchRuleForTarget("legacy-rule", "legacy-target", "tenant-acme"), - "legacy-target", "test-ns", "test-provider", "test-ns", "main", "test-path", - ) + legacy := watchRuleForTarget("legacy-rule", "legacy-target", "tenant-acme") + addRule(store, legacy, ownNamespaceScope(legacy)) manager.refreshWatchedTypeTables() @@ -93,15 +150,37 @@ func TestCollectWatchRuleSelections_LegacyRuleStillWatchesItsOwnNamespace(t *tes assert.Equal(t, []string{"tenant-acme"}, table.Types[0].WatchScopes()) } -// TestRefreshWatchedTypeTables_SourceNamespaceChangeReProjects is the end of the same chain: an -// edit to spec.sourceNamespace must actually move the resolved watch scope. This is what the -// fingerprint guard above exists to make possible, asserted through the real re-projection path. +// 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) - store.AddOrUpdateWatchRule( + addRule(store, watchRuleWithSource("rule", "reproj-target", "repo-config"), - "reproj-target", "test-ns", "test-provider", "test-ns", "main", "test-path", - ) + itemScope("repo-config")) manager.refreshWatchedTypeTables() table, ok := manager.watchedTypeTableForGitDest(gitDestRef("reproj-target")) @@ -109,10 +188,9 @@ func TestRefreshWatchedTypeTables_SourceNamespaceChangeReProjects(t *testing.T) require.Equal(t, []string{"repo-config"}, table.Types[0].WatchScopes()) // Same rule name, different source namespace — the update path, not a new rule. - store.AddOrUpdateWatchRule( + addRule(store, watchRuleWithSource("rule", "reproj-target", "moved-namespace"), - "reproj-target", "test-ns", "test-provider", "test-ns", "main", "test-path", - ) + itemScope("moved-namespace")) manager.refreshWatchedTypeTables() table, ok = manager.watchedTypeTableForGitDest(gitDestRef("reproj-target")) @@ -122,14 +200,16 @@ func TestRefreshWatchedTypeTables_SourceNamespaceChangeReProjects(t *testing.T) "changing sourceNamespace must rebuild the watched-type table, not leave a stale watch") } -// TestCompiledRule_SourceNamespaceIsSeparateFromTheRuleObject pins the field's meaning: Source -// names the WatchRule OBJECT in the control plane and SourceNamespace names the namespace being -// mirrored. Collapsing them is the mistake this field exists to prevent. -func TestCompiledRule_SourceNamespaceIsSeparateFromTheRuleObject(t *testing.T) { - store := makeStoreWithRule(t, watchRuleWithSource("rule", "target", "repo-config")) +// 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, "repo-config", compiled.SourceNamespace, "the namespace being mirrored") + 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 index 700693c2..ad25039f 100644 --- a/internal/watch/source_namespace_scope.go +++ b/internal/watch/source_namespace_scope.go @@ -6,6 +6,8 @@ import ( "context" "fmt" "maps" + "sort" + "strings" "sync" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -64,9 +66,20 @@ type sourceNamespaceScope struct { wanted map[string]struct{} // snapshots holds the last observed Namespace labels per source cluster. snapshots map[string]namespaceSnapshot - // grants records the source namespace last successfully GRANTED to each WatchRule — the + // 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]string + 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. @@ -90,7 +103,7 @@ func (m *Manager) sourceScope() *sourceNamespaceScope { m.sourceNamespaceScope = &sourceNamespaceScope{ wanted: map[string]struct{}{}, snapshots: map[string]namespaceSnapshot{}, - grants: map[k8stypes.NamespacedName]string{}, + grants: map[k8stypes.NamespacedName]sourceScopeGrant{}, } }) return m.sourceNamespaceScope @@ -120,24 +133,8 @@ func (m *Manager) ResolveSourceNamespace( scope.want(clusterID) snapshot, ok := scope.snapshot(clusterID) - 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)), - } - 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} + if result, unusable := m.unusableSnapshot(clusterID, snapshot, ok); unusable { + return result } labels, known := snapshot.labels[namespace] @@ -169,38 +166,140 @@ func (m *Manager) ResolveSourceNamespace( } } -// RetainedSourceNamespace reports the source namespace last GRANTED to a rule, and whether any -// grant was ever established. 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. -func (m *Manager) RetainedSourceNamespace(rule k8stypes.NamespacedName) (string, bool) { +// 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 := m.clusterIDForGitTarget(types.NewResourceReference(target.Name, target.Namespace)) + 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() - ns, ok := scope.grants[rule] - return ns, ok + grant, ok := scope.grants[rule] + if !ok || grant.specHash != specHash { + return nil, false + } + return grant.namespaces, true } -// RecordSourceNamespaceGrant remembers that a rule was granted a source namespace, establishing -// the scope that RetainedSourceNamespace will later report. -func (m *Manager) RecordSourceNamespaceGrant(rule k8stypes.NamespacedName, namespace string) { +// 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] = namespace + scope.grants[rule] = sourceScopeGrant{specHash: specHash, namespaces: namespaces} } -// ForgetSourceNamespaceGrant drops a rule's resolved scope. It is called on a REFUSAL or a +// 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) ForgetSourceNamespaceGrant(rule k8stypes.NamespacedName) { +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() diff --git a/internal/watch/source_namespace_test.go b/internal/watch/source_namespace_test.go index 56fc9589..05c57be7 100644 --- a/internal/watch/source_namespace_test.go +++ b/internal/watch/source_namespace_test.go @@ -51,19 +51,25 @@ func snbClusterProvider(delegate bool) *configv1alpha3.ClusterProvider { return &configv1alpha3.ClusterProvider{ ObjectMeta: metav1.ObjectMeta{Name: snbProvider}, Spec: configv1alpha3.ClusterProviderSpec{ - AllowedNamespaces: &configv1alpha3.NamespaceMatcher{Names: []string{snbTenantNS}}, - AllowWatchRuleSourceNamespaceOverride: delegate, + AllowedNamespaces: &configv1alpha3.NamespaceMatcher{Names: []string{snbTenantNS}}, + AllowSourceNamespaceOverride: delegate, }, } } -func snbWatchRule(sourceNamespace string) *configv1alpha3.WatchRule { +// 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}, - SourceNamespace: sourceNamespace, - Rules: []configv1alpha3.ResourceRule{{Resources: []string{"configmaps"}}}, + TargetRef: configv1alpha3.LocalTargetReference{Name: snbTarget}, + Rules: items, }, } } @@ -126,8 +132,8 @@ func TestBootstrap_LegacyWatchRuleStillCompiles(t *testing.T) { compiled := m.RuleStore.SnapshotWatchRules() require.Len(t, compiled, 1) assert.Equal(t, snbRule, compiled[0].Source.Name) - assert.Equal(t, snbTenantNS, compiled[0].SourceNamespace, - "a legacy rule's source namespace is its own namespace") + 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) } @@ -145,7 +151,7 @@ func TestBootstrap_AuthorizedOverrideCompilesWithItsSourceNamespace(t *testing.T compiled := m.RuleStore.SnapshotWatchRules() require.Len(t, compiled, 1) - assert.Equal(t, snbSourceNS, compiled[0].SourceNamespace) + 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") } @@ -166,18 +172,18 @@ func TestCompileWatchRule_TerminalRefusalRemovesAnAlreadyCompiledRule(t *testing target := *snbGitTarget(&configv1alpha3.NamespaceMatcher{Names: []string{snbSourceNS}}) provider := *snbGitProvider() - decision, err := CompileWatchRule(ctx, m.Client, m.RuleStore, m, rule, target, provider) + resolved, err := CompileWatchRule(ctx, m.Client, m.RuleStore, m, rule, target, provider) require.NoError(t, err) - require.True(t, decision.Admitted()) + 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"}}) - decision, err = CompileWatchRule(ctx, m.Client, m.RuleStore, m, rule, tightened, provider) + resolved, err = CompileWatchRule(ctx, m.Client, m.RuleStore, m, rule, tightened, provider) require.NoError(t, err) - assert.Equal(t, authz.SourceScopeDenied, decision.Verdict) + 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") } @@ -200,9 +206,9 @@ func TestCompileWatchRule_RetainsScopeWhenPolicyBecomesUnevaluatable(t *testing. named := *snbGitTarget(&configv1alpha3.NamespaceMatcher{Names: []string{snbSourceNS}}) // Establish the grant through an exact name (no source-cluster access needed). - decision, err := CompileWatchRule(ctx, m.Client, m.RuleStore, m, rule, named, provider) + resolved, err := CompileWatchRule(ctx, m.Client, m.RuleStore, m, rule, named, provider) require.NoError(t, err) - require.True(t, decision.Admitted()) + 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. @@ -211,13 +217,29 @@ func TestCompileWatchRule_RetainsScopeWhenPolicyBecomesUnevaluatable(t *testing. }) m.sourceScope().store(configPlaneClusterID, namespaceSnapshot{forbidden: true}) - decision, err = CompileWatchRule(ctx, m.Client, m.RuleStore, m, rule, selector, provider) + resolved, err = CompileWatchRule(ctx, m.Client, m.RuleStore, m, rule, selector, provider) require.NoError(t, err) - assert.Equal(t, authz.SourceScopeUnknown, decision.Verdict, + assert.Equal(t, authz.SourceScopeUnknown, resolved.Verdict, "a retained scope is Unknown, never a terminal failure") - assert.Len(t, m.RuleStore.SnapshotWatchRules(), 1, + + // 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 @@ -235,39 +257,73 @@ func TestCompileWatchRule_UnevaluatablePolicyEstablishesNothing(t *testing.T) { Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirrorable": "true"}}, }) - decision, err := CompileWatchRule( + resolved, err := CompileWatchRule( ctx, m.Client, m.RuleStore, m, *snbWatchRule(snbSourceNS), selector, *snbGitProvider()) require.NoError(t, err) - assert.Equal(t, authz.SourceScopeUnavailable, decision.Verdict, + 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_RetentionIsNamespaceSpecific: a rule that EDITS spec.sourceNamespace is -// establishing a NEW grant, so a stale grant for the previous namespace must not let an -// unevaluatable policy through. -func TestCompileWatchRule_RetentionIsNamespaceSpecific(t *testing.T) { +// 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}}, ) - m.RecordSourceNamespaceGrant( - k8stypes.NamespacedName{Name: snbRule, Namespace: snbTenantNS}, snbSourceNS) + granted := snbWatchRule(snbSourceNS) + grantKey := k8stypes.NamespacedName{Name: snbRule, Namespace: snbTenantNS} + m.RecordSourceScopeGrant(grantKey, SourceScopeSpecHash(granted), [][]string{{snbSourceNS}}) m.sourceScope().store(configPlaneClusterID, namespaceSnapshot{forbidden: true}) selector := *snbGitTarget(&configv1alpha3.NamespaceMatcher{ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirrorable": "true"}}, }) - // The rule now asks for a DIFFERENT namespace than the one it holds a grant for. - decision, err := CompileWatchRule( + // 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, decision.Verdict, - "a grant for a different namespace must not be retained across an edit") + 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 @@ -332,7 +388,7 @@ func TestSourceNamespaceSnapshot_StoreDetectsObservableChange(t *testing.T) { scope := &sourceNamespaceScope{ wanted: map[string]struct{}{}, snapshots: map[string]namespaceSnapshot{}, - grants: map[k8stypes.NamespacedName]string{}, + grants: map[k8stypes.NamespacedName]sourceScopeGrant{}, } synced := func(labels map[string]string) namespaceSnapshot { return namespaceSnapshot{synced: true, labels: map[string]map[string]string{snbSourceNS: labels}} diff --git a/internal/watch/watched_type_resolver.go b/internal/watch/watched_type_resolver.go index de912aff..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,15 +299,21 @@ func (m *Manager) collectWatchRuleSelections( matched := matchFollowableRecords( records, rr.APIGroups, rr.APIVersions, rr.Resources, configv1alpha3.ResourceScopeNamespaced) for _, rec := range matched { - // The rule's SOURCE namespace, NOT rule.Source.Namespace (which names the - // WatchRule object in the control plane). These differ whenever the rule sets - // spec.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 + // 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. - ts.selections = append(ts.selections, watchSelection{ - record: rec, namespace: rule.SourceNamespace, ops: rr.Operations, - }) + // + // 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, + }) + } } } } @@ -309,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, @@ -318,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, @@ -482,33 +500,42 @@ func (m *Manager) rulesFingerprint() uint64 { } // watchRuleFingerprint hashes everything about a compiled WatchRule that can change what it -// watches. The src= component MUST be the rule's SOURCE namespace, not the WatchRule object's own -// namespace: those diverge as soon as spec.sourceNamespace is set, and hashing the wrong one means -// an edit to that field does not re-project the watched-type table. That failure is silent — the -// old watch simply keeps running — which is why it is called out here rather than left to the -// field name. +// 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.SourceNamespace, + 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 index 1f1f66ce..3b878c12 100644 --- a/internal/watch/watchrule_compile.go +++ b/internal/watch/watchrule_compile.go @@ -4,6 +4,7 @@ package watch import ( "context" + "fmt" k8stypes "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" @@ -14,24 +15,28 @@ import ( ) // SourceScopeService is the source-scope service as its consumers need it: the policy resolution -// 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. +// 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 - // RetainedSourceNamespace reports the source namespace last granted to a rule, and whether - // any grant was ever established for it. - RetainedSourceNamespace(rule k8stypes.NamespacedName) (string, bool) - // RecordSourceNamespaceGrant remembers a successful grant. - RecordSourceNamespaceGrant(rule k8stypes.NamespacedName, namespace string) - // ForgetSourceNamespaceGrant drops a rule's resolved scope on a refusal or a deletion. - ForgetSourceNamespaceGrant(rule k8stypes.NamespacedName) + // 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 runs the -// source-namespace gate first and compiles only on an admitted verdict. +// 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 @@ -43,12 +48,12 @@ type SourceScopeService interface { // // Its three outcomes map onto the three things the caller must do: // -// - ADMITTED — the rule is compiled and its grant recorded. The caller publishes -// SourceNamespaceAuthorized=True. -// - TERMINAL (denied, or a permanently unevaluatable policy with no scope ever resolved) — 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. +// - 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 @@ -66,58 +71,69 @@ func CompileWatchRule( rule configv1alpha3.WatchRule, target configv1alpha3.GitTarget, provider configv1alpha3.GitProvider, -) (authz.SourceNamespaceDecision, error) { +) (authz.ResolvedSourceScope, error) { key := k8stypes.NamespacedName{Name: rule.Name, Namespace: rule.Namespace} + specHash := SourceScopeSpecHash(&rule) - decision, err := authz.WatchRuleSourceNamespaceAdmitted(ctx, reader, &rule, &target, resolverOf(scope)) + 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 decision, err + return resolved, err } - if decision.Admitted() { + 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.RecordSourceNamespaceGrant(key, decision.Namespace) + scope.RecordSourceScopeGrant(key, specHash, namespaces) } - return decision, nil + return resolved, nil } - // An unevaluatable policy on a rule that ALREADY has a resolved scope 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 decision.Verdict == authz.SourceScopeUnavailable && retainsScope(scope, key, decision.Namespace) { - decision.Verdict = authz.SourceScopeUnknown - return decision, 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 decision.Terminal() { + if resolved.Terminal() { // Stop the data plane before the caller says anything about it. store.Delete(key) if scope != nil { - scope.ForgetSourceNamespaceGrant(key) + scope.ForgetSourceScopeGrant(key) } } - return decision, nil + return resolved, nil } -// retainsScope reports whether a rule already holds a resolved grant for this same namespace. It -// is deliberately namespace-specific: a rule that EDITS spec.sourceNamespace is establishing a new -// grant, not maintaining its old one, so a stale grant for a different namespace must not let an -// unevaluatable policy through. -func retainsScope(scope SourceScopeService, rule k8stypes.NamespacedName, namespace string) bool { +// 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 } - granted, ok := scope.RetainedSourceNamespace(rule) - return ok && granted == namespace + _, ok := scope.RetainedSourceScope(rule, specHash) + return ok } // resolverOf adapts a possibly-nil service to the resolver authz takes, preserving the nil so @@ -128,3 +144,93 @@ func resolverOf(scope SourceScopeService) authz.SourceNamespaceResolver { } 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 +} From 30c75b415ff1f389ac1d3d075b4c80df0dbc1fc0 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 21 Jul 2026 09:59:44 +0000 Subject: [PATCH 08/18] feat(watch)!: ClusterWatchRule becomes cluster-scope-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ClusterWatchRule now selects cluster-scoped types and nothing else. Three enforcement points, deliberately redundant because each one covers a path the others cannot: - Admission — the narrowed `scope` enum rejects `Namespaced` on write. - Compile — `CompileClusterWatchRule` is the single shared path for BOTH the reconciler and bootstrap. It refuses in a fixed order: first the ClusterProvider GitTarget-namespace gate, then `DeclaresNamespacedScope()`. The refusal keys on the STORED value, so a rule admitted by an older CRD is still refused rather than silently re-interpreted. - Resolution — `collectClusterWatchRuleSelections` and `ResolveClusterWatchRuleResources` always match `ResourceScopeCluster`, so a selector like `resources: ["*"]` cannot pull a namespaced type back in. Bootstrap and the reconciler sharing one path is the point: on restart, rules are seeded before any status exists, and a bootstrap that did not refuse would open a namespaced watch the reconciler would only close later. `ClusterWatchRuleReconciler.gateGitTargetAdmission`/`refuseUnauthorizedGitTarget` collapse into `gateClusterWatchRule`/`refuseClusterWatchRule`, and the reconciler no longer calls `AddOrUpdateClusterWatchRule` itself. Test fixtures move to genuinely cluster-scoped types (namespaces, nodes, CRDs, storageclasses) because a ClusterWatchRule now resolves nothing else. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../clusterwatchrule_admission_test.go | 4 +- .../controller/clusterwatchrule_controller.go | 120 +++++++++--------- internal/watch/bootstrap.go | 38 ++---- internal/watch/bootstrap_admission_test.go | 90 ++++++++++++- .../config_plane_split_review_fixes_test.go | 26 ++-- internal/watch/manager_startup_test.go | 5 +- internal/watch/watched_type_helpers_test.go | 13 +- internal/watch/watched_type_metrics_test.go | 2 +- 8 files changed, 188 insertions(+), 110 deletions(-) diff --git a/internal/controller/clusterwatchrule_admission_test.go b/internal/controller/clusterwatchrule_admission_test.go index 235b651c..f07ff167 100644 --- a/internal/controller/clusterwatchrule_admission_test.go +++ b/internal/controller/clusterwatchrule_admission_test.go @@ -126,8 +126,8 @@ 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"}, }}, }, } diff --git a/internal/controller/clusterwatchrule_controller.go b/internal/controller/clusterwatchrule_controller.go index 1238c617..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) } diff --git a/internal/watch/bootstrap.go b/internal/watch/bootstrap.go index 578eb372..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 @@ -64,14 +63,14 @@ func (m *Manager) bootstrapWatchRule(ctx context.Context, rule configv1alpha3.Wa // 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. - decision, err := CompileWatchRule(ctx, m.Client, m.RuleStore, m, rule, target, provider) + 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 !decision.Admitted() { - return fmt.Errorf("WatchRule %s/%s may not watch source namespace %q: %s", - rule.Namespace, rule.Name, decision.Namespace, decision.Message) + if !resolved.Admitted() { + return fmt.Errorf("WatchRule %s/%s source-namespace scope was not authorized: %s", + rule.Namespace, rule.Name, resolved.Message) } return nil @@ -88,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 434a9c22..cd2bbaf9 100644 --- a/internal/watch/bootstrap_admission_test.go +++ b/internal/watch/bootstrap_admission_test.go @@ -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{ @@ -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/manager_startup_test.go b/internal/watch/manager_startup_test.go index 5d987aa5..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"}, }}, }, } 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", ) From 52412f6d46ce22d70585bab97e325dacbf41da9f Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 21 Jul 2026 09:59:45 +0000 Subject: [PATCH 09/18] feat(status): aggregate source-namespace authorization per rule item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SourceNamespaceAuthorized` stays one condition per object, now summarising every `spec.rules[]` item. `gateSourceNamespace` publishes the aggregate produced by `ResolveWatchRuleSourceScope`, so the precedence a reader sees is stated once and applied everywhere: a denied item refuses the whole rule and the message names which item lost, an unevaluatable policy reports Unknown rather than quietly watching less, and a wildcard that admits nothing says so. `StreamSummaryForWatchRule` now reads the COMPILED rule instead of the spec. A wildcard's expected stream count is not derivable from the spec — it depends on how many namespaces the item resolved to — so reading the spec left a wildcard rule permanently short of its expected streams and therefore permanently not-Ready. An uncompiled rule correspondingly expects zero streams. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controller/watchrule_source_namespace.go | 60 +++++---- .../watchrule_source_namespace_test.go | 124 +++++++++++++++-- .../source_namespace_stream_summary_test.go | 125 ++++++++++++++---- internal/watch/stream_readiness.go | 39 ++++-- 4 files changed, 277 insertions(+), 71 deletions(-) diff --git a/internal/controller/watchrule_source_namespace.go b/internal/controller/watchrule_source_namespace.go index 5114c742..7ab6aa20 100644 --- a/internal/controller/watchrule_source_namespace.go +++ b/internal/controller/watchrule_source_namespace.go @@ -17,12 +17,16 @@ import ( // 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 for a rule watching its own - // namespace against a GitTarget that declares no allowedSourceNamespaces policy. + // 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 for a namespace a declared policy - // admits — an authorized override, or an own-namespace rule the policy explicitly lists. + // 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 @@ -32,6 +36,9 @@ const ( // WatchRuleReasonCheckingSourceNamespacePolicy is the Unknown reason while the answer is still // being established or a retryable source-cluster error is being retried. WatchRuleReasonCheckingSourceNamespacePolicy = authz.ReasonCheckingSourceNamespacePolicy + // WatchRuleReasonSourceNamespaceFieldRemoved is the TERMINAL False reason for a stored rule + // that still carries the removed top-level spec.sourceNamespace. + WatchRuleReasonSourceNamespaceFieldRemoved = authz.ReasonSourceNamespaceFieldRemoved ) // gateSourceNamespace is the WatchRule source-namespace gate and the ONE place this controller @@ -44,6 +51,10 @@ const ( // 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. @@ -54,30 +65,29 @@ func (r *WatchRuleReconciler) gateSourceNamespace( provider configbutleraiv1alpha3.GitProvider, log logr.Logger, ) (bool, ctrl.Result, error) { - decision, err := watch.CompileWatchRule( + 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", - "sourceNamespace", watchRule.EffectiveSourceNamespace(), "gitTargetName", target.Name, "gitTargetNamespace", target.Namespace) return true, ctrl.Result{}, err } switch { - case decision.Admitted(): + case resolved.Admitted(): r.setTypedCondition( watchRule, ConditionTypeSourceNamespaceAuthorized, metav1.ConditionTrue, - decision.Reason, - decision.Message, + resolved.Reason, + resolved.Message, ) return false, ctrl.Result{}, nil - case decision.Terminal(): - result, refuseErr := r.refuseSourceNamespace(ctx, watchRule, decision, log) + case resolved.Terminal(): + result, refuseErr := r.refuseSourceNamespace(ctx, watchRule, resolved, log) return true, result, refuseErr default: @@ -85,7 +95,7 @@ func (r *WatchRuleReconciler) gateSourceNamespace( // 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, decision) + result, updateErr := r.holdSourceNamespaceUnknown(ctx, watchRule, resolved) return true, result, updateErr } } @@ -104,14 +114,14 @@ func (r *WatchRuleReconciler) gateSourceNamespace( func (r *WatchRuleReconciler) refuseSourceNamespace( ctx context.Context, watchRule *configbutleraiv1alpha3.WatchRule, - decision authz.SourceNamespaceDecision, + resolved authz.ResolvedSourceScope, log logr.Logger, ) (ctrl.Result, error) { - log.Info("Refusing WatchRule: its effective source namespace is not authorized", + log.Info("Refusing WatchRule: its source-namespace scope is not authorized", "name", watchRule.Name, "namespace", watchRule.Namespace, - "sourceNamespace", decision.Namespace, - "reason", decision.Reason) + "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. @@ -128,17 +138,17 @@ func (r *WatchRuleReconciler) refuseSourceNamespace( watchRule, ConditionTypeSourceNamespaceAuthorized, metav1.ConditionFalse, - decision.Reason, - decision.Message, + resolved.Reason, + resolved.Message, ) r.setTypedCondition( watchRule, ConditionTypeStreamsRunning, metav1.ConditionFalse, - decision.Reason, - "No streams: the effective source namespace is not authorized", + resolved.Reason, + "No streams: the rule's source-namespace scope is not authorized", ) - r.setRuleStalled(watchRule, decision.Reason, decision.Message) + r.setRuleStalled(watchRule, resolved.Reason, resolved.Message) return r.updateStatusAndRequeue(ctx, watchRule) } @@ -154,20 +164,20 @@ func (r *WatchRuleReconciler) refuseSourceNamespace( func (r *WatchRuleReconciler) holdSourceNamespaceUnknown( ctx context.Context, watchRule *configbutleraiv1alpha3.WatchRule, - decision authz.SourceNamespaceDecision, + resolved authz.ResolvedSourceScope, ) (ctrl.Result, error) { r.setTypedCondition( watchRule, ConditionTypeSourceNamespaceAuthorized, metav1.ConditionUnknown, - decision.Reason, - decision.Message, + resolved.Reason, + resolved.Message, ) r.setTypedCondition( watchRule, ConditionTypeStreamsRunning, metav1.ConditionUnknown, - decision.Reason, + resolved.Reason, "Streams not re-evaluated while source-namespace authorization is unsettled", ) r.setRuleKstatus(watchRule, "WatchRule source-namespace authorization is unsettled") diff --git a/internal/controller/watchrule_source_namespace_test.go b/internal/controller/watchrule_source_namespace_test.go index 48474f28..1a4c12dc 100644 --- a/internal/controller/watchrule_source_namespace_test.go +++ b/internal/controller/watchrule_source_namespace_test.go @@ -56,18 +56,24 @@ func wrsnClusterProvider(delegate bool) *configbutleraiv1alpha3.ClusterProvider AllowedNamespaces: &configbutleraiv1alpha3.NamespaceMatcher{ Names: []string{wrsnTenantNS}, }, - AllowWatchRuleSourceNamespaceOverride: delegate, + AllowSourceNamespaceOverride: delegate, }, } } -func wrsnWatchRule(sourceNamespace string) *configbutleraiv1alpha3.WatchRule { +// 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}, - SourceNamespace: sourceNamespace, - Rules: []configbutleraiv1alpha3.ResourceRule{{Resources: []string{"configmaps"}}}, + TargetRef: configbutleraiv1alpha3.LocalTargetReference{Name: wrsnTarget}, + Rules: items, }, } } @@ -136,13 +142,13 @@ func wrsnCondition(t *testing.T, rule *configbutleraiv1alpha3.WatchRule, conditi func wrsnBaseObjects( policy *configbutleraiv1alpha3.NamespaceMatcher, delegate bool, - sourceNamespace string, + sourceNamespaces ...string, ) []client.Object { return []client.Object{ wrsnGitTarget(policy), wrsnGitProvider(), wrsnClusterProvider(delegate), - wrsnWatchRule(sourceNamespace), + wrsnWatchRule(sourceNamespaces...), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: wrsnTenantNS}}, } } @@ -185,7 +191,7 @@ func TestReconcile_DeniedSourceNamespaceStartsNoWatch(t *testing.T) { cond := wrsnCondition(t, rule, ConditionTypeSourceNamespaceAuthorized) assert.Equal(t, metav1.ConditionFalse, cond.Status) assert.Equal(t, WatchRuleReasonSourceNamespaceNotAllowed, cond.Reason) - assert.Contains(t, cond.Message, "allowWatchRuleSourceNamespaceOverride", + assert.Contains(t, cond.Message, "allowSourceNamespaceOverride", "the message must name the fix") } @@ -232,7 +238,7 @@ func TestReconcile_AuthorizedOverrideCompilesWithItsSourceNamespace(t *testing.T compiled := f.store.SnapshotWatchRules() require.Len(t, compiled, 1) - assert.Equal(t, wrsnSourceNS, compiled[0].SourceNamespace) + 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) @@ -387,3 +393,103 @@ func TestReconcile_ClusterProviderReadErrorRequeuesWithoutDenying(t *testing.T) 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") +} + +// TestReconcile_StoredTopLevelSourceNamespaceIsRefused covers decision 10's stored-object half at +// the reconciler. Admission rejects the field, but an object written before this release keeps its +// value in etcd; resolving the items as if it had not asked would silently watch the wrong namespace. +func TestReconcile_StoredTopLevelSourceNamespaceIsRefused(t *testing.T) { + ctx := context.Background() + objects := wrsnBaseObjects(nil, true, "") + for _, obj := range objects { + if rule, ok := obj.(*configbutleraiv1alpha3.WatchRule); ok { + rule.Spec.SourceNamespace = wrsnSourceNS //nolint:staticcheck // simulating a stored object + } + } + f := newWRSNFixture(t, objects) + + _, err := f.reconcile(ctx) + require.NoError(t, err) + + assert.Empty(t, f.compiledNames(), "a stored top-level sourceNamespace must compile nothing") + + cond := wrsnCondition(t, f.reloadRule(ctx, t), ConditionTypeSourceNamespaceAuthorized) + assert.Equal(t, metav1.ConditionFalse, cond.Status) + assert.Equal(t, WatchRuleReasonSourceNamespaceFieldRemoved, cond.Reason) + assert.Contains(t, cond.Message, "spec.rules[].sourceNamespace", + "the refusal must name the replacement, because the move is not automatic") +} diff --git a/internal/watch/source_namespace_stream_summary_test.go b/internal/watch/source_namespace_stream_summary_test.go index 2617e0d3..35724813 100644 --- a/internal/watch/source_namespace_stream_summary_test.go +++ b/internal/watch/source_namespace_stream_summary_test.go @@ -11,20 +11,26 @@ import ( "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 PR 4 shipped with a first cut: 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 (effective source) namespace. The two -// differ exactly when spec.sourceNamespace 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. +// 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(), } @@ -45,28 +51,42 @@ func (m *Manager) seedStreamState(gitDest types.ResourceReference, key targetWat states[key] = status } -func srcnsOverrideRule() configv1alpha3.WatchRule { +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"}, - SourceNamespace: "repo-config", - Rules: []configv1alpha3.ResourceRule{{Resources: []string{"configmaps"}}}, + TargetRef: configv1alpha3.LocalTargetReference{Name: "acme"}, + Rules: []configv1alpha3.ResourceRule{{ + Resources: []string{"configmaps"}, SourceNamespace: sourceNamespace, + }}, }, } } -// TestStreamSummaryForWatchRule_KeysOnEffectiveSourceNamespace is the fix asserted: a stream keyed +// 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_KeysOnEffectiveSourceNamespace(t *testing.T) { +func TestStreamSummaryForWatchRule_KeysOnResolvedSourceNamespace(t *testing.T) { m := srcnsSummaryManager(t) - rule := srcnsOverrideRule() - gitDest := types.NewResourceReference("acme", "tenant-acme") - configmaps := schema.GroupVersionResource{Version: "v1", Resource: "configmaps"} + 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(gitDest, - targetWatchKey{GVR: configmaps, Namespace: "repo-config"}, + m.seedStreamState(srcnsGitDest(), + targetWatchKey{GVR: srcnsConfigMaps(), Namespace: "repo-config"}, targetStreamStatus{state: StreamStateStreaming}) summary := m.StreamSummaryForWatchRule(rule) @@ -83,13 +103,12 @@ func TestStreamSummaryForWatchRule_KeysOnEffectiveSourceNamespace(t *testing.T) // the config-plane-namespace lookup. func TestStreamSummaryForWatchRule_WrongNamespaceKeyMisses(t *testing.T) { m := srcnsSummaryManager(t) - rule := srcnsOverrideRule() - gitDest := types.NewResourceReference("acme", "tenant-acme") - configmaps := schema.GroupVersionResource{Version: "v1", Resource: "configmaps"} + 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(gitDest, - targetWatchKey{GVR: configmaps, Namespace: "tenant-acme"}, + m.seedStreamState(srcnsGitDest(), + targetWatchKey{GVR: srcnsConfigMaps(), Namespace: "tenant-acme"}, targetStreamStatus{state: StreamStateStreaming}) summary := m.StreamSummaryForWatchRule(rule) @@ -99,17 +118,67 @@ func TestStreamSummaryForWatchRule_WrongNamespaceKeyMisses(t *testing.T) { 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() - rule.Spec.SourceNamespace = "" // legacy: watches its own namespace - gitDest := types.NewResourceReference("acme", "tenant-acme") - configmaps := schema.GroupVersionResource{Version: "v1", Resource: "configmaps"} + rule := srcnsOverrideRule("") // legacy: watches its own namespace + compileForSummary(m, rule, ownNamespaceScope(rule)) - m.seedStreamState(gitDest, - targetWatchKey{GVR: configmaps, Namespace: "tenant-acme"}, + m.seedStreamState(srcnsGitDest(), + targetWatchKey{GVR: srcnsConfigMaps(), Namespace: "tenant-acme"}, targetStreamStatus{state: StreamStateStreaming}) summary := m.StreamSummaryForWatchRule(rule) diff --git a/internal/watch/stream_readiness.go b/internal/watch/stream_readiness.go index 4566e882..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,24 +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 - // stream is keyed on the namespace being WATCHED — the effective source namespace. Those - // differ whenever spec.sourceNamespace is set, so looking the stream up by rule.Namespace would - // miss it and report the rule perpetually not-ready even while its stream runs. + // streams are keyed on the namespaces being WATCHED. gitDest := types.NewResourceReference(rule.Spec.TargetRef.Name, rule.Namespace) - sourceNamespace := rule.EffectiveSourceNamespace() + 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: sourceNamespace} - 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) } } @@ -155,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) @@ -164,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) From 0c3db0aa7541fa402247193fb54ed93aa94937ae Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 21 Jul 2026 09:59:46 +0000 Subject: [PATCH 10/18] docs: scope by kind, plus the PR 4 migration guide Documents the model and covers the migration end to end in tests. Docs: `configuration.md`, `architecture.md`, `security-model.md` (a new "Which namespaces a rule may read from"), `rbac.md` (a new section on `namespaces` RBAC against a REMOTE source cluster), `status-conditions-guide.md` (the aggregation order and `NoAdmittedSourceNamespaces`), and a new `UPGRADING.md` entry: the two removed capabilities, the conversion, a `jq` one-liner to find affected objects, and the warning that converting without declaring `allowedSourceNamespaces` narrows what a rule mirrors. Tests: `superseded_fields_admission_test.go` asserts against the GENERATED CRDs that `scope: Namespaced` and `spec.sourceNamespace` are rejected, that `rules[].sourceNamespace` including `"*"` is accepted, that a malformed namespace is rejected, and that an omitted `scope` defaults. The e2e source- namespace spec moves to `rules[].sourceNamespace` and gains a wildcard case asserting against real commits that an admitted namespace arrives and an unadmitted one never does. The restart fixture becomes a WatchRule and keeps covering the `apiVersions: ["*"]` startup-snapshot regression. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/UPGRADING.md | 96 +++++++++++++ docs/architecture.md | 24 ++-- docs/configuration.md | 117 +++++++++++----- .../pr4-cluster-scope-only.md | 4 +- docs/rbac.md | 14 ++ docs/security-model.md | 22 +++ docs/spec/status-conditions-guide.md | 27 +++- .../superseded_fields_admission_test.go | 131 ++++++++++++++++++ test/e2e/restart_reconcile_e2e_test.go | 20 +-- test/e2e/source_namespace_e2e_test.go | 93 ++++++++++--- .../templates/demo/clusterwatchrule-demo.tmpl | 6 +- .../manager/clusterwatchrule-crd.tmpl | 3 +- .../restart/clusterwatchrule-wildcard.tmpl | 18 --- .../templates/restart/watchrule-wildcard.tmpl | 22 +++ test/e2e/unsupported_folder_e2e_test.go | 10 +- 15 files changed, 506 insertions(+), 101 deletions(-) create mode 100644 internal/controller/superseded_fields_admission_test.go delete mode 100644 test/e2e/templates/restart/clusterwatchrule-wildcard.tmpl create mode 100644 test/e2e/templates/restart/watchrule-wildcard.tmpl diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index 9a2a2a4e..2e905d5e 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -7,6 +7,102 @@ 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. + +### Both superseded fields are retained for one release as loud rejections + +Neither field was deleted, because a deleted field 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 change what it mirrors. + +| Field | Now | +|---|---| +| `ClusterWatchRule.spec.rules[].scope` | optional, defaults to `Cluster`, and the enum accepts only `Cluster`. `scope: Namespaced` is **rejected at apply time**; a stored one is refused at compile with `ClusterScopeOnly`. | +| `WatchRule.spec.sourceNamespace` | **rejected at apply time** (`spec.sourceNamespace moved to spec.rules[].sourceNamespace`); a stored one is refused at compile with `SourceNamespaceFieldRemoved`. | + +Both are removed entirely one release from now, or at `v1beta1`. + +### `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..5bdb6a8a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -182,8 +182,8 @@ graph LR | 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 +196,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,7 +207,12 @@ 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. +* `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. @@ -775,8 +781,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 diff --git a/docs/configuration.md b/docs/configuration.md index 9b7ea4c2..610fa4cc 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 @@ -398,11 +399,12 @@ 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. - allowWatchRuleSourceNamespaceOverride: true + 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 list the namespace. Setting it on an +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 @@ -706,8 +708,9 @@ For design details and the exact boundary, see ## `WatchRule` -`WatchRule` is the normal namespaced watcher. It watches resources in **one namespace of its -`GitTarget`'s source cluster** — its own namespace by default — and writes them to that `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, `GitTargetReady` for the referenced target's write readiness, and `SourceNamespaceAuthorized` for the @@ -717,18 +720,28 @@ when its GitTarget reports `GitPathAccepted=False`. The important fields are: - `spec.targetRef.name`: target to write to -- `spec.sourceNamespace`: the source-cluster namespace to watch; omitted means the rule's own - namespace - `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.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 is authorized by three things, all of which must hold: +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 `allowWatchRuleSourceNamespaceOverride: true`; and +2. that `ClusterProvider` sets `allowSourceNamespaceOverride: true`; and 3. the `GitTarget`'s `allowedSourceNamespaces` admits the namespace. ```yaml @@ -740,23 +753,35 @@ metadata: spec: targetRef: name: acme - sourceNamespace: repo-config # watched in the SOURCE cluster rules: - - resources: [configmaps] + - 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 ``` -The outcome is published as `SourceNamespaceAuthorized`, also shown by -`kubectl get watchrules -o wide`. A refusal is terminal — `Ready=False`, `Stalled=True`, and the -stream stopped — and its message names the fix. Authorization is re-evaluated on every reconcile, so -tightening a policy revokes a running rule rather than only affecting new ones. +`"*"` 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 under `repo-config/…`, not `tenant-acme/…`. +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 any rule kind: +that target by its `WatchRule`s: ```yaml spec: @@ -771,20 +796,34 @@ spec: 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 each rule kind keeps its legacy scope. +- **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 list every namespace +- **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 listed — loudly, with + 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` selecting cluster-scoped types receives every such object its source credential -can read, and this field is not consulted for them. 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. +`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: @@ -816,17 +855,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: @@ -844,11 +884,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/pr4-cluster-scope-only.md b/docs/design/watchrule-source-namespace/pr4-cluster-scope-only.md index 2ea54ed1..3f9cd6fb 100644 --- a/docs/design/watchrule-source-namespace/pr4-cluster-scope-only.md +++ b/docs/design/watchrule-source-namespace/pr4-cluster-scope-only.md @@ -206,7 +206,7 @@ replacement. Deleting the field instead would be worse in two ways, and both are - 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/clusterwatchrule-wildcard.tmpl). + 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`. @@ -621,7 +621,7 @@ Must change in the same PR: Fixture conversions worth calling out, because they change what the test proves: -- [test/e2e/templates/restart/clusterwatchrule-wildcard.tmpl](../../../test/e2e/templates/restart/clusterwatchrule-wildcard.tmpl) +- [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. 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 ae34409c..48dc4d0e 100644 --- a/docs/spec/status-conditions-guide.md +++ b/docs/spec/status-conditions-guide.md @@ -59,7 +59,7 @@ const ( TypeGitPathAccepted = "GitPathAccepted" TypeGitTargetReady = "GitTargetReady" - // WatchRule only: whether the rule's EFFECTIVE source namespace is authorized. + // WatchRule only: whether every rule item's RESOLVED source-namespace scope is authorized. TypeSourceNamespaceAuthorized = "SourceNamespaceAuthorized" ) ``` @@ -92,6 +92,31 @@ Canonical reads: 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) CommitRequest runs once to a terminal outcome. Best-practice 1 above would suggest a `Succeeded` summary, diff --git a/internal/controller/superseded_fields_admission_test.go b/internal/controller/superseded_fields_admission_test.go new file mode 100644 index 00000000..a775da89 --- /dev/null +++ b/internal/controller/superseded_fields_admission_test.go @@ -0,0 +1,131 @@ +// 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("rejects the removed top-level WatchRule spec.sourceNamespace at admission", func() { + ctx := context.Background() + + rule := &configbutleraiv1alpha3.WatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: "legacy-top-level-source", Namespace: "default"}, + Spec: configbutleraiv1alpha3.WatchRuleSpec{ + TargetRef: configbutleraiv1alpha3.LocalTargetReference{Name: "any-target"}, + SourceNamespace: "repo-config", + Rules: []configbutleraiv1alpha3.ResourceRule{{ + Resources: []string{"configmaps"}, + }}, + }, + } + + err := k8sClient.Create(ctx, rule) + + Expect(err).To(HaveOccurred(), + "a manifest that still sets the top-level field must FAIL, never be silently pruned") + Expect(err.Error()).To(ContainSubstring("spec.rules[].sourceNamespace"), + "and the rejection must name the replacement") + }) + + 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/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 index 817a1db5..121803c2 100644 --- a/test/e2e/source_namespace_e2e_test.go +++ b/test/e2e/source_namespace_e2e_test.go @@ -7,15 +7,15 @@ import ( "os" "path" "path/filepath" + "strings" "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -// This spec covers WatchRule.spec.sourceNamespace end to end (see -// docs/design/watchrule-source-namespace/historical-top-level-source-namespace-baseline.md; the -// field moves onto spec.rules[] in pr4-cluster-scope-only.md). +// 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 @@ -25,7 +25,8 @@ import ( // 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. +// 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" @@ -36,24 +37,30 @@ var _ = Describe("WatchRule source namespace", Label("manager"), Ordered, func() 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. - configNS string - sourceNS string - srcnsRepo *RepoArtifacts + // two differ on purpose — that separation is the entire feature. 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 + outsideNS string + srcnsRepo *RepoArtifacts + grantedDir string ) BeforeAll(func() { - By("creating separate config-plane and source namespaces") + By("creating separate config-plane, source, and unadmitted namespaces") configNS = testNamespaceFor("srcns-config") sourceNS = testNamespaceFor("srcns-source") + outsideNS = testNamespaceFor("srcns-outside") _, _ = kubectlRun("create", "namespace", configNS) _, _ = kubectlRun("create", "namespace", sourceNS) + _, _ = kubectlRun("create", "namespace", outsideNS) By("setting up Gitea repo and credentials") srcnsRepo = SetupRepo( @@ -61,6 +68,7 @@ var _ = Describe("WatchRule source namespace", Label("manager"), Ordered, func() 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) @@ -96,13 +104,14 @@ var _ = Describe("WatchRule source namespace", Label("manager"), Ordered, func() deleteClusterProvider(nonDelegatingCP) cleanupNamespace(configNS) cleanupNamespace(sourceNS) + 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 in the config namespace that watches the source namespace") + 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") @@ -138,6 +147,55 @@ var _ = Describe("WatchRule source namespace", Label("manager"), Ordered, func() }).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 Secret in the ADMITTED namespace and one in an unadmitted namespace") + const admittedSecret = "srcns-wildcard-admitted" + const outsideSecret = "srcns-wildcard-outside" + _, err := kubectlRunInNamespace(sourceNS, "create", "secret", "generic", admittedSecret, + "--from-literal=k=v") + Expect(err).NotTo(HaveOccurred()) + _, err = kubectlRunInNamespace(outsideNS, "create", "secret", "generic", outsideSecret, + "--from-literal=k=v") + Expect(err).NotTo(HaveOccurred()) + + By("asserting the admitted namespace arrives") + Eventually(func(g Gomega) { + pullLatestRepoState(g, srcnsRepo.CheckoutDir) + g.Expect(findFileByBasename(grantedDir, admittedSecret+".yaml")).NotTo(BeEmpty(), + `"*" must expand to the admitted set. Recent commits:\n%s`, + 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, outsideSecret+".yaml")).To(BeEmpty(), + "a target whose policy admits only %q must never receive an object from %q", + sourceNS, 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("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 @@ -153,7 +211,7 @@ var _ = Describe("WatchRule source namespace", Label("manager"), Ordered, func() By("asserting the terminal refusal is published with a fix-naming message") verifyResourceCondition("watchrule", refusedRule, configNS, "SourceNamespaceAuthorized", "False", "SourceNamespaceNotAllowed", - "allowWatchRuleSourceNamespaceOverride") + "allowSourceNamespaceOverride") verifyResourceCondition("watchrule", refusedRule, configNS, "Stalled", "True", "SourceNamespaceNotAllowed", "") verifyResourceCondition("watchrule", refusedRule, configNS, @@ -189,7 +247,7 @@ metadata: spec: allowedNamespaces: names: [%s] - allowWatchRuleSourceNamespaceOverride: %t + allowSourceNamespaceOverride: %t `, name, allowedNS, delegate) return kubectlRunWithStdin("", manifest, "apply", "-f", "-") } @@ -218,8 +276,9 @@ spec: return kubectlRunWithStdin(ns, manifest, "apply", "-f", "-") } -// applyWatchRuleWithSourceNamespace applies a WatchRule that watches a namespace OTHER than its own. -func applyWatchRuleWithSourceNamespace(name, ns, target, sourceNS string) (string, error) { +// 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: @@ -229,9 +288,11 @@ spec: targetRef: kind: GitTarget name: %s - sourceNamespace: %s rules: - resources: ["configmaps"] -`, name, ns, target, sourceNS) + 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") } From 638878ef40b9f1157476543c78fb59900e3b1a16 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 21 Jul 2026 10:35:29 +0000 Subject: [PATCH 11/18] test(e2e): make the wildcard spec prove the wildcard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec passed vacuously in one half and could never pass in the other. - It probed for `.yaml`, but Secrets are committed SOPS-encrypted as `.sops.yaml` and `findFileByBasename` matches basenames exactly. The positive assertion could therefore never succeed — and the `Consistently` proving an UNADMITTED namespace never arrives was searching for a filename that could not exist either way, so it could never fail. That half is the load-bearing one, which made the vacuous pass the more dangerous defect. - The positive assertion created its object in the namespace the PRECEDING spec's rule already watches by exact name, so it would have passed even if `"*"` expanded to nothing at all. The granted GitTarget now admits a second namespace that no rule item ever names, so only a wildcard can reach it, and the probe objects are ConfigMaps so the assertion does not also ride on the encryption filename convention (the dedicated Secret-encryption spec already covers that). Co-Authored-By: Claude Opus 4.8 (1M context) --- test/e2e/source_namespace_e2e_test.go | 54 +++++++++++++++++---------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/test/e2e/source_namespace_e2e_test.go b/test/e2e/source_namespace_e2e_test.go index 121803c2..e3198761 100644 --- a/test/e2e/source_namespace_e2e_test.go +++ b/test/e2e/source_namespace_e2e_test.go @@ -44,22 +44,27 @@ var _ = Describe("WatchRule source namespace", Label("manager"), Ordered, func() var ( // configNS holds the WatchRules and GitTargets; sourceNS is the namespace they WATCH. The - // two differ on purpose — that separation is the entire feature. outsideNS exists to prove - // the policy is a bound rather than a hint: it is never admitted by any target here. + // 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, and unadmitted namespaces") + 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") @@ -87,9 +92,12 @@ var _ = Describe("WatchRule source namespace", Label("manager"), Ordered, func() Expect(applyInClusterClusterProvider(nonDelegatingCP, configNS, false)).Error(). NotTo(HaveOccurred(), "failed to apply non-delegating ClusterProvider") - By("creating a GitTarget whose policy admits the source namespace, and one that is refused") + 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)).Error(). + configNS, grantedTarget, providerName, grantedPath, delegatingCP, + sourceNS, wildcardNS)).Error(). NotTo(HaveOccurred(), "failed to apply granted GitTarget") Expect(applyGitTargetWithSourceNamespaces( configNS, refusedTarget, providerName, refusedPath, nonDelegatingCP, sourceNS)).Error(). @@ -104,6 +112,7 @@ var _ = Describe("WatchRule source namespace", Label("manager"), Ordered, func() deleteClusterProvider(nonDelegatingCP) cleanupNamespace(configNS) cleanupNamespace(sourceNS) + cleanupNamespace(wildcardNS) cleanupNamespace(outsideNS) }) @@ -158,22 +167,27 @@ var _ = Describe("WatchRule source namespace", Label("manager"), Ordered, func() "SourceNamespaceAuthorized", "True", "SourceNamespaceAllowed", "") verifyResourceStatus("watchrule", wildcardRule, configNS, "True", "Ready", "") - By("creating one Secret in the ADMITTED namespace and one in an unadmitted namespace") - const admittedSecret = "srcns-wildcard-admitted" - const outsideSecret = "srcns-wildcard-outside" - _, err := kubectlRunInNamespace(sourceNS, "create", "secret", "generic", admittedSecret, + 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", "secret", "generic", outsideSecret, + _, err = kubectlRunInNamespace(outsideNS, "create", "configmap", outsideCM, "--from-literal=k=v") Expect(err).NotTo(HaveOccurred()) - By("asserting the admitted namespace arrives") + 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(findFileByBasename(grantedDir, admittedSecret+".yaml")).NotTo(BeEmpty(), - `"*" must expand to the admitted set. Recent commits:\n%s`, - recentCommitDiagnostics(srcnsRepo.CheckoutDir, grantedPath)) + 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") @@ -181,9 +195,9 @@ var _ = Describe("WatchRule source namespace", Label("manager"), Ordered, func() // 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, outsideSecret+".yaml")).To(BeEmpty(), - "a target whose policy admits only %q must never receive an object from %q", - sourceNS, outsideNS) + 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)) @@ -253,9 +267,9 @@ spec: } // applyGitTargetWithSourceNamespaces applies a GitTarget that declares an allowedSourceNamespaces -// policy naming one source namespace by exact name. +// policy naming one or more source namespaces by exact name. func applyGitTargetWithSourceNamespaces( - ns, name, gitProvider, targetPath, clusterProvider, sourceNS string, + ns, name, gitProvider, targetPath, clusterProvider string, sourceNSs ...string, ) (string, error) { manifest := fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 kind: GitTarget @@ -272,7 +286,7 @@ spec: name: %s allowedSourceNamespaces: names: [%s] -`, name, ns, gitProvider, targetPath, clusterProvider, sourceNS) +`, name, ns, gitProvider, targetPath, clusterProvider, strings.Join(sourceNSs, ", ")) return kubectlRunWithStdin(ns, manifest, "apply", "-f", "-") } From 08e91e6d6f77ac1c8d1405d79aedb985bc70f278 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 21 Jul 2026 11:21:07 +0000 Subject: [PATCH 12/18] fix(placement): prove namespace-agnosticism before reusing a cohort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling inference could file one namespace's objects under another namespace's folder, and then collapse an entire resource type into a single file. Step 2 of inference ("same type, any namespace") is guarded by the P4 rule: a cohort may only be reused for a namespace the target has never written if it has proven itself namespace-agnostic. The bundle branch enforced that properly, via `spansMultipleNamespaces`. The singleton branch only asked `allSameDir`, which is "trivially true for zero or one element" — so ONE directory holding ONE namespace passed the guard, even though it is exactly as consistent with a per-namespace-segmented layout whose second namespace had simply never been written. Absence of contrary evidence was being read as proof. The consequence was worse than one misplaced file, because it cascades. Objects that exist under the same name in every namespace (kube-root-ca.crt) make the inferred path collide EXACTLY with the first namespace's file, so the second namespace's object is appended as another document. That file now genuinely spans two namespaces, so every later object of the type legitimately prefers the bundle — and the whole type collapses into one file holding several namespaces' objects. Observed end to end: four ConfigMaps from two namespaces in a single file, which was the only ConfigMap file in the tree. Both branches now require the same positive proof. Declining costs nothing: the canonical path builds the correct namespace segment directly, which is the layout the target already uses for the namespace it has written. This is pre-existing and not introduced by the source-namespace work — the write path is untouched by it, and placement.go last changed in #239. It is reachable today wherever one GitTarget receives one type from two namespaces. It matters now because `sourceNamespace: "*"` reaches that shape from a single rule. Co-Authored-By: Claude Opus 4.8 (1M context) --- .coverage-baseline | 2 +- docs/configuration.md | 6 ++ internal/manifestanalyzer/placement.go | 18 ++++-- internal/manifestanalyzer/placement_test.go | 69 +++++++++++++++++++++ 4 files changed, 90 insertions(+), 5 deletions(-) diff --git a/.coverage-baseline b/.coverage-baseline index cd27a8f4..d8ce1eb4 100644 --- a/.coverage-baseline +++ b/.coverage-baseline @@ -1 +1 @@ -77.2 +77.5 diff --git a/docs/configuration.md b/docs/configuration.md index 610fa4cc..a80c5a1e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -575,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 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} From cf55c5fe498dfbdc70514a897f848a9ac1e7d561 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 21 Jul 2026 11:21:17 +0000 Subject: [PATCH 13/18] test(e2e): one GitTarget with two source namespaces keeps them apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wildcard spec proved that `"*"` resolves to the admitted set, but every object it created lived in a single namespace, so it could not have caught a placement defect that only appears once a target holds a SECOND one. This adds that case with two ordinary rules and explicit sourceNamespace values — the same shape a wildcard expands to, reached without the wildcard. Keeping it wildcard-free is the point: it separates a defect in wildcard EXPANSION from one in how a target handles more than one source namespace at all, which is what told us the collapse fixed in the previous commit was not caused by this feature. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/e2e/source_namespace_e2e_test.go | 37 +++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/test/e2e/source_namespace_e2e_test.go b/test/e2e/source_namespace_e2e_test.go index e3198761..c346b282 100644 --- a/test/e2e/source_namespace_e2e_test.go +++ b/test/e2e/source_namespace_e2e_test.go @@ -210,6 +210,43 @@ var _ = Describe("WatchRule source namespace", Label("manager"), Ordered, func() }, 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 From ff3b0cb9d4a4e97eafbfa72dddcea380f4be160f Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 21 Jul 2026 12:42:46 +0000 Subject: [PATCH 14/18] fix(watch): key a source-namespace policy on the target's own cluster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A GitTarget's allowedSourceNamespaces is a statement about ITS source cluster, but both resolver entry points looked the cluster up 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 for authorization: the WatchRule reconciler gates as soon as it has resolved the GitTarget, while Declare is the GitTarget controller's job, and after a restart the two run concurrently. In that window a REMOTE target's selector was evaluated against CONFIG-PLANE Namespace labels, so a namespace could be admitted because a same-named namespace here happened to carry the right label. Key on target.SourceCluster() instead. The two can never disagree: the controller passes exactly that value to DeclareForGitTarget. This also fixes a latent enqueue miss, since enqueueSourceNamespaceChange matches the armed cluster id against the same map — an undeclared target armed "" and so could never match itself. Seven existing tests had to move their snapshot onto the target's own cluster. They passed only because the resolver read the wrong one, which is the clearest evidence the defect was real; the new test uses DIVERGENT labels, because with both clusters labelled alike it would pass against the bug. Also bound the refresh loop that maintains those snapshots. It walked clusters serially, and the Namespace list runs on a config that deliberately carries no rest.Config.Timeout (its watches must stay open) with only its dial bounded, so a cluster that accepts the connection and then hangs blocked ReconcileForRuleChange forever — and with it the watched-type tables and target watches for every tenant. Each cluster now lists under its own deadline, with the same bounded concurrency refreshRemoteCatalogsConcurrently already applies to the catalog for exactly this reason. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/watch/source_namespace_scope.go | 70 ++++++++- internal/watch/source_namespace_test.go | 181 ++++++++++++++++++++++- 2 files changed, 237 insertions(+), 14 deletions(-) diff --git a/internal/watch/source_namespace_scope.go b/internal/watch/source_namespace_scope.go index ad25039f..d00d5394 100644 --- a/internal/watch/source_namespace_scope.go +++ b/internal/watch/source_namespace_scope.go @@ -9,6 +9,7 @@ import ( "sort" "strings" "sync" + "time" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -30,6 +31,18 @@ func namespacesGVR() schema.GroupVersionResource { // 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?". // @@ -114,6 +127,22 @@ func (m *Manager) sourceScope() *sourceNamespaceScope { // 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. // @@ -125,7 +154,7 @@ func (m *Manager) ResolveSourceNamespace( target *configv1alpha3.GitTarget, namespace string, ) authz.SourceScopeResult { - clusterID := m.clusterIDForGitTarget(types.NewResourceReference(target.Name, target.Namespace)) + clusterID := sourceScopeClusterID(target) scope := m.sourceScope() // Arm the refresh loop for this cluster. The first question is always "cannot say yet"; the @@ -181,7 +210,7 @@ func (m *Manager) EnumerateSourceNamespaces( _ context.Context, target *configv1alpha3.GitTarget, ) ([]string, authz.SourceScopeResult) { - clusterID := m.clusterIDForGitTarget(types.NewResourceReference(target.Name, target.Namespace)) + clusterID := sourceScopeClusterID(target) scope := m.sourceScope() // Arm the refresh loop for this cluster, exactly as the single-candidate path does. @@ -361,14 +390,41 @@ func labelSetsEqual(a, b map[string]map[string]string) bool { // 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() - for _, clusterID := range scope.wantedClusters() { - next := m.listSourceNamespaces(ctx, clusterID) - if scope.store(clusterID, next) { - m.enqueueSourceNamespaceChange(clusterID) - } + 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 diff --git a/internal/watch/source_namespace_test.go b/internal/watch/source_namespace_test.go index 05c57be7..d55fa622 100644 --- a/internal/watch/source_namespace_test.go +++ b/internal/watch/source_namespace_test.go @@ -4,14 +4,21 @@ 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" @@ -215,7 +222,7 @@ func TestCompileWatchRule_RetainsScopeWhenPolicyBecomesUnevaluatable(t *testing. selector := *snbGitTarget(&configv1alpha3.NamespaceMatcher{ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirrorable": "true"}}, }) - m.sourceScope().store(configPlaneClusterID, namespaceSnapshot{forbidden: true}) + m.sourceScope().store(snbProvider, namespaceSnapshot{forbidden: true}) resolved, err = CompileWatchRule(ctx, m.Client, m.RuleStore, m, rule, selector, provider) @@ -251,7 +258,7 @@ func TestCompileWatchRule_UnevaluatablePolicyEstablishesNothing(t *testing.T) { snbGitTarget(nil), snbGitProvider(), snbClusterProvider(true), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snbTenantNS}}, ) - m.sourceScope().store(configPlaneClusterID, namespaceSnapshot{forbidden: true}) + m.sourceScope().store(snbProvider, namespaceSnapshot{forbidden: true}) selector := *snbGitTarget(&configv1alpha3.NamespaceMatcher{ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirrorable": "true"}}, @@ -279,7 +286,7 @@ func TestCompileWatchRule_RetentionIsSpecSpecific(t *testing.T) { granted := snbWatchRule(snbSourceNS) grantKey := k8stypes.NamespacedName{Name: snbRule, Namespace: snbTenantNS} m.RecordSourceScopeGrant(grantKey, SourceScopeSpecHash(granted), [][]string{{snbSourceNS}}) - m.sourceScope().store(configPlaneClusterID, namespaceSnapshot{forbidden: true}) + m.sourceScope().store(snbProvider, namespaceSnapshot{forbidden: true}) selector := *snbGitTarget(&configv1alpha3.NamespaceMatcher{ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirrorable": "true"}}, @@ -343,7 +350,7 @@ func TestResolveSourceNamespace_ThreeValuedResults(t *testing.T) { t.Run("forbidden Namespace list is terminal", func(t *testing.T) { m := snbManager(t) - m.sourceScope().store(configPlaneClusterID, namespaceSnapshot{forbidden: true}) + 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") @@ -351,7 +358,7 @@ func TestResolveSourceNamespace_ThreeValuedResults(t *testing.T) { t.Run("synced cache with matching labels admits", func(t *testing.T) { m := snbManager(t) - m.sourceScope().store(configPlaneClusterID, namespaceSnapshot{ + m.sourceScope().store(snbProvider, namespaceSnapshot{ synced: true, labels: map[string]map[string]string{snbSourceNS: {"mirrorable": "true"}}, }) @@ -361,7 +368,7 @@ func TestResolveSourceNamespace_ThreeValuedResults(t *testing.T) { t.Run("synced cache with non-matching labels denies", func(t *testing.T) { m := snbManager(t) - m.sourceScope().store(configPlaneClusterID, namespaceSnapshot{ + m.sourceScope().store(snbProvider, namespaceSnapshot{ synced: true, labels: map[string]map[string]string{snbSourceNS: {"mirrorable": "false"}}, }) @@ -371,7 +378,7 @@ func TestResolveSourceNamespace_ThreeValuedResults(t *testing.T) { t.Run("synced cache missing the namespace denies with a legible cause", func(t *testing.T) { m := snbManager(t) - m.sourceScope().store(configPlaneClusterID, namespaceSnapshot{ + m.sourceScope().store(snbProvider, namespaceSnapshot{ synced: true, labels: map[string]map[string]string{"elsewhere": {"mirrorable": "true"}}, }) @@ -381,6 +388,166 @@ func TestResolveSourceNamespace_ThreeValuedResults(t *testing.T) { }) } +// 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. From 08720fc08930666de9f7c10bfd8c0dbd4eb57504 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 21 Jul 2026 12:42:59 +0000 Subject: [PATCH 15/18] fix(controller): forget a deleted WatchRule's retained source scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delete path removed the rule from the store but never called ForgetSourceScopeGrant, whose own docstring says it runs "on a REFUSAL or a deletion". Only the refusal half was wired. The grant is what distinguishes a rule MAINTAINING an already-resolved scope from one ESTABLISHING its first, and the two branches are deliberately opposite: maintaining retains the last known-good set and reports Unknown, while establishing must refuse with a terminal, actionable Stalled. A grant left behind by a deleted rule is inherited by the next rule created under that name and spec — a name a different tenant may now own — so an unevaluatable policy reads as maintaining. The rule then sits Unknown and Reconciling indefinitely instead of saying why it will never run. The consequence is narrower than "it keeps running": the maintaining branch compiles nothing, and the deleted rule was already out of the store, so no stream resurrects. The damage is a rule stuck silently in progress. The test recreates a byte-identical rule, which is the case the spec hash cannot catch — only forgetting the grant can — and was verified to fail against the unfixed controller. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../clusterwatchrule_admission_test.go | 11 +- internal/controller/watchrule_controller.go | 12 ++ .../watchrule_source_namespace_test.go | 110 ++++++++++++++++++ 3 files changed, 130 insertions(+), 3 deletions(-) diff --git a/internal/controller/clusterwatchrule_admission_test.go b/internal/controller/clusterwatchrule_admission_test.go index f07ff167..15f3b3be 100644 --- a/internal/controller/clusterwatchrule_admission_test.go +++ b/internal/controller/clusterwatchrule_admission_test.go @@ -33,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 { @@ -71,9 +75,10 @@ func (m *cwaWatchManager) StreamSummaryForClusterWatchRule( return cwaRunningSummary() } -// SourceScope returns nil: this double has no source cluster, so selector-based -// allowedSourceNamespaces degrades to "cannot say yet" while exact names stay fully answerable. -func (m *cwaWatchManager) SourceScope() watch.SourceScopeService { return nil } +// 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 } diff --git a/internal/controller/watchrule_controller.go b/internal/controller/watchrule_controller.go index c4052075..fc662f87 100644 --- a/internal/controller/watchrule_controller.go +++ b/internal/controller/watchrule_controller.go @@ -71,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 { diff --git a/internal/controller/watchrule_source_namespace_test.go b/internal/controller/watchrule_source_namespace_test.go index 1a4c12dc..5f3d6be7 100644 --- a/internal/controller/watchrule_source_namespace_test.go +++ b/internal/controller/watchrule_source_namespace_test.go @@ -19,6 +19,7 @@ import ( "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" ) @@ -324,6 +325,115 @@ func TestReconcile_DeclaredPolicyAdmittingOwnNamespaceCompiles(t *testing.T) { 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 From 62e35a6f9cef05154d24669070fb81903f92f0c0 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 21 Jul 2026 12:43:15 +0000 Subject: [PATCH 16/18] fix(api)!: reject a namespace pattern in a policy, and trim CRD descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NamespaceMatcher.names accepted `*`, and expandWildcard copies policy names verbatim into a rule's resolved scope. The effect is NOT the privilege escalation it looks like: Kubernetes treats `*` as a LITERAL namespace name — a list or watch against namespaces/* matches nothing, measured against a live cluster — and matchesSourceNamespace is exact string equality with no glob. So a policy of names: ["*"] resolved a `sourceNamespace: "*"` item to a namespace that cannot exist. The rule reported itself authorized, planned a stream, and mirrored NOTHING, which is precisely the silent no-op the NoAdmittedSourceNamespaces reason exists to make loud. Entries now validate as DNS-1123 labels, which both NamespaceMatcher users inherit from the shared type, plus a defensive ValidateNames() for policies already in etcd. The gate treats a failure as Unavailable — a policy that cannot be evaluated, not a smaller one — so one bad entry condemns the whole policy rather than silently narrowing to the well-formed remainder. The "every namespace" declaration is, and remains, `selector: {}`. BREAKING CHANGE: an allowedSourceNamespaces or allowedNamespaces entry that is not a valid namespace name is now rejected at admission. `*` was never a pattern there; it silently matched nothing. Use `selector: {}` to admit every namespace. Also trims the CRD descriptions, which had grown to 46 KB of schema text — allowedSourceNamespaces alone was 47 lines of design rationale in `kubectl explain`. Descriptions now carry the contract and point at docs/configuration.md for the full resolution table; rationale moves to comment blocks detached from the doc comment by a blank line, so it stays in the source and out of the schema. Total description text drops to 38 KB and nothing of ours exceeds 12 lines. Verified to change descriptions ONLY: placing such a block between a type's markers and its doc comment silently drops the markers, which cost ClusterWatchRule its scope=Cluster (the CRD flipped to Namespaced) and both rule kinds their printer columns before the diff caught it. AGENTS.md records the convention and that check. Co-Authored-By: Claude Opus 4.8 (1M context) --- .coverage-baseline | 2 +- AGENTS.md | 12 +++ api/v1alpha3/clusterprovider_types.go | 83 ++++++++-------- api/v1alpha3/clusterwatchrule_types.go | 56 +++++------ api/v1alpha3/gittarget_types.go | 86 +++++++--------- api/v1alpha3/namespace_matcher.go | 41 +++++++- api/v1alpha3/namespace_matcher_test.go | 38 ++++++++ api/v1alpha3/watchrule_types.go | 97 +++++++++---------- .../configbutler.ai_clusterproviders.yaml | 75 ++++++-------- .../configbutler.ai_clusterwatchrules.yaml | 42 ++------ .../crd/bases/configbutler.ai_gittargets.yaml | 75 +++++--------- .../crd/bases/configbutler.ai_watchrules.yaml | 78 +++++---------- internal/authz/source_namespace.go | 18 ++++ internal/authz/source_namespace_test.go | 39 ++++++++ 14 files changed, 376 insertions(+), 366 deletions(-) diff --git a/.coverage-baseline b/.coverage-baseline index d8ce1eb4..f5bfc226 100644 --- a/.coverage-baseline +++ b/.coverage-baseline @@ -1 +1 @@ -77.5 +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 af8db470..495867aa 100644 --- a/api/v1alpha3/clusterprovider_types.go +++ b/api/v1alpha3/clusterprovider_types.go @@ -56,13 +56,14 @@ type ClusterProviderReference 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"` @@ -74,35 +75,33 @@ type ClusterProviderSpec struct { // +optional AllowedNamespaces *NamespaceMatcher `json:"allowedNamespaces,omitempty"` - // 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. - // - // The flag does not grant access by itself — an admitted GitTarget must still admit the - // namespace in spec.allowedSourceNamespaces. What it delegates is the AUTHORITY to choose: a - // target owner may then configure a broad allow-list, including one matching every source - // namespace, so the source credential's own RBAC remains the hard maximum. Set it only when - // the owners of admitted GitTargets are trusted to pick a subset of what that credential - // may read. - // - // It is required for EVERY cross-source-namespace request, including a - // rules[].sourceNamespace of "*": a wildcard expresses a request to follow the target's - // policy set, so a later policy edit could otherwise widen the watch with no platform-admin - // opt-in. It does not apply to ClusterWatchRule, which is cluster-scope-only and selects no - // namespaces at all. + // 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. + // 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. // - // Note that LOCALITY is not the switch: whether a provider is in-cluster follows from - // spec.kubeConfig, and neither that nor the provider's name decides this. Only this flag does. + // 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"` @@ -148,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"` diff --git a/api/v1alpha3/clusterwatchrule_types.go b/api/v1alpha3/clusterwatchrule_types.go index ca02562f..50a759ac 100644 --- a/api/v1alpha3/clusterwatchrule_types.go +++ b/api/v1alpha3/clusterwatchrule_types.go @@ -111,19 +111,18 @@ type ClusterResourceRule struct { // +kubebuilder:validation:items:Pattern=`^[^/]*$` Resources []string `json:"resources"` - // Scope is REMOVED as a choice: a ClusterWatchRule is cluster-scoped only, so the only - // accepted value is "Cluster" — which is also the default, making the field omittable. + // Design rationale, kept out of the generated CRD description by the blank line below. // - // To watch NAMESPACED resources, use a WatchRule in the tenant namespace and set - // spec.rules[].sourceNamespace ("*" reaches every namespace the GitTarget admits). - // - // The field is retained in the schema for one release 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 with a message naming the replacement. + // 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 @@ -170,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 @@ -182,27 +188,13 @@ type ClusterWatchRuleStatus struct { // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // ClusterWatchRule selects CLUSTER-SCOPED resources on the source cluster its GitTarget mirrors -// from. It is the cluster-scoped half of the two-object model: scope is carried by the rule KIND, -// so it has no per-rule scope choice and no source-namespace selection at all. -// -// Security model: -// - ClusterWatchRule is cluster-scoped and requires cluster-admin permissions. -// - It references a GitTarget via targetRef (namespace required), and the referenced target's -// namespace must be admitted by that target's ClusterProvider. -// - 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. -// -// Use cases: -// - Audit cluster infrastructure (Nodes, PersistentVolumes, StorageClasses) -// - Audit RBAC changes (ClusterRoles, ClusterRoleBindings) -// - Audit CRD installations and updates +// 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. // -// To mirror NAMESPACED resources, use a WatchRule in the tenant namespace and set -// spec.rules[].sourceNamespace — "*" reaches every namespace the GitTarget's -// allowedSourceNamespaces admits, which replaces the removed cluster-wide namespaced scope. +// 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 767dd3a1..86dd19c4 100644 --- a/api/v1alpha3/gittarget_types.go +++ b/api/v1alpha3/gittarget_types.go @@ -87,65 +87,47 @@ 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"` - // AllowedSourceNamespaces bounds which SOURCE-cluster namespaces may be mirrored INTO this - // target. It is a property of the DESTINATION, not of any requesting rule: when declared it is - // exhaustive for every WatchRule that writes here. - // - // The invariant everything else serves — a declared policy is exhaustive: - // - // | declared? | a WatchRule item may watch | - // | no | the WatchRule's own namespace only (legacy) | - // | yes | exactly what the policy admits, and nothing else | - // - // It is also what a rules[].sourceNamespace of "*" resolves THROUGH: + // Design rationale, kept out of the generated CRD description by the blank line below. // - // | policy | "*" resolves to | - // | undeclared | denied — deny-by-default; "*" is not a backdoor | - // | {} (declared, empty) | nothing | - // | names: [a, b] | exactly a and b, with no source-cluster access | - // | selector: {matchLabels: …} | every source namespace carrying those labels, live | - // | selector: {} | EVERY source namespace — the "all namespaces" form | - // - // The last row is the deliberate "all source namespaces" declaration, and it is the - // replacement for the removed cluster-wide namespaced ClusterWatchRule: declared by the - // destination owner rather than by the rule author, legible here, and self-updating as - // namespaces come and go. - // - // There is deliberately NO self-namespace exception. Once a policy is declared it must admit - // every namespace that may reach this target, INCLUDING a co-resident legacy WatchRule's own - // namespace. 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. - // - // Its selector matches labels on Namespaces in the SOURCE cluster this target mirrors from — - // not the control cluster ClusterProvider.allowedNamespaces describes. Evaluating it therefore - // needs Namespace get/list/watch for the identity in that cluster's credential; an - // exact-NAMES entry stays usable without it, which is a deliberate degradation path that also - // keeps "*" resolvable against a names-only policy. - // - // Empty or omitted are NOT the same: omitted declares no policy (a WatchRule keeps its own - // namespace), while a declared-but-empty policy admits nothing. Naming any namespace other - // than the WatchRule's own — including "*" — additionally requires the ClusterProvider to set - // spec.allowSourceNamespaceOverride. + // 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. // - // It does NOT bound ClusterWatchRule. Cluster-scoped objects have no namespace, so a - // ClusterWatchRule is intentionally cluster-global and this field is neither consulted nor a - // bound for it; isolating cluster-scoped objects between tenants takes separate source - // credentials/ClusterProviders. + // 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"` } diff --git a/api/v1alpha3/namespace_matcher.go b/api/v1alpha3/namespace_matcher.go index f9bc0ab0..305717df 100644 --- a/api/v1alpha3/namespace_matcher.go +++ b/api/v1alpha3/namespace_matcher.go @@ -3,8 +3,12 @@ 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 @@ -29,9 +33,23 @@ import ( // 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 { - // Names is an explicit allow-list of namespace names. + // 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 @@ -58,6 +76,27 @@ func (m *NamespaceMatcher) MatchesName(nsName string) bool { 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 { diff --git a/api/v1alpha3/namespace_matcher_test.go b/api/v1alpha3/namespace_matcher_test.go index 9c8a584e..067ce2ed 100644 --- a/api/v1alpha3/namespace_matcher_test.go +++ b/api/v1alpha3/namespace_matcher_test.go @@ -28,6 +28,44 @@ func TestNamespaceMatcher_DenyByDefault(t *testing.T) { 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. diff --git a/api/v1alpha3/watchrule_types.go b/api/v1alpha3/watchrule_types.go index 894666a8..971538a7 100644 --- a/api/v1alpha3/watchrule_types.go +++ b/api/v1alpha3/watchrule_types.go @@ -54,15 +54,17 @@ type WatchRuleSpec struct { // +required TargetRef LocalTargetReference `json:"targetRef"` - // SourceNamespace is REMOVED. It moved to spec.rules[].sourceNamespace, so that the source - // namespace sits beside the resource selector it applies to. + // Design rationale, kept out of the generated CRD description by the blank line below. // - // The field is retained in the schema for one release purely so that re-applying a manifest - // that still sets it FAILS. Deleting it outright would be worse and silent: CRD pruning happens - // on write, so an unrecognised top-level field is dropped without an error and the rule would - // quietly watch its own namespace instead of the one it asked for. The XValidation rule on - // spec rejects any value at admission, and the compile path refuses a stored one with the same - // message. + // The field is retained in the schema purely so that re-applying a manifest that still sets it + // FAILS. Deleting it outright would be worse and silent: CRD pruning happens on write, so an + // unrecognised top-level field is dropped without an error and the rule would quietly watch its + // own namespace instead of the one it asked for. The XValidation rule on spec rejects any value + // at admission, and the compile path refuses a stored one with the same message. + + // SourceNamespace is REMOVED. It moved to spec.rules[].sourceNamespace, so that the source + // namespace sits beside the resource selector it applies to. Setting it is rejected; the field + // remains in the schema for one release only so that doing so fails loudly. // // Deprecated: use spec.rules[].sourceNamespace. Removed one release from now, or at v1beta1. // +optional @@ -136,36 +138,31 @@ type ResourceRule struct { // +kubebuilder:validation:items:Pattern=`^[^/]*$` Resources []string `json:"resources"` - // SourceNamespace is the namespace this item watches IN THE SOURCE CLUSTER the referenced - // GitTarget mirrors from. - // - // | value | meaning | - // | omitted | this WatchRule's own namespace — the legacy behavior | - // | an exact name | one source namespace, subject to the gate below | - // | "*" | every namespace GitTarget.spec.allowedSourceNamespaces admits, live | - // - // Naming a namespace OTHER than this WatchRule's own — including "*" — requires all three of: + // Design rationale, kept out of the generated CRD description by the blank line below. // - // 1. the GitTarget's namespace is admitted by its ClusterProvider (spec.allowedNamespaces); - // 2. that ClusterProvider sets spec.allowSourceNamespaceOverride; and - // 3. the GitTarget's spec.allowedSourceNamespaces admits the namespace. + // 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. // - // "*" never means "every namespace that exists": it expands to exactly what the GitTarget's - // policy admits, so a target that declares no policy denies it. The outcome for all items is - // aggregated into the one SourceNamespaceAuthorized condition. A DENIED explicit name refuses - // the whole WatchRule rather than silently trimming the item; a "*" that admits nothing is - // valid, starts no stream for this item, and is reported as NoAdmittedSourceNamespaces. - // - // Once the GitTarget declares a policy that policy is exhaustive, so even an OMITTED - // sourceNamespace is then checked against it — the rule's own namespace gets no carve-out. + // 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. // - // This changes only which namespace is WATCHED. It never changes where objects are written: - // Git placement follows each mirrored object's OWN namespace, so a rule in "tenant-acme" - // watching "repo-config" writes under repo-config/…, not tenant-acme/…. + // "*" 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. // - // Cost note: a "*" item opens one watch stream per (matched type × admitted namespace) and - // produces one resync scope each, rather than a single cluster-wide stream. That is deliberate — - // it keeps every replay scoped to one namespace — but it is a real fan-out on a broad policy. + // 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])?)$` @@ -262,6 +259,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 @@ -274,24 +279,14 @@ type WatchRuleStreamsStatus struct { // +kubebuilder:printcolumn:name="SourceAuthorized",type=string,JSONPath=`.status.conditions[?(@.type=="SourceNamespaceAuthorized")].status`,priority=1 // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` -// WatchRule selects NAMESPACED resources on the source cluster its GitTarget mirrors from. It is -// the namespaced half of the two-object model: scope is carried by the rule KIND, so a WatchRule -// never selects cluster-scoped types and a ClusterWatchRule never selects namespaced ones. -// -// It provides fine-grained control over which resources trigger Git commits, with filtering by -// operation type, API group, version, and source namespace. +// 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: -// - Each spec.rules[] item watches its own source namespace: the WatchRule's OWN namespace when -// omitted, an explicit name, or "*" for every namespace the GitTarget admits. Anything other -// than its own namespace passes the three-part gate described on rules[].sourceNamespace — -// provider admission, an explicit provider-side delegation flag, and the GitTarget's -// allowedSourceNamespaces. The gate is deny-by-default and re-evaluated on every reconcile, -// so a policy tightened later revokes a running rule. -// - Use ClusterWatchRule for watching cluster-scoped resources (Nodes, ClusterRoles, etc.) -// - RBAC controls who can create/modify WatchRules per namespace. Note that where the source is -// the operator's OWN cluster, an authorized override deliberately bypasses live namespace -// RBAC — which is why it takes an explicit platform-admin delegation to enable. +// 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/config/crd/bases/configbutler.ai_clusterproviders.yaml b/config/crd/bases/configbutler.ai_clusterproviders.yaml index 08302f6c..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: |- @@ -77,31 +75,12 @@ spec: provider admits. While false (the default) a WatchRule mirroring through this provider may watch only its OWN namespace, whatever any GitTarget policy says. - The flag does not grant access by itself — an admitted GitTarget must still admit the - namespace in spec.allowedSourceNamespaces. What it delegates is the AUTHORITY to choose: a - target owner may then configure a broad allow-list, including one matching every source - namespace, so the source credential's own RBAC remains the hard maximum. Set it only when - the owners of admitted GitTargets are trusted to pick a subset of what that credential - may read. - - It is required for EVERY cross-source-namespace request, including a - rules[].sourceNamespace of "*": a wildcard expresses a request to follow the target's - policy set, so a later policy edit could otherwise widen the watch with no platform-admin - opt-in. It does not apply to ClusterWatchRule, which is cluster-scope-only and selects no - namespaces at all. - - 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. - - Note that LOCALITY is not the switch: whether a provider is in-cluster follows from - spec.kubeConfig, and neither that nor the provider's name decides this. Only this flag does. + 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: |- @@ -111,8 +90,13 @@ spec: 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. + 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 @@ -174,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 93c635e3..549b38bd 100644 --- a/config/crd/bases/configbutler.ai_clusterwatchrules.yaml +++ b/config/crd/bases/configbutler.ai_clusterwatchrules.yaml @@ -43,27 +43,13 @@ spec: openAPIV3Schema: description: |- ClusterWatchRule selects CLUSTER-SCOPED resources on the source cluster its GitTarget mirrors - from. It is the cluster-scoped half of the two-object model: scope is carried by the rule KIND, - so it has no per-rule scope choice and no source-namespace selection at all. + 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), and the referenced target's - namespace must be admitted by that target's ClusterProvider. - - 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. - - Use cases: - - Audit cluster infrastructure (Nodes, PersistentVolumes, StorageClasses) - - Audit RBAC changes (ClusterRoles, ClusterRoleBindings) - - Audit CRD installations and updates - - To mirror NAMESPACED resources, use a WatchRule in the tenant namespace and set - spec.rules[].sourceNamespace — "*" reaches every namespace the GitTarget's - allowedSourceNamespaces admits, which replaces the removed cluster-wide namespaced scope. + 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: |- @@ -160,19 +146,9 @@ spec: scope: default: Cluster description: |- - Scope is REMOVED as a choice: a ClusterWatchRule is cluster-scoped only, so the only - accepted value is "Cluster" — which is also the default, making the field omittable. - - To watch NAMESPACED resources, use a WatchRule in the tenant namespace and set - spec.rules[].sourceNamespace ("*" reaches every namespace the GitTarget admits). - - The field is retained in the schema for one release 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 with a message naming the replacement. + 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 diff --git a/config/crd/bases/configbutler.ai_gittargets.yaml b/config/crd/bases/configbutler.ai_gittargets.yaml index 1fd4ea93..8a1bf299 100644 --- a/config/crd/bases/configbutler.ai_gittargets.yaml +++ b/config/crd/bases/configbutler.ai_gittargets.yaml @@ -94,54 +94,28 @@ spec: description: spec defines the desired state of GitTarget properties: allowedSourceNamespaces: - description: "AllowedSourceNamespaces bounds which SOURCE-cluster - namespaces may be mirrored INTO this\ntarget. It is a property of - the DESTINATION, not of any requesting rule: when declared it is\nexhaustive - for every WatchRule that writes here.\n\nThe invariant everything - else serves — a declared policy is exhaustive:\n\n\t| declared? - | a WatchRule item may watch |\n\t| no - \ | the WatchRule's own namespace only (legacy) |\n\t| - yes | exactly what the policy admits, and nothing else |\n\nIt - is also what a rules[].sourceNamespace of \"*\" resolves THROUGH:\n\n\t| - policy | \"*\" resolves to |\n\t| - undeclared | denied — deny-by-default; \"*\" is - not a backdoor |\n\t| {} (declared, empty) | nothing - \ |\n\t| names: [a, b] - \ | exactly a and b, with no source-cluster access - \ |\n\t| selector: {matchLabels: …} | every source namespace - carrying those labels, live |\n\t| selector: {} | - EVERY source namespace — the \"all namespaces\" form |\n\nThe last - row is the deliberate \"all source namespaces\" declaration, and - it is the\nreplacement for the removed cluster-wide namespaced ClusterWatchRule: - declared by the\ndestination owner rather than by the rule author, - legible here, and self-updating as\nnamespaces come and go.\n\nThere - is deliberately NO self-namespace exception. Once a policy is declared - it must admit\nevery namespace that may reach this target, INCLUDING - a co-resident legacy WatchRule's own\nnamespace. An implicit carve-out - would mean the field does not actually bound what arrives\nhere, - so a reader auditing it would be wrong about the target's contents - — which is the\nwhole reason the field exists. The resulting authoring - footgun (adding a policy for one\noverride silently denies co-resident - legacy rules) is mitigated by being LOUD:\nSourceNamespaceAuthorized=False, - Stalled=True, and a message naming the exact fix.\n\nIts selector - matches labels on Namespaces in the SOURCE cluster this target mirrors - from —\nnot the control cluster ClusterProvider.allowedNamespaces - describes. Evaluating it therefore\nneeds Namespace get/list/watch - for the identity in that cluster's credential; an\nexact-NAMES entry - stays usable without it, which is a deliberate degradation path - that also\nkeeps \"*\" resolvable against a names-only policy.\n\nEmpty - or omitted are NOT the same: omitted declares no policy (a WatchRule - keeps its own\nnamespace), while a declared-but-empty policy admits - nothing. Naming any namespace other\nthan the WatchRule's own — - including \"*\" — additionally requires the ClusterProvider to set\nspec.allowSourceNamespaceOverride.\n\nIt - does NOT bound ClusterWatchRule. Cluster-scoped objects have no - namespace, so a\nClusterWatchRule is intentionally cluster-global - and this field is neither consulted nor a\nbound for it; isolating - cluster-scoped objects between tenants takes separate source\ncredentials/ClusterProviders." + 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. + 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 @@ -206,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 7c2ce7b4..145f95b8 100644 --- a/config/crd/bases/configbutler.ai_watchrules.yaml +++ b/config/crd/bases/configbutler.ai_watchrules.yaml @@ -46,24 +46,14 @@ spec: schema: openAPIV3Schema: description: |- - WatchRule selects NAMESPACED resources on the source cluster its GitTarget mirrors from. It is - the namespaced half of the two-object model: scope is carried by the rule KIND, so a WatchRule - never selects cluster-scoped types and a ClusterWatchRule never selects namespaced ones. + 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. - It provides fine-grained control over which resources trigger Git commits, with filtering by - operation type, API group, version, and source namespace. - - Security model: - - Each spec.rules[] item watches its own source namespace: the WatchRule's OWN namespace when - omitted, an explicit name, or "*" for every namespace the GitTarget admits. Anything other - than its own namespace passes the three-part gate described on rules[].sourceNamespace — - provider admission, an explicit provider-side delegation flag, and the GitTarget's - allowedSourceNamespaces. The gate is deny-by-default and re-evaluated on every reconcile, - so a policy tightened later revokes a running rule. - - Use ClusterWatchRule for watching cluster-scoped resources (Nodes, ClusterRoles, etc.) - - RBAC controls who can create/modify WatchRules per namespace. Note that where the source is - the operator's OWN cluster, an authorized override deliberately bypasses live namespace - RBAC — which is why it takes an explicit platform-admin delegation to enable. + 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: |- @@ -167,37 +157,19 @@ spec: minItems: 1 type: array sourceNamespace: - description: "SourceNamespace is the namespace this item watches - IN THE SOURCE CLUSTER the referenced\nGitTarget mirrors from.\n\n\t| - value | meaning |\n\t| - omitted | this WatchRule's own namespace — the legacy - behavior |\n\t| an exact name | one source - namespace, subject to the gate below |\n\t| - \"*\" | every namespace GitTarget.spec.allowedSourceNamespaces - admits, live |\n\nNaming a namespace OTHER than this WatchRule's - own — including \"*\" — requires all three of:\n\n 1. the - GitTarget's namespace is admitted by its ClusterProvider (spec.allowedNamespaces);\n - 2. that ClusterProvider sets spec.allowSourceNamespaceOverride; - and\n 3. the GitTarget's spec.allowedSourceNamespaces admits - the namespace.\n\n\"*\" never means \"every namespace that - exists\": it expands to exactly what the GitTarget's\npolicy - admits, so a target that declares no policy denies it. The - outcome for all items is\naggregated into the one SourceNamespaceAuthorized - condition. A DENIED explicit name refuses\nthe whole WatchRule - rather than silently trimming the item; a \"*\" that admits - nothing is\nvalid, starts no stream for this item, and is - reported as NoAdmittedSourceNamespaces.\n\nOnce the GitTarget - declares a policy that policy is exhaustive, so even an OMITTED\nsourceNamespace - is then checked against it — the rule's own namespace gets - no carve-out.\n\nThis changes only which namespace is WATCHED. - It never changes where objects are written:\nGit placement - follows each mirrored object's OWN namespace, so a rule in - \"tenant-acme\"\nwatching \"repo-config\" writes under repo-config/…, - not tenant-acme/….\n\nCost note: a \"*\" item opens one watch - stream per (matched type × admitted namespace) and\nproduces - one resync scope each, rather than a single cluster-wide stream. - That is deliberate —\nit keeps every replay scoped to one - namespace — but it is a real fan-out on a broad policy." + 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 @@ -209,14 +181,8 @@ spec: sourceNamespace: description: |- SourceNamespace is REMOVED. It moved to spec.rules[].sourceNamespace, so that the source - namespace sits beside the resource selector it applies to. - - The field is retained in the schema for one release purely so that re-applying a manifest - that still sets it FAILS. Deleting it outright would be worse and silent: CRD pruning happens - on write, so an unrecognised top-level field is dropped without an error and the rule would - quietly watch its own namespace instead of the one it asked for. The XValidation rule on - spec rejects any value at admission, and the compile path refuses a stored one with the same - message. + namespace sits beside the resource selector it applies to. Setting it is rejected; the field + remains in the schema for one release only so that doing so fails loudly. Deprecated: use spec.rules[].sourceNamespace. Removed one release from now, or at v1beta1. minLength: 1 diff --git a/internal/authz/source_namespace.go b/internal/authz/source_namespace.go index 0919c0f8..3c96b601 100644 --- a/internal/authz/source_namespace.go +++ b/internal/authz/source_namespace.go @@ -322,6 +322,24 @@ func (g *itemGate) decide( 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 } diff --git a/internal/authz/source_namespace_test.go b/internal/authz/source_namespace_test.go index 69403a50..6b8f1c8e 100644 --- a/internal/authz/source_namespace_test.go +++ b/internal/authz/source_namespace_test.go @@ -581,6 +581,45 @@ func TestResolveWatchRuleSourceScope_ExactNamesNeedNoResolver(t *testing.T) { }) } +// 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 From 80899d235e201ca1760771f1c8dca777c09f6635 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 21 Jul 2026 12:43:27 +0000 Subject: [PATCH 17/18] docs: correct the PR 4 migration guidance and record the review follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three corrections, all found by reviewing PR 4 against the code: - The migration warning said a target with no declared policy "admits nothing". ReasonLegacySourceNamespace disagrees: a WatchRule whose every item watches its own namespace keeps working with no policy and no delegation flag. Stated unqualified, UPGRADING.md would tell every existing operator their rules break on upgrade, which is false and the more expensive of the two possible errors. The denial applies to converted wildcard and cross-namespace items. - The historical baseline said PR 4 defers selector-backed wildcards. PR 4 ships them and resolves them from the source-namespace snapshot. - A facts doc still said ClusterWatchRule watches namespaced resources. Adds pr4-review-followups.md, the intake for both review passes. Each item was re-verified against the code before being accepted, and one reported blocker did not survive that check — its premise (that Kubernetes reads `*` as an all-namespaces watch) is false, though its recommendation stands for a different reason. The doc records what landed and which test holds it. Also carries architecture.md revisions from a parallel review pass: per-ClusterProvider source contexts, the control-plane/source-cluster split in the flow diagrams, and the distinction between the object mark-and-sweep and the Namespace-label listing that maintains selector authorization. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/architecture.md | 163 +++++++---- .../watchrule-source-namespace/README.md | 12 +- ...cal-top-level-source-namespace-baseline.md | 2 +- .../pr4-cluster-scope-only.md | 8 +- .../pr4-review-followups.md | 253 ++++++++++++++++++ docs/facts/kubernetes-api-discovery.md | 3 +- 6 files changed, 381 insertions(+), 60 deletions(-) create mode 100644 docs/design/watchrule-source-namespace/pr4-review-followups.md diff --git a/docs/architecture.md b/docs/architecture.md index 5bdb6a8a..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,6 +195,13 @@ 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 NAMESPACED resources, in which source namespaces, route to a GitTarget | @@ -210,13 +232,24 @@ namespace selection of its own. Both share the rule model: * `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. + 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) @@ -311,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"] @@ -352,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 @@ -520,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 @@ -683,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 @@ -796,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 @@ -818,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. *** @@ -839,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)`. @@ -855,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/design/watchrule-source-namespace/README.md b/docs/design/watchrule-source-namespace/README.md index b746cfaa..ebc7aa42 100644 --- a/docs/design/watchrule-source-namespace/README.md +++ b/docs/design/watchrule-source-namespace/README.md @@ -3,7 +3,8 @@ > **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. Index: [INDEX.md](../../INDEX.md). +> 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 @@ -124,8 +125,13 @@ direction. PR 4 is deliberately breaking: did not. There is no migration tool. The breaking change is carried by the release notes and -[UPGRADING.md](../../UPGRADING.md), which must state that a target with no declared policy admits -nothing — so converting without declaring `allowedSourceNamespaces` narrows what is mirrored. +[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 diff --git a/docs/design/watchrule-source-namespace/historical-top-level-source-namespace-baseline.md b/docs/design/watchrule-source-namespace/historical-top-level-source-namespace-baseline.md index edd437d6..be202363 100644 --- a/docs/design/watchrule-source-namespace/historical-top-level-source-namespace-baseline.md +++ b/docs/design/watchrule-source-namespace/historical-top-level-source-namespace-baseline.md @@ -278,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. | A WatchRule already running under a selector-resolved scope. PR 4 defers selector-backed wildcards, but preserves this exact-name selector contract. | +| 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`**. | diff --git a/docs/design/watchrule-source-namespace/pr4-cluster-scope-only.md b/docs/design/watchrule-source-namespace/pr4-cluster-scope-only.md index 3f9cd6fb..65620dfa 100644 --- a/docs/design/watchrule-source-namespace/pr4-cluster-scope-only.md +++ b/docs/design/watchrule-source-namespace/pr4-cluster-scope-only.md @@ -549,9 +549,11 @@ Instead: 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 **nothing**, 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; + 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. 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/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 From 521859856a6cb27c37840a0ff79caaf27e59b3d4 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Tue, 21 Jul 2026 12:49:09 +0000 Subject: [PATCH 18/18] refactor(api)!: delete WatchRule.spec.sourceNamespace instead of deprecating it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field was carried as a "loud rejection" — a CEL guard, a SourceNamespaceFieldRemoved reason, and a compile-path refusal — on the same reasoning as ClusterResourceRule.scope: deleting a field is the SILENT option, because CRD pruning happens on write, so a re-applied legacy manifest is accepted with the value dropped and the rule quietly changes what it mirrors. That reasoning is about STORED objects, and it does not apply here. spec.sourceNamespace has never existed on main — not in the Go type, not in the generated CRD — so no cluster's etcd can hold one and no manifest in the wild sets one. The only population it protected was this repo's own pre-release fixtures, which move with the change. Retaining a rejection for a field nobody can have set is ceremony, and it leaves a phantom in `kubectl explain` that readers have to be told to ignore. ClusterResourceRule.scope keeps its rejection: that field DID ship, so a stored `Namespaced` value is a real object in a real cluster. Removes the field, the CEL guard, DeclaresRemovedSourceNamespace(), the SourceNamespaceFieldRemoved reason and its refusal path, and the four tests that covered them. UPGRADING.md now describes one retained shim rather than two, with the never-shipped field stated as simply absent. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/v1alpha3/namespace_matcher_test.go | 12 ------ api/v1alpha3/watchrule_types.go | 26 ------------- .../crd/bases/configbutler.ai_watchrules.yaml | 12 ------ docs/UPGRADING.md | 17 ++++---- .../watchrule-source-namespace/README.md | 10 +++-- .../pr4-cluster-scope-only.md | 39 +++++++++---------- internal/authz/source_namespace.go | 28 ------------- internal/authz/source_namespace_test.go | 21 ---------- .../superseded_fields_admission_test.go | 22 ----------- .../controller/watchrule_source_namespace.go | 3 -- .../watchrule_source_namespace_test.go | 25 ------------ 11 files changed, 34 insertions(+), 181 deletions(-) diff --git a/api/v1alpha3/namespace_matcher_test.go b/api/v1alpha3/namespace_matcher_test.go index 067ce2ed..71c27993 100644 --- a/api/v1alpha3/namespace_matcher_test.go +++ b/api/v1alpha3/namespace_matcher_test.go @@ -172,18 +172,6 @@ func TestResourceRule_EffectiveSourceNamespace(t *testing.T) { assert.True(t, item.OverridesSourceNamespace(own)) } -// TestWatchRule_DeclaresRemovedSourceNamespace covers the stored-object half of decision 10: the -// field is rejected at admission, but a pre-release object keeps its value in etcd and the compile -// path must still see it. -func TestWatchRule_DeclaresRemovedSourceNamespace(t *testing.T) { - rule := &WatchRule{ObjectMeta: metav1.ObjectMeta{Name: "r", Namespace: "tenant-acme"}} - assert.False(t, rule.DeclaresRemovedSourceNamespace()) - - rule.Spec.SourceNamespace = "repo-config" - assert.True(t, rule.DeclaresRemovedSourceNamespace(), - "a stored top-level sourceNamespace must be visible to Go, or the refusal has nothing to key on") -} - // 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) { diff --git a/api/v1alpha3/watchrule_types.go b/api/v1alpha3/watchrule_types.go index 971538a7..05fae6f6 100644 --- a/api/v1alpha3/watchrule_types.go +++ b/api/v1alpha3/watchrule_types.go @@ -47,30 +47,12 @@ type LocalTargetReference struct { // 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. -// +kubebuilder:validation:XValidation:rule="!has(self.sourceNamespace)",message="spec.sourceNamespace moved to spec.rules[].sourceNamespace" type WatchRuleSpec struct { // TargetRef references the GitTarget to use. // Must be in the same namespace. // +required TargetRef LocalTargetReference `json:"targetRef"` - // 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 sets it - // FAILS. Deleting it outright would be worse and silent: CRD pruning happens on write, so an - // unrecognised top-level field is dropped without an error and the rule would quietly watch its - // own namespace instead of the one it asked for. The XValidation rule on spec rejects any value - // at admission, and the compile path refuses a stored one with the same message. - - // SourceNamespace is REMOVED. It moved to spec.rules[].sourceNamespace, so that the source - // namespace sits beside the resource selector it applies to. Setting it is rejected; the field - // remains in the schema for one release only so that doing so fails loudly. - // - // Deprecated: use spec.rules[].sourceNamespace. Removed one release from now, or at v1beta1. - // +optional - // +kubebuilder:validation:MinLength=1 - SourceNamespace string `json:"sourceNamespace,omitempty"` - // 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, resource types, and a source namespace. @@ -303,14 +285,6 @@ type WatchRule struct { Status WatchRuleStatus `json:"status,omitempty"` } -// DeclaresRemovedSourceNamespace reports whether a STORED WatchRule still carries the removed -// top-level spec.sourceNamespace. Admission rejects it, but an object written before this release -// keeps its value in etcd, so the compile path must refuse it too — otherwise the rule would -// silently watch its own namespace instead of the one it asked for. -func (w *WatchRule) DeclaresRemovedSourceNamespace() bool { - return w.Spec.SourceNamespace != "" -} - // +kubebuilder:object:root=true // WatchRuleList contains a list of WatchRule. diff --git a/config/crd/bases/configbutler.ai_watchrules.yaml b/config/crd/bases/configbutler.ai_watchrules.yaml index 145f95b8..af458b92 100644 --- a/config/crd/bases/configbutler.ai_watchrules.yaml +++ b/config/crd/bases/configbutler.ai_watchrules.yaml @@ -178,15 +178,6 @@ spec: type: object minItems: 1 type: array - sourceNamespace: - description: |- - SourceNamespace is REMOVED. It moved to spec.rules[].sourceNamespace, so that the source - namespace sits beside the resource selector it applies to. Setting it is rejected; the field - remains in the schema for one release only so that doing so fails loudly. - - Deprecated: use spec.rules[].sourceNamespace. Removed one release from now, or at v1beta1. - minLength: 1 - type: string targetRef: description: |- TargetRef references the GitTarget to use. @@ -218,9 +209,6 @@ spec: - rules - targetRef type: object - x-kubernetes-validations: - - message: spec.sourceNamespace moved to spec.rules[].sourceNamespace - rule: '!has(self.sourceNamespace)' status: description: status defines the observed state of WatchRule properties: diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index 2e905d5e..2fa299fa 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -36,18 +36,19 @@ spec: `GitTarget.spec.allowedSourceNamespaces: {selector: {}}` plus `sourceNamespace: "*"` — same reach, declared by the `GitTarget` owner rather than by the rule author, and legible on the target. -### Both superseded fields are retained for one release as loud rejections +### `ClusterWatchRule.spec.rules[].scope` is retained for one release as a loud rejection -Neither field was deleted, because a deleted field is the **silent** option: CRD pruning happens on +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 change what it mirrors. +and the rule would quietly stop mirroring namespaced objects. -| Field | Now | -|---|---| -| `ClusterWatchRule.spec.rules[].scope` | optional, defaults to `Cluster`, and the enum accepts only `Cluster`. `scope: Namespaced` is **rejected at apply time**; a stored one is refused at compile with `ClusterScopeOnly`. | -| `WatchRule.spec.sourceNamespace` | **rejected at apply time** (`spec.sourceNamespace moved to spec.rules[].sourceNamespace`); a stored one is refused at compile with `SourceNamespaceFieldRemoved`. | +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`. -Both are 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` diff --git a/docs/design/watchrule-source-namespace/README.md b/docs/design/watchrule-source-namespace/README.md index ebc7aa42..184cd7a5 100644 --- a/docs/design/watchrule-source-namespace/README.md +++ b/docs/design/watchrule-source-namespace/README.md @@ -116,10 +116,12 @@ This is preliminary v1alpha3 API. PR 5 changes the default effective sweep behav direction. PR 4 is deliberately breaking: - The unshipped `WatchRule.spec.sourceNamespace` field is replaced with - `WatchRule.spec.rules[].sourceNamespace`; it never reaches a release. -- `ClusterResourceRule.scope` narrows to `Cluster` only. Both superseded fields stay in the schema for - one release as **loud rejections** rather than being deleted, because a deleted field is silently - pruned from a re-applied legacy manifest. + `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. diff --git a/docs/design/watchrule-source-namespace/pr4-cluster-scope-only.md b/docs/design/watchrule-source-namespace/pr4-cluster-scope-only.md index 65620dfa..36a9beb1 100644 --- a/docs/design/watchrule-source-namespace/pr4-cluster-scope-only.md +++ b/docs/design/watchrule-source-namespace/pr4-cluster-scope-only.md @@ -182,9 +182,11 @@ implementation choices to revisit while coding. 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. **Both superseded fields stay in the schema for one release as loud rejections** rather than being - deleted outright. Deleting a field makes a re-applied legacy manifest *silently pruned*; keeping it - makes the apply fail. See [§1](#1-clusterwatchrule-becomes-cluster-only) and +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 `"*"`. @@ -256,20 +258,17 @@ kind. | 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 | -**Retire `WatchRule.spec.sourceNamespace` the same way as `scope`** -([watchrule_types.go:53-72](../../../api/v1alpha3/watchrule_types.go#L53-L72)), and for the same reason: -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 — a -silent scope change, the failure class this whole folder exists to remove. Keep the field for one -release and reject it structurally: +**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. -~~~go -// +kubebuilder:validation:XValidation:rule="!has(self.sourceNamespace)",message="spec.sourceNamespace moved to spec.rules[].sourceNamespace" -~~~ - -and refuse a stored value in the compile path with the same message. The field never reached a -release, so the only manifests affected are pre-release ones — including this repo's own fixtures — -which is exactly the population that would otherwise fail silently in CI. +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 @@ -638,8 +637,8 @@ Fixture conversions worth calling out, because they change what the test proves: `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`, or `spec.sourceNamespace`, is rejected by the API server (envtest against - the generated CRDs) — the guard that the fields were narrowed rather than deleted. +- 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** @@ -686,8 +685,8 @@ Fixture conversions worth calling out, because they change what the test proves: ## Done when -- The public `scope` choice and the top-level `sourceNamespace` are unreachable: rejected at admission, - refused at compile, ignored at resolution. +- 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 diff --git a/internal/authz/source_namespace.go b/internal/authz/source_namespace.go index 3c96b601..388235a4 100644 --- a/internal/authz/source_namespace.go +++ b/internal/authz/source_namespace.go @@ -53,12 +53,6 @@ const ( // 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" - - // ReasonSourceNamespaceFieldRemoved is the TERMINAL False reason for a STORED WatchRule that - // still carries the removed top-level spec.sourceNamespace. Admission rejects the field, but an - // object written before this release keeps its value in etcd; refusing it here is what stops - // the rule from silently watching its own namespace instead of the one it asked for. - ReasonSourceNamespaceFieldRemoved = "SourceNamespaceFieldRemoved" ) // SourceScopeVerdict is the THREE-valued answer a source-namespace policy evaluation produces. @@ -227,13 +221,6 @@ func ResolveWatchRuleSourceScope( target *configv1alpha3.GitTarget, resolver SourceNamespaceResolver, ) (ResolvedSourceScope, error) { - // A STORED object carrying the removed top-level field is refused before anything else: it - // asked for a namespace this controller no longer reads, and resolving the items as if it had - // not asked is precisely the silent scope change this design exists to remove. - if rule.DeclaresRemovedSourceNamespace() { - return removedFieldScope(rule), nil - } - gate := &itemGate{reader: reader, rule: rule, target: target, resolver: resolver} items := make([]SourceNamespaceDecision, 0, len(rule.Spec.Rules)) for i := range rule.Spec.Rules { @@ -246,21 +233,6 @@ func ResolveWatchRuleSourceScope( return aggregateSourceScope(items), nil } -// removedFieldScope is the terminal refusal for a stored spec.sourceNamespace. Reading the -// deprecated field is the entire point: it is retained precisely so a stored value stays visible to -// Go and can be refused instead of silently ignored. -func removedFieldScope(rule *configv1alpha3.WatchRule) ResolvedSourceScope { - msg := fmt.Sprintf( - "WatchRule %s/%s still sets spec.sourceNamespace (%q): that field moved to "+ - "spec.rules[].sourceNamespace; move the value onto the rule items it applies to", - rule.Namespace, rule.Name, rule.Spec.SourceNamespace) //nolint:staticcheck // deliberate: see above - return ResolvedSourceScope{ - Verdict: SourceScopeDenied, - Reason: ReasonSourceNamespaceFieldRemoved, - Message: msg, - } -} - // 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 diff --git a/internal/authz/source_namespace_test.go b/internal/authz/source_namespace_test.go index 6b8f1c8e..9db254a0 100644 --- a/internal/authz/source_namespace_test.go +++ b/internal/authz/source_namespace_test.go @@ -438,27 +438,6 @@ func TestResolveWatchRuleSourceScope_WildcardOverNamesNeedsNoResolver(t *testing "a names-only policy must never reach the source-scope service") } -// TestResolveWatchRuleSourceScope_StoredTopLevelFieldIsRefused covers decision 10's stored-object -// half: admission rejects spec.sourceNamespace, but a pre-release object keeps its value in etcd and -// resolving the items as if it had not asked would silently watch the wrong namespace. -func TestResolveWatchRuleSourceScope_StoredTopLevelFieldIsRefused(t *testing.T) { - target := snTarget(nil) - cl := fake.NewClientBuilder().WithScheme(snScheme(t)). - WithObjects(target, snClusterProvider(true)).Build() - - rule := snRule("") - rule.Spec.SourceNamespace = snSourceNS //nolint:staticcheck // the point of the test - - resolved, err := authz.ResolveWatchRuleSourceScope(context.Background(), cl, rule, target, nil) - - require.NoError(t, err) - assert.Equal(t, authz.SourceScopeDenied, resolved.Verdict) - assert.Equal(t, authz.ReasonSourceNamespaceFieldRemoved, resolved.Reason) - assert.Contains(t, resolved.Message, "spec.rules[].sourceNamespace", - "the refusal must name the replacement, because the move is not automatic") - assert.Empty(t, resolved.Items, "a refused rule resolves no items") -} - // 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. diff --git a/internal/controller/superseded_fields_admission_test.go b/internal/controller/superseded_fields_admission_test.go index a775da89..e647c8f2 100644 --- a/internal/controller/superseded_fields_admission_test.go +++ b/internal/controller/superseded_fields_admission_test.go @@ -68,28 +68,6 @@ var _ = Describe("Superseded source-scope fields are rejected, not pruned", func "the field is omittable and defaults to Cluster, so a converted manifest need not set it") }) - It("rejects the removed top-level WatchRule spec.sourceNamespace at admission", func() { - ctx := context.Background() - - rule := &configbutleraiv1alpha3.WatchRule{ - ObjectMeta: metav1.ObjectMeta{Name: "legacy-top-level-source", Namespace: "default"}, - Spec: configbutleraiv1alpha3.WatchRuleSpec{ - TargetRef: configbutleraiv1alpha3.LocalTargetReference{Name: "any-target"}, - SourceNamespace: "repo-config", - Rules: []configbutleraiv1alpha3.ResourceRule{{ - Resources: []string{"configmaps"}, - }}, - }, - } - - err := k8sClient.Create(ctx, rule) - - Expect(err).To(HaveOccurred(), - "a manifest that still sets the top-level field must FAIL, never be silently pruned") - Expect(err.Error()).To(ContainSubstring("spec.rules[].sourceNamespace"), - "and the rejection must name the replacement") - }) - It("accepts rules[].sourceNamespace, including the wildcard", func() { ctx := context.Background() diff --git a/internal/controller/watchrule_source_namespace.go b/internal/controller/watchrule_source_namespace.go index 7ab6aa20..410dc3bd 100644 --- a/internal/controller/watchrule_source_namespace.go +++ b/internal/controller/watchrule_source_namespace.go @@ -36,9 +36,6 @@ const ( // WatchRuleReasonCheckingSourceNamespacePolicy is the Unknown reason while the answer is still // being established or a retryable source-cluster error is being retried. WatchRuleReasonCheckingSourceNamespacePolicy = authz.ReasonCheckingSourceNamespacePolicy - // WatchRuleReasonSourceNamespaceFieldRemoved is the TERMINAL False reason for a stored rule - // that still carries the removed top-level spec.sourceNamespace. - WatchRuleReasonSourceNamespaceFieldRemoved = authz.ReasonSourceNamespaceFieldRemoved ) // gateSourceNamespace is the WatchRule source-namespace gate and the ONE place this controller diff --git a/internal/controller/watchrule_source_namespace_test.go b/internal/controller/watchrule_source_namespace_test.go index 5f3d6be7..ba497d69 100644 --- a/internal/controller/watchrule_source_namespace_test.go +++ b/internal/controller/watchrule_source_namespace_test.go @@ -578,28 +578,3 @@ func TestReconcile_EmptyWildcardIsAuthorizedButNotSilentlyHealthy(t *testing.T) assert.Equal(t, metav1.ConditionFalse, wrsnCondition(t, rule, ConditionTypeStalled).Status, "a rule with nothing to watch is not stalled — nothing is wrong with it") } - -// TestReconcile_StoredTopLevelSourceNamespaceIsRefused covers decision 10's stored-object half at -// the reconciler. Admission rejects the field, but an object written before this release keeps its -// value in etcd; resolving the items as if it had not asked would silently watch the wrong namespace. -func TestReconcile_StoredTopLevelSourceNamespaceIsRefused(t *testing.T) { - ctx := context.Background() - objects := wrsnBaseObjects(nil, true, "") - for _, obj := range objects { - if rule, ok := obj.(*configbutleraiv1alpha3.WatchRule); ok { - rule.Spec.SourceNamespace = wrsnSourceNS //nolint:staticcheck // simulating a stored object - } - } - f := newWRSNFixture(t, objects) - - _, err := f.reconcile(ctx) - require.NoError(t, err) - - assert.Empty(t, f.compiledNames(), "a stored top-level sourceNamespace must compile nothing") - - cond := wrsnCondition(t, f.reloadRule(ctx, t), ConditionTypeSourceNamespaceAuthorized) - assert.Equal(t, metav1.ConditionFalse, cond.Status) - assert.Equal(t, WatchRuleReasonSourceNamespaceFieldRemoved, cond.Reason) - assert.Contains(t, cond.Message, "spec.rules[].sourceNamespace", - "the refusal must name the replacement, because the move is not automatic") -}